1
0
Fork 0
discord-tweeter/pkg/web/web.go
Manuel 21d580d1a6
feat: Major refactor, implement web, caching, better tests and build files
* Update golang version to 1.24
* Update multiarch Dockerfile to be more ISA agnostic
* Refactor existing code and properly structure project into modules
* Get rid of global variables except where necessary (go:embed)
* Add default values to Config
* Add webserver with templates to finally correctly serve videos and gifs
* Add tiny caching library to decrease api load and improve latency
* Improve Webhook data preparation by filtering out redundant links
  from the tweet text and properly attaching videos and gifs in separate
  webhook request by utilising new webserver
* Improve tests for filter function
* Improve bake definition for easier CI integration
2025-03-18 19:22:00 +01:00

211 lines
5.2 KiB
Go

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)
}