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.
522 lines
16 KiB
Go
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)
|
|
}
|