218 lines
5.4 KiB
Go
218 lines
5.4 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 Response 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, Response]
|
|
|
|
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, Response](),
|
|
&http.Server{
|
|
Handler: sm,
|
|
Addr: fmt.Sprintf(":%d", config.WebPort),
|
|
ReadTimeout: 30 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
},
|
|
}
|
|
sm.HandleFunc("GET /", ws.handleIndex)
|
|
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) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|
if ws.config.IndexTarget != "" {
|
|
http.Redirect(w, r, ws.config.IndexTarget, http.StatusPermanentRedirect)
|
|
}
|
|
}
|
|
|
|
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 := Response{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 := Response{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, Response{http.StatusBadRequest, "text/plain", "Bad Request"})
|
|
}
|
|
|
|
func serverError(w http.ResponseWriter) {
|
|
response(w, Response{http.StatusInternalServerError, "text/plain", "Internal Server Error"})
|
|
}
|
|
|
|
func response(w http.ResponseWriter, res Response) {
|
|
w.WriteHeader(res.StatusCode)
|
|
w.Header().Set("Content-Type", res.ContentType)
|
|
fmt.Fprint(w, res.Content)
|
|
}
|