package api import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/squid/rcs/backend/internal/config" "github.com/squid/rcs/backend/internal/db" "github.com/squid/rcs/backend/internal/imgur" "github.com/squid/rcs/backend/internal/web" ) // Server is the HTTP API and Web UI. type Server struct { cfg config.Config store *db.Store imgur *imgur.Client router chi.Router webUI *web.UI } // New builds the HTTP server with routes. func New(cfg config.Config, store *db.Store, imgurClient *imgur.Client, webUI *web.UI) *Server { s := &Server{ cfg: cfg, store: store, imgur: imgurClient, webUI: webUI, } s.router = s.routes() return s } // Handler returns the root HTTP handler. func (s *Server) Handler() http.Handler { return s.router } func (s *Server) routes() chi.Router { r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) if s.cfg.TrustProxy { r.Use(trustForwardedHeaders) } r.Use(privateNetworkAccess) r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"}, ExposedHeaders: []string{"Link"}, AllowCredentials: false, MaxAge: 300, })) r.Get("/api/health", s.handleHealth) r.Group(func(protected chi.Router) { protected.Use(s.apiKeyMiddleware) protected.Get("/api/comments/exists", s.handleCommentExists) protected.Get("/api/posts/exists", s.handlePostExists) protected.Post("/api/comments", s.handleSaveComment) protected.Post("/api/comments/{id}/screenshot", s.handleAttachScreenshot) protected.Post("/api/posts", s.handleSavePost) protected.Get("/api/search", s.handleSearchJSON) protected.Post("/api/comments/{id}/imgur", s.handleImgurExport) protected.Get("/api/blocklist", s.handleListBlocklist) protected.Put("/api/blocklist", s.handleReplaceBlocklist) protected.Post("/api/blocklist", s.handleAddBlocked) protected.Delete("/api/blocklist/{username}", s.handleRemoveBlocked) }) r.Get("/screenshots/{file}", s.handleScreenshot) r.Get("/", s.webUI.HandleIndex) r.Post("/export/{id}", s.webUI.HandleExport) r.Handle("/static/*", s.webUI.StaticHandler()) return r } func (s *Server) apiKeyMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.cfg.APIKey == "" { next.ServeHTTP(w, r) return } key := r.Header.Get("X-API-Key") if key == "" { auth := r.Header.Get("Authorization") if strings.HasPrefix(strings.ToLower(auth), "bearer ") { key = strings.TrimSpace(auth[7:]) } } if key != s.cfg.APIKey { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } next.ServeHTTP(w, r) }) } // privateNetworkAccess allows Chrome pages on public origins (e.g. reddit.com) // to call a localhost/LAN backend (CORS Private Network Access preflight). func privateNetworkAccess(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Private-Network", "true") if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Private-Network") == "true" { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-API-Key") w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) }) } // trustForwardedHeaders applies X-Forwarded-Host / X-Forwarded-Proto from a trusted reverse proxy. func trustForwardedHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if host := firstForwardedValue(r.Header.Get("X-Forwarded-Host")); host != "" { r.Host = host } if proto := firstForwardedValue(r.Header.Get("X-Forwarded-Proto")); proto != "" && r.URL != nil { r.URL.Scheme = proto } next.ServeHTTP(w, r) }) } func firstForwardedValue(header string) string { if header == "" { return "" } parts := strings.Split(header, ",") return strings.TrimSpace(parts[0]) } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "imgur": s.imgur.Enabled(), }) } func (s *Server) handleCommentExists(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id required"}) return } status, err := s.store.GetCommentCaptureStatus(r.Context(), id) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, status) } func (s *Server) handleAttachScreenshot(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(chi.URLParam(r, "id")) if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id required"}) return } var req struct { ScreenshotBase64 string `json:"screenshot_base64"` } if err := json.NewDecoder(io.LimitReader(r.Body, 20<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } if strings.TrimSpace(req.ScreenshotBase64) == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "screenshot_base64 required"}) return } status, err := s.store.GetCommentCaptureStatus(r.Context(), id) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !status.Exists { writeJSON(w, http.StatusNotFound, map[string]string{"error": "comment not found"}) return } if status.HasScreenshot { writeJSON(w, http.StatusOK, map[string]any{ "updated": false, "has_screenshot": true, "screenshot_path": status.ScreenshotPath, }) return } path, err := s.saveScreenshot(id, req.ScreenshotBase64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } updated, err := s.store.SetCommentScreenshot(r.Context(), id, path) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{ "updated": updated, "has_screenshot": true, "screenshot_path": path, }) } func (s *Server) handlePostExists(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id required"}) return } exists, err := s.store.PostExists(r.Context(), id) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]bool{"exists": exists}) } type saveCommentRequest struct { RedditCommentID string `json:"reddit_comment_id"` Body string `json:"body"` AuthorID string `json:"author_id"` AuthorName string `json:"author_name"` RedditPostID string `json:"reddit_post_id"` PostTitle string `json:"post_title"` PostBody string `json:"post_body"` PostAuthorID string `json:"post_author_id"` PostAuthorName string `json:"post_author_name"` PostPermalink string `json:"post_permalink"` Subreddit string `json:"subreddit"` Permalink string `json:"permalink"` ScreenshotBase64 string `json:"screenshot_base64"` } func (s *Server) handleSaveComment(w http.ResponseWriter, r *http.Request) { var req saveCommentRequest if err := json.NewDecoder(io.LimitReader(r.Body, 20<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } req.RedditCommentID = strings.TrimSpace(req.RedditCommentID) req.RedditPostID = strings.TrimSpace(req.RedditPostID) if req.RedditCommentID == "" || req.RedditPostID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reddit_comment_id and reddit_post_id required"}) return } blocked, err := s.store.IsUsernameBlocked(r.Context(), req.AuthorName) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if blocked { writeJSON(w, http.StatusForbidden, map[string]any{ "saved": false, "blocked": true, "error": "author is on the blocklist", }) return } exists, err := s.store.CommentExists(r.Context(), req.RedditCommentID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if exists { writeJSON(w, http.StatusOK, map[string]any{"saved": false, "exists": true}) return } screenshotPath := "" if req.ScreenshotBase64 != "" { path, err := s.saveScreenshot(req.RedditCommentID, req.ScreenshotBase64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } screenshotPath = path } id, err := s.store.SaveComment(r.Context(), db.SaveCommentInput{ RedditCommentID: req.RedditCommentID, Body: req.Body, AuthorID: req.AuthorID, AuthorName: req.AuthorName, RedditPostID: req.RedditPostID, PostTitle: req.PostTitle, PostBody: req.PostBody, PostAuthorID: req.PostAuthorID, PostAuthorName: req.PostAuthorName, PostPermalink: req.PostPermalink, Subreddit: req.Subreddit, Permalink: req.Permalink, ScreenshotPath: screenshotPath, }) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusCreated, map[string]any{"saved": true, "id": id, "exists": false}) } type savePostRequest struct { RedditPostID string `json:"reddit_post_id"` Title string `json:"title"` Body string `json:"body"` AuthorID string `json:"author_id"` AuthorName string `json:"author_name"` Permalink string `json:"permalink"` Subreddit string `json:"subreddit"` } func (s *Server) handleSavePost(w http.ResponseWriter, r *http.Request) { var req savePostRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } req.RedditPostID = strings.TrimSpace(req.RedditPostID) if req.RedditPostID == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "reddit_post_id required"}) return } exists, err := s.store.PostExists(r.Context(), req.RedditPostID) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } id, err := s.store.SavePost(r.Context(), db.SavePostInput{ RedditPostID: req.RedditPostID, Title: req.Title, Body: req.Body, AuthorID: req.AuthorID, AuthorName: req.AuthorName, Permalink: req.Permalink, Subreddit: req.Subreddit, }) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } status := http.StatusCreated if exists { status = http.StatusOK } writeJSON(w, status, map[string]any{"saved": true, "id": id, "exists": exists}) } func (s *Server) handleListBlocklist(w http.ResponseWriter, r *http.Request) { names, err := s.store.ListBlockedUsernames(r.Context()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"usernames": names}) } func (s *Server) handleAddBlocked(w http.ResponseWriter, r *http.Request) { var req struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } name, err := s.store.AddBlockedUsername(r.Context(), req.Username) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } names, err := s.store.ListBlockedUsernames(r.Context()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"added": name, "usernames": names}) } func (s *Server) handleRemoveBlocked(w http.ResponseWriter, r *http.Request) { username := chi.URLParam(r, "username") if err := s.store.RemoveBlockedUsername(r.Context(), username); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } names, err := s.store.ListBlockedUsernames(r.Context()) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"usernames": names}) } func (s *Server) handleReplaceBlocklist(w http.ResponseWriter, r *http.Request) { var req struct { Usernames []string `json:"usernames"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"}) return } names, err := s.store.ReplaceBlockedUsernames(r.Context(), req.Usernames) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"usernames": names}) } func (s *Server) handleSearchJSON(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") user := r.URL.Query().Get("user") results, err := s.store.SearchComments(r.Context(), q, user, 100, 0) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"results": results}) } func (s *Server) handleImgurExport(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") url, err := s.exportToImgur(r, id) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]string{"imgur_url": url}) } func (s *Server) exportToImgur(r *http.Request, redditCommentID string) (string, error) { comment, err := s.store.GetCommentByRedditID(r.Context(), redditCommentID) if err != nil { return "", err } if comment == nil { return "", fmt.Errorf("comment not found") } if comment.ImgurURL != "" { return comment.ImgurURL, nil } if comment.ScreenshotPath == "" { return "", fmt.Errorf("no screenshot for this comment") } absPath := comment.ScreenshotPath if !filepath.IsAbs(absPath) { absPath = filepath.Join(s.cfg.DataDir, absPath) } link, err := s.imgur.UploadFile(absPath) if err != nil { return "", err } if err := s.store.SetImgurURL(r.Context(), redditCommentID, link); err != nil { return "", err } return link, nil } func (s *Server) handleScreenshot(w http.ResponseWriter, r *http.Request) { file := filepath.Base(chi.URLParam(r, "file")) if file == "." || file == "/" || strings.Contains(file, "..") { http.NotFound(w, r) return } path := filepath.Join(s.cfg.ScreenshotDir, file) http.ServeFile(w, r, path) } func (s *Server) saveScreenshot(redditCommentID, dataURL string) (string, error) { payload := dataURL if idx := strings.Index(dataURL, ","); idx >= 0 { payload = dataURL[idx+1:] } raw, err := base64.StdEncoding.DecodeString(payload) if err != nil { return "", fmt.Errorf("invalid screenshot base64: %w", err) } if err := os.MkdirAll(s.cfg.ScreenshotDir, 0o755); err != nil { return "", err } filename := sanitizeID(redditCommentID) + ".png" absPath := filepath.Join(s.cfg.ScreenshotDir, filename) if err := os.WriteFile(absPath, raw, 0o644); err != nil { return "", err } // Store path relative to data dir for portability. rel := filepath.Join("screenshots", filename) return rel, nil } func sanitizeID(id string) string { replacer := strings.NewReplacer("/", "_", "\\", "_", "..", "_", ":", "_") return replacer.Replace(id) } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) }