package web import ( "bytes" "embed" "fmt" "html/template" "net/http" "slices" "strings" "time" "git.snrd.eu/sunred/discord-tweeter/pkg/cache" "git.snrd.eu/sunred/discord-tweeter/pkg/config" ts "github.com/imperatrona/twitter-scraper" ) const ( AvatarCacheTime = 60 * time.Minute // How long to serve same avatar from cache before doing a fresh scrape TweetCacheTime = 10 * time.Minute // How long to serve same tweet from cache before doing a fresh scrape ) //go:embed templates/*.html var templateFiles embed.FS type Reponse struct { StatusCode int ContentType string Content string } type WebServer struct { config *config.Config scraper *ts.Scraper templates *template.Template avatarCache *cache.Cache[string, string] responseCache *cache.Cache[string, Reponse] Server *http.Server } type Tweet struct { URL string Link string Title string Text string Username string Timestamp string Format string Images []string Videos []string Previews []string } func New(config *config.Config, scraper *ts.Scraper) (*WebServer, error) { sm := http.NewServeMux() tmpl, err := template.ParseFS(templateFiles, "templates/*.html") if err != nil { return nil, err } ws := &WebServer{ config, scraper, tmpl, cache.New[string, string](), cache.New[string, Reponse](), &http.Server{ Handler: sm, Addr: fmt.Sprintf(":%d", config.WebPort), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second, }, } sm.HandleFunc("GET /avatar/{username}", ws.handleAvatar) sm.HandleFunc("GET /tweet/{id}", ws.handleTweet) sm.HandleFunc("GET /video/{id}", ws.handleVideo) return ws, nil } func (ws WebServer) handleAvatar(w http.ResponseWriter, r *http.Request) { username := r.PathValue("username") if !slices.Contains(ws.config.Channels, username) { badRequest(w) return } url, cached := ws.avatarCache.Get("avatar-" + username) if cached { http.Redirect(w, r, url, http.StatusPermanentRedirect) return } profile, err := ws.scraper.GetProfile(username) if err != nil { serverError(w) return } ws.avatarCache.Set("avatar-"+username, profile.Avatar, AvatarCacheTime) http.Redirect(w, r, profile.Avatar, http.StatusPermanentRedirect) } func (ws WebServer) handleTweet(w http.ResponseWriter, r *http.Request) { ws.handleTemplate(w, r, r.PathValue("id"), "tweet") } func (ws WebServer) handleVideo(w http.ResponseWriter, r *http.Request) { ws.handleTemplate(w, r, r.PathValue("id"), "video") } func (ws WebServer) handleTemplate(w http.ResponseWriter, r *http.Request, id string, template string) { if !validUserAgent(r.UserAgent(), ws.config.UserAgents) { http.Redirect(w, r, ws.config.NitterBase+"/i/status/"+id, http.StatusPermanentRedirect) return } entry, cached := ws.responseCache.Get(template + "-" + id) if cached { response(w, entry) return } tweet, err := ws.scraper.GetTweet(id) if err != nil { serverError(w) return } if !slices.Contains(ws.config.Channels, tweet.Username) { res := Reponse{http.StatusBadRequest, "text/plain", "Bad Request"} ws.responseCache.Set(template+"-"+id, res, TweetCacheTime) response(w, res) return } tweetText := tweet.Text var images []string var videos []string var previews []string for _, photo := range tweet.Photos { images = append(images, photo.URL) tweetText = strings.ReplaceAll(tweetText, photo.URL, "") } for _, gif := range tweet.GIFs { videos = append(images, gif.URL) previews = append(images, gif.Preview) tweetText = strings.ReplaceAll(tweetText, gif.URL, "") tweetText = strings.ReplaceAll(tweetText, gif.Preview, "") } for _, video := range tweet.Videos { videos = append(videos, video.URL) previews = append(previews, video.Preview) tweetText = strings.ReplaceAll(tweetText, video.URL, "") tweetText = strings.ReplaceAll(tweetText, video.Preview, "") } format := "summary_large_image" if len(videos) > 0 { format = "player" } data := Tweet{ URL: tweet.PermanentURL, Link: ws.config.NitterBase + "/" + tweet.Username + "/status/" + id, Title: tweet.Name + " (@" + tweet.Username + ")", Text: strings.TrimSpace(tweetText), Username: tweet.Username, Timestamp: tweet.TimeParsed.Format(time.RFC3339), Format: format, Images: images, Videos: videos, Previews: previews, } var tpl bytes.Buffer if err := ws.templates.ExecuteTemplate(&tpl, template+".html", data); err != nil { serverError(w) return } res := Reponse{http.StatusOK, "text/html", tpl.String()} ws.responseCache.Set(template+"-"+id, res, TweetCacheTime) response(w, res) } func validUserAgent(ua string, uas []string) bool { for _, s := range uas { if s == "*" || strings.Contains(strings.ToLower(ua), s) { return true } } return false } func badRequest(w http.ResponseWriter) { response(w, Reponse{http.StatusBadRequest, "text/plain", "Bad Request"}) } func serverError(w http.ResponseWriter) { response(w, Reponse{http.StatusInternalServerError, "text/plain", "Internal Server Error"}) } func response(w http.ResponseWriter, res Reponse) { w.WriteHeader(res.StatusCode) w.Header().Set("Content-Type", res.ContentType) fmt.Fprint(w, res.Content) }