Files
Squid 5a0e2e630b Add self-hosted RCS backend, extension, and ops tooling
Ship the Go/SQLite API and Web UI, Chrome/Brave capture addon,
Docker Compose, Pangolin reverse-proxy support, and a user-crontab
watchdog so the binary stays running without systemd.
2026-08-06 22:02:45 +02:00

522 lines
16 KiB
Go

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