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.
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user