diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fd7663 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Data & binaries +/data/*.db +/data/*.db-journal +/data/*.db-wal +/data/*.db-shm +/data/screenshots/* +!/data/screenshots/.gitkeep +/data/rcs.env +/data/rcs.pid +/data/rcs.log +/data/rcs.watchdog.lock +/backend/rcs +/backend/bin/ +/bin/ + +# Go +vendor/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +.DS_Store +.env +.env.local diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..804ea9c --- /dev/null +++ b/Makefile @@ -0,0 +1,63 @@ +.PHONY: backend build tidy test-api clean prune-dev-data install-cron uninstall-cron + +REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +WATCHDOG := $(REPO_ROOT)/scripts/rcs-watchdog.sh +CRON_MARK := \# RCS watchdog +CRON_LINE := * * * * * $(WATCHDOG) + +# Run the backend from the repo root (data/ next to Makefile). +backend: + cd backend && RCS_DATA_DIR=../data RCS_ADDR=127.0.0.1:8080 go run ./cmd/rcs + +build: + cd backend && go build -o ../bin/rcs ./cmd/rcs + +tidy: + cd backend && go mod tidy + +test-api: + curl -sS http://127.0.0.1:8080/api/health | tee /dev/stderr | grep -q '"ok":true' + +# Remove local seed/test SQLite rows; keep real Reddit captures. +prune-dev-data: + bash scripts/prune-dev-data.sh + +# Install a user crontab entry that runs the watchdog every minute. +install-cron: build + @mkdir -p "$(REPO_ROOT)/data" + @test -f "$(REPO_ROOT)/data/rcs.env" || cp "$(REPO_ROOT)/data/rcs.env.example" "$(REPO_ROOT)/data/rcs.env" + @chmod +x "$(WATCHDOG)" + @tmp="$$(mktemp)"; \ + (crontab -l 2>/dev/null || true) > "$$tmp"; \ + if grep -Fq "$(CRON_MARK)" "$$tmp"; then \ + echo "crontab already has RCS watchdog entry"; \ + else \ + printf '%s\n%s\n' "$(CRON_MARK)" "$(CRON_LINE)" >> "$$tmp"; \ + crontab "$$tmp"; \ + echo "installed: $(CRON_LINE)"; \ + fi; \ + rm -f "$$tmp" + +# Remove the RCS watchdog block from the user crontab. +uninstall-cron: + @tmp="$$(mktemp)"; \ + (crontab -l 2>/dev/null || true) > "$$tmp"; \ + if ! grep -Fq "$(CRON_MARK)" "$$tmp"; then \ + echo "no RCS watchdog crontab entry"; \ + rm -f "$$tmp"; \ + exit 0; \ + fi; \ + awk -v mark="$(CRON_MARK)" -v line="$(CRON_LINE)" '\ + $$0 == mark { skip=1; next } \ + skip && $$0 == line { skip=0; next } \ + skip { skip=0 } \ + { print }' "$$tmp" > "$$tmp.out"; \ + crontab "$$tmp.out"; \ + rm -f "$$tmp" "$$tmp.out"; \ + echo "removed RCS watchdog crontab entry" + +clean: + rm -rf bin/ data/*.db data/*.db-* data/screenshots/* + # keep screenshots/.gitkeep + mkdir -p data/screenshots + touch data/screenshots/.gitkeep diff --git a/README.md b/README.md index 50070cc..7acb883 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,155 @@ -# RCS +# RCS — Reddit Comment Saver -Reddit Comment Saved \ No newline at end of file +Self-hostable Go backend + Chrome/Brave extension that auto-saves Reddit comments (and the thread’s top post) from `www.reddit.com` and `old.reddit.com`, with screenshots, GLOB search, and optional Imgur export. + +## Quick local dev + +### 1. Backend + +```bash +git checkout dev +make backend +``` + +Opens `http://127.0.0.1:8080`. SQLite + screenshots live under `./data/`. + +To drop local seed/test rows (keeps real Reddit captures): + +```bash +make prune-dev-data +``` + +Optional env vars: + +| Variable | Default | Purpose | +|---|---|---| +| `RCS_ADDR` | `127.0.0.1:8080` | Listen address | +| `RCS_DATA_DIR` | `data` | DB + screenshots directory | +| `RCS_DB_PATH` | `$RCS_DATA_DIR/rcs.db` | SQLite path | +| `RCS_API_KEY` | _(empty)_ | If set, require `X-API-Key` on API routes | +| `RCS_PUBLIC_BASE_URL` | _(empty)_ | Canonical public origin (e.g. `https://rcs.example.com`) | +| `RCS_TRUST_PROXY` | _(off)_ | Set `1`/`true` to honor `X-Forwarded-Proto` / `X-Forwarded-Host` | +| `IMGUR_CLIENT_ID` | _(empty)_ | Enables Imgur export in the Web UI | + +### 2. Extension (Chrome / Brave) + +1. Open `chrome://extensions` (or `brave://extensions`) +2. Enable **Developer mode** +3. **Load unpacked** → select the `extension/` folder +4. Click the RCS icon → enter backend URL `http://127.0.0.1:8080` (and API key if you set one) +5. Browse a Reddit thread — visible comments are captured automatically (deduped by comment id) + +### 3. Search Web UI + +Open `http://127.0.0.1:8080`: + +- Filter by **username** (prefix match) +- Free text uses SQLite **GLOB** (`*` and `?`). Example: `*bla*bla*` +- Screenshot thumbnails appear when captured; click to enlarge (lightbox) +- Each result can **Export to Imgur** (needs `IMGUR_CLIENT_ID`). After export: **Open on Imgur** + **Copy URL**. The Imgur URL is stored so the same image is not re-uploaded. + +## Docker / VM / LXC + +```bash +docker compose up -d --build +``` + +Or build a binary: + +```bash +make build # writes ./bin/rcs +RCS_ADDR=0.0.0.0:8080 RCS_DATA_DIR=/var/lib/rcs ./bin/rcs +``` + +Example systemd unit: + +```ini +[Unit] +Description=RCS Reddit Comment Saver +After=network.target + +[Service] +ExecStart=/usr/local/bin/rcs +Environment=RCS_ADDR=0.0.0.0:8080 +Environment=RCS_DATA_DIR=/var/lib/rcs +Environment=RCS_API_KEY= +Environment=RCS_PUBLIC_BASE_URL= +Environment=RCS_TRUST_PROXY= +Environment=IMGUR_CLIENT_ID= +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +### User crontab (watchdog) + +Keeps `bin/rcs` running without systemd: every minute the watchdog checks a pidfile and starts the process if it is down. + +```bash +make build +cp -n data/rcs.env.example data/rcs.env # if not created yet +# edit data/rcs.env (RCS_ADDR, Pangolin URL, API key, Imgur, …) +make install-cron +``` + +That installs: + +```cron +# RCS watchdog +* * * * * /absolute/path/to/RCS/scripts/rcs-watchdog.sh +``` + +Logs append to `data/rcs.log`. Check status with `tail -f data/rcs.log` or `make test-api`. Remove with `make uninstall-cron`. + +Cron does not rebuild Go — re-run `make build` after pulling updates. Override paths via `RCS_BIN`, `RCS_ENV_FILE`, etc. if needed (see [`scripts/rcs-watchdog.sh`](scripts/rcs-watchdog.sh)). + +When the backend is not on localhost, grant the extension host permission when saving the URL in the popup (Brave/Chrome will prompt). + +## Reverse proxy (Pangolin) + +RCS is meant to sit privately behind a reverse proxy. Auth stays at the proxy — leave `RCS_API_KEY` empty when Pangolin alone is enough. + +### Pangolin protected subdomain + +1. Run RCS so only Pangolin/Newt can reach it (e.g. `RCS_ADDR=0.0.0.0:8080` on a private site). +2. Create a public resource such as `https://rcs.example.com` pointing at upstream `http://127.0.0.1:8080` (or the container IP). +3. Enable Pangolin authentication on that resource (protected / SSO). +4. Start RCS with: + +```bash +RCS_ADDR=0.0.0.0:8080 \ +RCS_PUBLIC_BASE_URL=https://rcs.example.com \ +RCS_TRUST_PROXY=1 \ +./bin/rcs +``` + +5. In the **same browser profile**, open `https://rcs.example.com` once and complete Pangolin login (sets the session cookie). +6. In the RCS extension popup, set Backend URL to `https://rcs.example.com` and grant host permission. + +The extension sends cookies (`credentials: "include"`) on API calls, so Pangolin allows the request the same way the Web UI tab does. Other browser profiles / Incognito need their own login. If health checks fail with 401/403 or an HTML login page, open the Web UI, log in, and retry. + +Serve RCS at the **subdomain root** (`/`), not a subpath — static assets and API paths are absolute from `/`. + +## API (extension / integrations) + +- `GET /api/health` +- `GET /api/comments/exists?id=t1_...` — `{ exists, has_screenshot }` +- `GET /api/posts/exists?id=t3_...` +- `POST /api/comments` — JSON body with text fields + optional `screenshot_base64` (data URL or raw base64) +- `POST /api/comments/{id}/screenshot` — attach screenshot to an existing comment (backfill) +- `POST /api/posts` +- `GET /api/search?q=&user=` +- `POST /api/comments/{id}/imgur` +- `GET /api/blocklist` — blocked usernames (synced to extensions) +- `POST /api/blocklist` — `{ "username": "AutoModerator" }` +- `DELETE /api/blocklist/{username}` +- `PUT /api/blocklist` — replace full list `{ "usernames": [...] }` + +## Layout + +``` +backend/ Go server (SQLite, Web UI, Imgur) +extension/ Manifest V3 Chrome/Brave addon +data/ Local SQLite + screenshots (gitignored) +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..b9c0f22 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.24-bookworm AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /out/rcs ./cmd/rcs + +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=build /out/rcs /app/rcs +ENV RCS_ADDR=0.0.0.0:8080 +ENV RCS_DATA_DIR=/data +VOLUME ["/data"] +EXPOSE 8080 +ENTRYPOINT ["/app/rcs"] diff --git a/backend/cmd/rcs/main.go b/backend/cmd/rcs/main.go new file mode 100644 index 0000000..eef0fac --- /dev/null +++ b/backend/cmd/rcs/main.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + + "github.com/squid/rcs/backend/internal/api" + "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" +) + +func main() { + cfg := config.Load() + + if err := os.MkdirAll(cfg.ScreenshotDir, 0o755); err != nil { + log.Fatalf("create screenshot dir: %v", err) + } + if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil { + log.Fatalf("create db dir: %v", err) + } + + store, err := db.Open(cfg.DBPath) + if err != nil { + log.Fatalf("open db: %v", err) + } + defer store.Close() + + imgurClient := imgur.New(cfg.ImgurClientID) + webUI, err := web.New(cfg, store, imgurClient) + if err != nil { + log.Fatalf("web ui: %v", err) + } + + server := api.New(cfg, store, imgurClient, webUI) + + fmt.Printf("RCS backend listening on http://%s\n", cfg.Addr) + fmt.Printf(" data dir: %s\n", cfg.DataDir) + fmt.Printf(" db: %s\n", cfg.DBPath) + if cfg.PublicBaseURL != "" { + fmt.Printf(" public: %s\n", cfg.PublicBaseURL) + } + if cfg.TrustProxy { + fmt.Println(" proxy: trusting X-Forwarded-* headers") + } + if cfg.APIKey != "" { + fmt.Println(" api key: enabled") + } else { + fmt.Println(" api key: disabled (open)") + } + if imgurClient.Enabled() { + fmt.Println(" imgur: enabled") + } else { + fmt.Println(" imgur: disabled (set IMGUR_CLIENT_ID)") + } + + if err := http.ListenAndServe(cfg.Addr, server.Handler()); err != nil { + log.Fatal(err) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..7adb6a0 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,21 @@ +module github.com/squid/rcs/backend + +go 1.24.5 + +require ( + github.com/go-chi/chi/v5 v5.3.1 + github.com/go-chi/cors v1.2.2 + modernc.org/sqlite v1.34.5 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..aac25e4 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,47 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= +github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= +modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go new file mode 100644 index 0000000..3e8d498 --- /dev/null +++ b/backend/internal/api/api.go @@ -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) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..1bfdb79 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,55 @@ +package config + +import ( + "os" + "path/filepath" + "strings" +) + +// Config holds runtime settings for the RCS backend. +type Config struct { + Addr string + DataDir string + DBPath string + ScreenshotDir string + APIKey string + ImgurClientID string + // PublicBaseURL is the canonical public origin (e.g. https://rcs.example.com). + PublicBaseURL string + // TrustProxy honors X-Forwarded-Proto / X-Forwarded-Host when behind a reverse proxy. + TrustProxy bool +} + +// Load reads configuration from environment variables with sensible defaults. +func Load() Config { + dataDir := envOr("RCS_DATA_DIR", "data") + dbPath := envOr("RCS_DB_PATH", filepath.Join(dataDir, "rcs.db")) + screenshotDir := filepath.Join(dataDir, "screenshots") + + return Config{ + Addr: envOr("RCS_ADDR", "127.0.0.1:8080"), + DataDir: dataDir, + DBPath: dbPath, + ScreenshotDir: screenshotDir, + APIKey: os.Getenv("RCS_API_KEY"), + ImgurClientID: os.Getenv("IMGUR_CLIENT_ID"), + PublicBaseURL: strings.TrimRight(os.Getenv("RCS_PUBLIC_BASE_URL"), "/"), + TrustProxy: envTruthy("RCS_TRUST_PROXY"), + } +} + +func envOr(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func envTruthy(key string) bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..531f500 --- /dev/null +++ b/backend/internal/db/db.go @@ -0,0 +1,553 @@ +package db + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +// Store wraps the SQLite connection and domain queries. +type Store struct { + db *sql.DB +} + +// User is a Reddit account reference. +type User struct { + ID int64 `json:"id"` + RedditUserID string `json:"reddit_user_id"` + Username string `json:"username"` +} + +// Post is a Reddit submission ("top post"). +type Post struct { + ID int64 `json:"id"` + RedditPostID string `json:"reddit_post_id"` + Title string `json:"title"` + Body string `json:"body"` + AuthorID int64 `json:"author_id"` + AuthorName string `json:"author_name"` + Permalink string `json:"permalink"` + Subreddit string `json:"subreddit"` + CreatedAt time.Time `json:"created_at"` +} + +// Comment is a saved Reddit comment with optional screenshot/Imgur URL. +type Comment struct { + ID int64 `json:"id"` + RedditCommentID string `json:"reddit_comment_id"` + Body string `json:"body"` + AuthorID int64 `json:"author_id"` + AuthorName string `json:"author_name"` + PostID int64 `json:"post_id"` + RedditPostID string `json:"reddit_post_id"` + Permalink string `json:"permalink"` + ScreenshotPath string `json:"screenshot_path"` + ImgurURL string `json:"imgur_url"` + CapturedAt time.Time `json:"captured_at"` + PostTitle string `json:"post_title"` +} + +// SearchResult is a comment row returned by search. +type SearchResult struct { + Comment +} + +// Open opens (or creates) the SQLite database and runs migrations. +func Open(path string) (*Store, error) { + database, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + database.SetMaxOpenConns(1) + + store := &Store{db: database} + if err := store.migrate(); err != nil { + _ = database.Close() + return nil, err + } + return store, nil +} + +// Close closes the database. +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) migrate() error { + const schema = ` +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reddit_user_id TEXT NOT NULL UNIQUE, + username TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reddit_post_id TEXT NOT NULL UNIQUE, + title TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + author_id INTEGER NOT NULL REFERENCES users(id), + permalink TEXT NOT NULL DEFAULT '', + subreddit TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reddit_comment_id TEXT NOT NULL UNIQUE, + body TEXT NOT NULL DEFAULT '', + author_id INTEGER NOT NULL REFERENCES users(id), + post_id INTEGER NOT NULL REFERENCES posts(id), + permalink TEXT NOT NULL DEFAULT '', + screenshot_path TEXT NOT NULL DEFAULT '', + imgur_url TEXT NOT NULL DEFAULT '', + captured_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); +CREATE INDEX IF NOT EXISTS idx_comments_reddit_id ON comments(reddit_comment_id); +CREATE INDEX IF NOT EXISTS idx_posts_reddit_id ON posts(reddit_post_id); + +CREATE TABLE IF NOT EXISTS blocked_usernames ( + username TEXT PRIMARY KEY COLLATE NOCASE, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +` + _, err := s.db.Exec(schema) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + return s.seedBlocklistIfEmpty() +} + +func (s *Store) seedBlocklistIfEmpty() error { + var count int + if err := s.db.QueryRow(`SELECT COUNT(1) FROM blocked_usernames`).Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + _, err := s.db.Exec(`INSERT INTO blocked_usernames (username) VALUES (?)`, NormalizeUsername("AutoModerator")) + return err +} + +// NormalizeUsername trims, strips u/, and lower-cases a Reddit username. +func NormalizeUsername(name string) string { + name = strings.TrimSpace(name) + name = strings.TrimPrefix(name, "u/") + name = strings.TrimPrefix(name, "U/") + name = strings.TrimPrefix(name, "/u/") + return strings.ToLower(strings.TrimSpace(name)) +} + +// ListBlockedUsernames returns all blocked usernames (normalized). +func (s *Store) ListBlockedUsernames(ctx context.Context) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT username FROM blocked_usernames ORDER BY username COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + out = append(out, name) + } + if out == nil { + out = []string{} + } + return out, rows.Err() +} + +// IsUsernameBlocked reports whether the author is on the blocklist. +func (s *Store) IsUsernameBlocked(ctx context.Context, username string) (bool, error) { + normalized := NormalizeUsername(username) + if normalized == "" { + return false, nil + } + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_usernames WHERE username = ?`, normalized).Scan(&count) + return count > 0, err +} + +// AddBlockedUsername inserts one username (idempotent). +func (s *Store) AddBlockedUsername(ctx context.Context, username string) (string, error) { + normalized := NormalizeUsername(username) + if normalized == "" { + return "", fmt.Errorf("username required") + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO blocked_usernames (username) VALUES (?) +ON CONFLICT(username) DO NOTHING +`, normalized) + return normalized, err +} + +// RemoveBlockedUsername deletes one username. +func (s *Store) RemoveBlockedUsername(ctx context.Context, username string) error { + normalized := NormalizeUsername(username) + if normalized == "" { + return fmt.Errorf("username required") + } + _, err := s.db.ExecContext(ctx, `DELETE FROM blocked_usernames WHERE username = ?`, normalized) + return err +} + +// ReplaceBlockedUsernames replaces the entire blocklist. +func (s *Store) ReplaceBlockedUsernames(ctx context.Context, usernames []string) ([]string, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM blocked_usernames`); err != nil { + return nil, err + } + + seen := map[string]struct{}{} + var normalized []string + for _, name := range usernames { + n := NormalizeUsername(name) + if n == "" { + continue + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + if _, err := tx.ExecContext(ctx, `INSERT INTO blocked_usernames (username) VALUES (?)`, n); err != nil { + return nil, err + } + normalized = append(normalized, n) + } + if err := tx.Commit(); err != nil { + return nil, err + } + if normalized == nil { + normalized = []string{} + } + return normalized, nil +} + +// CommentExists reports whether a comment with the given Reddit id is stored. +func (s *Store) CommentExists(ctx context.Context, redditCommentID string) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM comments WHERE reddit_comment_id = ?`, redditCommentID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// CommentCaptureStatus reports existence and whether a screenshot is already stored. +type CommentCaptureStatus struct { + Exists bool `json:"exists"` + HasScreenshot bool `json:"has_screenshot"` + ScreenshotPath string `json:"screenshot_path,omitempty"` +} + +// GetCommentCaptureStatus loads capture status for a Reddit comment id. +func (s *Store) GetCommentCaptureStatus(ctx context.Context, redditCommentID string) (CommentCaptureStatus, error) { + var path string + err := s.db.QueryRowContext(ctx, `SELECT screenshot_path FROM comments WHERE reddit_comment_id = ?`, redditCommentID).Scan(&path) + if err == sql.ErrNoRows { + return CommentCaptureStatus{Exists: false, HasScreenshot: false}, nil + } + if err != nil { + return CommentCaptureStatus{}, err + } + return CommentCaptureStatus{ + Exists: true, + HasScreenshot: strings.TrimSpace(path) != "", + ScreenshotPath: path, + }, nil +} + +// SetCommentScreenshot sets screenshot_path only when currently empty. +func (s *Store) SetCommentScreenshot(ctx context.Context, redditCommentID, screenshotPath string) (bool, error) { + result, err := s.db.ExecContext(ctx, ` +UPDATE comments +SET screenshot_path = ? +WHERE reddit_comment_id = ? + AND (screenshot_path IS NULL OR screenshot_path = '') +`, screenshotPath, redditCommentID) + if err != nil { + return false, err + } + n, err := result.RowsAffected() + return n > 0, err +} + +// PostExists reports whether a post with the given Reddit id is stored. +func (s *Store) PostExists(ctx context.Context, redditPostID string) (bool, error) { + var count int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM posts WHERE reddit_post_id = ?`, redditPostID).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// UpsertUser inserts or updates a user by Reddit user id. +func (s *Store) UpsertUser(ctx context.Context, redditUserID, username string) (int64, error) { + if redditUserID == "" { + redditUserID = "unknown:" + username + } + if username == "" { + username = "[deleted]" + } + + _, err := s.db.ExecContext(ctx, ` +INSERT INTO users (reddit_user_id, username) VALUES (?, ?) +ON CONFLICT(reddit_user_id) DO UPDATE SET username = excluded.username +`, redditUserID, username) + if err != nil { + return 0, err + } + + var id int64 + err = s.db.QueryRowContext(ctx, `SELECT id FROM users WHERE reddit_user_id = ?`, redditUserID).Scan(&id) + return id, err +} + +// SavePostInput is the payload for saving a submission. +type SavePostInput struct { + RedditPostID string + Title string + Body string + AuthorID string + AuthorName string + Permalink string + Subreddit string +} + +// SavePost upserts a post and its author. +func (s *Store) SavePost(ctx context.Context, input SavePostInput) (int64, error) { + authorID, err := s.UpsertUser(ctx, input.AuthorID, input.AuthorName) + if err != nil { + return 0, err + } + + _, err = s.db.ExecContext(ctx, ` +INSERT INTO posts (reddit_post_id, title, body, author_id, permalink, subreddit, created_at) +VALUES (?, ?, ?, ?, ?, ?, datetime('now')) +ON CONFLICT(reddit_post_id) DO UPDATE SET + title = excluded.title, + body = excluded.body, + author_id = excluded.author_id, + permalink = excluded.permalink, + subreddit = excluded.subreddit +`, input.RedditPostID, input.Title, input.Body, authorID, input.Permalink, input.Subreddit) + if err != nil { + return 0, err + } + + var id int64 + err = s.db.QueryRowContext(ctx, `SELECT id FROM posts WHERE reddit_post_id = ?`, input.RedditPostID).Scan(&id) + return id, err +} + +// SaveCommentInput is the payload for saving a comment. +type SaveCommentInput struct { + RedditCommentID string + Body string + AuthorID string + AuthorName string + RedditPostID string + PostTitle string + PostBody string + PostAuthorID string + PostAuthorName string + PostPermalink string + Subreddit string + Permalink string + ScreenshotPath string +} + +// SaveComment ensures the parent post exists, then inserts the comment (no overwrite of existing). +func (s *Store) SaveComment(ctx context.Context, input SaveCommentInput) (int64, error) { + exists, err := s.CommentExists(ctx, input.RedditCommentID) + if err != nil { + return 0, err + } + if exists { + var id int64 + err = s.db.QueryRowContext(ctx, `SELECT id FROM comments WHERE reddit_comment_id = ?`, input.RedditCommentID).Scan(&id) + return id, err + } + + postID, err := s.SavePost(ctx, SavePostInput{ + RedditPostID: input.RedditPostID, + Title: input.PostTitle, + Body: input.PostBody, + AuthorID: input.PostAuthorID, + AuthorName: input.PostAuthorName, + Permalink: input.PostPermalink, + Subreddit: input.Subreddit, + }) + if err != nil { + return 0, err + } + + authorID, err := s.UpsertUser(ctx, input.AuthorID, input.AuthorName) + if err != nil { + return 0, err + } + + result, err := s.db.ExecContext(ctx, ` +INSERT INTO comments (reddit_comment_id, body, author_id, post_id, permalink, screenshot_path, captured_at) +VALUES (?, ?, ?, ?, ?, ?, datetime('now')) +`, input.RedditCommentID, input.Body, authorID, postID, input.Permalink, input.ScreenshotPath) + if err != nil { + return 0, err + } + return result.LastInsertId() +} + +// GetCommentByRedditID loads a comment by Reddit id. +func (s *Store) GetCommentByRedditID(ctx context.Context, redditCommentID string) (*Comment, error) { + row := s.db.QueryRowContext(ctx, ` +SELECT c.id, c.reddit_comment_id, c.body, c.author_id, u.username, c.post_id, p.reddit_post_id, + c.permalink, c.screenshot_path, c.imgur_url, c.captured_at, p.title +FROM comments c +JOIN users u ON u.id = c.author_id +JOIN posts p ON p.id = c.post_id +WHERE c.reddit_comment_id = ? +`, redditCommentID) + + var comment Comment + var capturedAt string + err := row.Scan( + &comment.ID, &comment.RedditCommentID, &comment.Body, &comment.AuthorID, &comment.AuthorName, + &comment.PostID, &comment.RedditPostID, &comment.Permalink, &comment.ScreenshotPath, + &comment.ImgurURL, &capturedAt, &comment.PostTitle, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + comment.CapturedAt = parseTime(capturedAt) + return &comment, nil +} + +// SetImgurURL stores the Imgur link for a comment. +func (s *Store) SetImgurURL(ctx context.Context, redditCommentID, imgurURL string) error { + _, err := s.db.ExecContext(ctx, `UPDATE comments SET imgur_url = ? WHERE reddit_comment_id = ?`, imgurURL, redditCommentID) + return err +} + +func commentSearchFilter(query, username string) (where string, args []any) { + var clauses []string + if username != "" { + clauses = append(clauses, `u.username LIKE ?`) + args = append(args, username+"%") + } + if query != "" { + pattern := normalizeGlob(query) + clauses = append(clauses, `(c.body GLOB ? OR p.title GLOB ?)`) + args = append(args, pattern, pattern) + } + if len(clauses) > 0 { + where = "WHERE " + strings.Join(clauses, " AND ") + } + return where, args +} + +// CountComments returns how many comments match the optional username / GLOB filters. +func (s *Store) CountComments(ctx context.Context, query, username string) (int, error) { + where, args := commentSearchFilter(query, username) + sqlQuery := fmt.Sprintf(` +SELECT COUNT(1) +FROM comments c +JOIN users u ON u.id = c.author_id +JOIN posts p ON p.id = c.post_id +%s +`, where) + var total int + err := s.db.QueryRowContext(ctx, sqlQuery, args...).Scan(&total) + return total, err +} + +// SearchComments finds comments by optional username and GLOB text pattern (newest first). +func (s *Store) SearchComments(ctx context.Context, query, username string, limit, offset int) ([]SearchResult, error) { + if limit <= 0 || limit > 500 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + + where, args := commentSearchFilter(query, username) + sqlQuery := fmt.Sprintf(` +SELECT c.id, c.reddit_comment_id, c.body, c.author_id, u.username, c.post_id, p.reddit_post_id, + c.permalink, c.screenshot_path, c.imgur_url, c.captured_at, p.title +FROM comments c +JOIN users u ON u.id = c.author_id +JOIN posts p ON p.id = c.post_id +%s +ORDER BY c.captured_at DESC, c.id DESC +LIMIT ? OFFSET ? +`, where) + args = append(args, limit, offset) + + rows, err := s.db.QueryContext(ctx, sqlQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var item SearchResult + var capturedAt string + if err := rows.Scan( + &item.ID, &item.RedditCommentID, &item.Body, &item.AuthorID, &item.AuthorName, + &item.PostID, &item.RedditPostID, &item.Permalink, &item.ScreenshotPath, + &item.ImgurURL, &capturedAt, &item.PostTitle, + ); err != nil { + return nil, err + } + item.CapturedAt = parseTime(capturedAt) + results = append(results, item) + } + return results, rows.Err() +} + +func normalizeGlob(query string) string { + query = strings.TrimSpace(query) + if query == "" { + return "*" + } + // If the user did not include wildcards, wrap for substring match. + if !strings.ContainsAny(query, "*?") { + return "*" + query + "*" + } + return query +} + +func parseTime(value string) time.Time { + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed + } + } + return time.Time{} +} diff --git a/backend/internal/imgur/imgur.go b/backend/internal/imgur/imgur.go new file mode 100644 index 0000000..adca6f9 --- /dev/null +++ b/backend/internal/imgur/imgur.go @@ -0,0 +1,105 @@ +package imgur + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "time" +) + +const uploadURL = "https://api.imgur.com/3/image" + +// Client uploads images to Imgur anonymously with a Client-ID. +type Client struct { + clientID string + httpClient *http.Client +} + +// New creates an Imgur client. clientID may be empty (uploads will fail clearly). +func New(clientID string) *Client { + return &Client{ + clientID: clientID, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +// Enabled reports whether a Client-ID is configured. +func (c *Client) Enabled() bool { + return c != nil && c.clientID != "" +} + +type uploadResponse struct { + Success bool `json:"success"` + Data struct { + Link string `json:"link"` + ID string `json:"id"` + } `json:"data"` + Status int `json:"status"` +} + +// UploadFile uploads a local image file and returns the public Imgur URL. +func (c *Client) UploadFile(path string) (string, error) { + if !c.Enabled() { + return "", fmt.Errorf("imgur is not configured (set IMGUR_CLIENT_ID)") + } + + file, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open screenshot: %w", err) + } + defer file.Close() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("image", "screenshot.png") + if err != nil { + return "", err + } + if _, err := io.Copy(part, file); err != nil { + return "", err + } + _ = writer.WriteField("type", "file") + if err := writer.Close(); err != nil { + return "", err + } + + req, err := http.NewRequest(http.MethodPost, uploadURL, &body) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Client-ID "+c.clientID) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("imgur request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var parsed uploadResponse + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("decode imgur response: %w (body=%s)", err, truncate(string(raw), 200)) + } + if !parsed.Success || parsed.Data.Link == "" { + return "", fmt.Errorf("imgur upload failed (status=%d): %s", resp.StatusCode, truncate(string(raw), 300)) + } + return parsed.Data.Link, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/backend/internal/web/static/app.js b/backend/internal/web/static/app.js new file mode 100644 index 0000000..5adeb1b --- /dev/null +++ b/backend/internal/web/static/app.js @@ -0,0 +1,61 @@ +document.querySelectorAll(".copy-url").forEach((button) => { + button.addEventListener("click", async () => { + const url = button.getAttribute("data-url"); + if (!url) return; + try { + await navigator.clipboard.writeText(url); + const hint = button.parentElement.querySelector(".copied-hint"); + if (hint) { + hint.hidden = false; + setTimeout(() => { + hint.hidden = true; + }, 1500); + } + } catch (err) { + console.error("copy failed", err); + } + }); +}); + +(function setupLightbox() { + const lightbox = document.getElementById("lightbox"); + const lightboxImg = document.getElementById("lightbox-img"); + if (!lightbox || !lightboxImg) { + console.warn("[RCS] lightbox elements missing"); + return; + } + + function openLightbox(src) { + lightboxImg.src = src; + lightbox.hidden = false; + lightbox.setAttribute("aria-hidden", "false"); + document.body.classList.add("lightbox-open"); + } + + function closeLightbox() { + lightbox.hidden = true; + lightbox.setAttribute("aria-hidden", "true"); + lightboxImg.removeAttribute("src"); + document.body.classList.remove("lightbox-open"); + } + + // Event delegation so thumbs work even if markup is re-rendered later. + document.addEventListener("click", (event) => { + const thumb = event.target.closest(".shot-thumb"); + if (thumb) { + event.preventDefault(); + const src = thumb.getAttribute("data-full") || (thumb.querySelector("img") && thumb.querySelector("img").src); + if (src) openLightbox(src); + return; + } + if (event.target === lightbox || event.target.classList.contains("lightbox-close")) { + closeLightbox(); + } + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && !lightbox.hidden) { + closeLightbox(); + } + }); +})(); diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css new file mode 100644 index 0000000..0d0ff3b --- /dev/null +++ b/backend/internal/web/static/style.css @@ -0,0 +1,263 @@ +:root { + --bg: #0f1419; + --panel: #1a222c; + --text: #e7ecf1; + --muted: #8b98a5; + --accent: #ff4500; + --accent-2: #4a9eff; + --ok: #1f6f4a; + --warn: #7a5b12; + --error: #8b2e2e; + --border: #2c3640; + --font: "IBM Plex Sans", "Segoe UI", sans-serif; + --mono: "IBM Plex Mono", ui-monospace, monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font); + background: + radial-gradient(1200px 600px at 10% -10%, #243041 0%, transparent 60%), + radial-gradient(900px 500px at 100% 0%, #2a1c14 0%, transparent 55%), + var(--bg); + color: var(--text); + min-height: 100vh; +} + +.site-header { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + align-items: end; + justify-content: space-between; + padding: 1.5rem clamp(1rem, 3vw, 2.5rem); + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 85%, transparent); + backdrop-filter: blur(8px); +} + +.brand h1 { + margin: 0; + font-size: 2rem; + letter-spacing: 0.04em; +} + +.brand p { + margin: 0.15rem 0 0; + color: var(--muted); +} + +.search { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: end; +} + +.search label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.8rem; + color: var(--muted); +} + +.search input { + min-width: 12rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); +} + +button, .btn { + appearance: none; + border: 0; + border-radius: 6px; + padding: 0.55rem 0.9rem; + background: var(--accent); + color: #fff; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-flex; + align-items: center; +} + +.btn.secondary { + background: var(--accent-2); +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.banner { + margin: 1rem clamp(1rem, 3vw, 2.5rem); + padding: 0.75rem 1rem; + border-radius: 6px; +} + +.banner.error { background: var(--error); } +.banner.warn { background: var(--warn); } +.banner.ok { background: var(--ok); } + +main { + padding: 1rem clamp(1rem, 3vw, 2.5rem) 3rem; +} + +.empty { + color: var(--muted); +} + +.results { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 1rem; +} + +.result { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.6rem; + align-items: baseline; + margin-bottom: 0.5rem; +} + +.muted { color: var(--muted); } +.permalink { color: var(--accent-2); } + +.body { + white-space: pre-wrap; + line-height: 1.45; + margin: 0 0 0.75rem; +} + +.shot { + margin: 0.5rem 0 0.25rem; +} + +.shot-thumb { + appearance: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0; + background: #000; + cursor: zoom-in; + display: inline-flex; + flex-direction: column; + align-items: stretch; + max-width: min(100%, 420px); + overflow: hidden; +} + +.shot-thumb img { + display: block; + max-width: 100%; + height: auto; + vertical-align: middle; +} + +.shot-hint { + font-size: 0.75rem; + color: var(--muted); + background: color-mix(in srgb, var(--panel) 90%, #000); + padding: 0.35rem 0.55rem; + text-align: left; +} + +.no-shot { + margin: 0.35rem 0 0.5rem; + font-size: 0.85rem; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.82); + padding: 1.5rem; +} + +.lightbox[hidden] { + display: none !important; +} + +.lightbox img { + max-width: min(96vw, 1100px); + max-height: 90vh; + border-radius: 8px; + border: 1px solid var(--border); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + background: #111; +} + +.lightbox-close { + position: absolute; + top: 0.75rem; + right: 1rem; + border: 0; + background: transparent; + color: #fff; + font-size: 2rem; + line-height: 1; + cursor: pointer; +} + +body.lightbox-open { + overflow: hidden; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; +} + +.pager { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + align-items: center; + justify-content: space-between; + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.pager-meta { + color: var(--muted); + font-size: 0.9rem; +} + +.pager-links { + display: flex; + gap: 0.5rem; +} + +.btn.secondary.disabled, +span.btn.secondary.disabled { + opacity: 0.4; + cursor: default; + pointer-events: none; +} + +code { font-family: var(--mono); } diff --git a/backend/internal/web/templates/index.html b/backend/internal/web/templates/index.html new file mode 100644 index 0000000..6681085 --- /dev/null +++ b/backend/internal/web/templates/index.html @@ -0,0 +1,104 @@ + + + + + + RCS — Reddit Comment Saver + + + + + + {{if .Error}} + + {{end}} + {{if .Flash}} + + {{end}} + + {{if not .ImgurOn}} + + {{end}} + +
+ {{if not .Results}} +

No comments yet. Browse Reddit with the RCS extension connected to this backend.

+ {{else}} + + + {{end}} +
+ + + + + diff --git a/backend/internal/web/web.go b/backend/internal/web/web.go new file mode 100644 index 0000000..b325b55 --- /dev/null +++ b/backend/internal/web/web.go @@ -0,0 +1,233 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/squid/rcs/backend/internal/config" + "github.com/squid/rcs/backend/internal/db" + "github.com/squid/rcs/backend/internal/imgur" +) + +const pageSize = 50 + +//go:embed templates/* static/* +var assets embed.FS + +// UI serves the search Web UI. +type UI struct { + cfg config.Config + store *db.Store + imgur *imgur.Client + tmpl *template.Template +} + +// New creates the Web UI. +func New(cfg config.Config, store *db.Store, imgurClient *imgur.Client) (*UI, error) { + tmpl, err := template.New("").Funcs(template.FuncMap{ + "hasImgur": func(url string) bool { return strings.TrimSpace(url) != "" }, + "snippet": func(body string) string { + body = strings.TrimSpace(body) + if len(body) > 280 { + return body[:280] + "…" + } + return body + }, + "screenshotURL": func(path string) string { + if path == "" { + return "" + } + return "/screenshots/" + filepath.Base(path) + }, + "pageURL": func(data pageData, page int) string { + values := url.Values{} + if data.Query != "" { + values.Set("q", data.Query) + } + if data.User != "" { + values.Set("user", data.User) + } + if page > 1 { + values.Set("page", strconv.Itoa(page)) + } + encoded := values.Encode() + if encoded == "" { + return "/" + } + return "/?" + encoded + }, + }).ParseFS(assets, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("parse templates: %w", err) + } + return &UI{cfg: cfg, store: store, imgur: imgurClient, tmpl: tmpl}, nil +} + +// StaticHandler serves embedded CSS/JS. +func (u *UI) StaticHandler() http.Handler { + sub, err := fs.Sub(assets, "static") + if err != nil { + return http.NotFoundHandler() + } + return http.StripPrefix("/static/", http.FileServer(http.FS(sub))) +} + +type pageData struct { + Query string + User string + Results []db.SearchResult + Error string + Flash string + ImgurOn bool + Page int + TotalPages int + Total int + HasPrev bool + HasNext bool + PrevPage int + NextPage int +} + +func parsePage(raw string) int { + page, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || page < 1 { + return 1 + } + return page +} + +// HandleIndex renders search results. +func (u *UI) HandleIndex(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + user := strings.TrimSpace(r.URL.Query().Get("user")) + flash := strings.TrimSpace(r.URL.Query().Get("flash")) + errMsg := strings.TrimSpace(r.URL.Query().Get("error")) + page := parsePage(r.URL.Query().Get("page")) + + data := pageData{ + Query: q, + User: user, + Flash: flash, + Error: errMsg, + ImgurOn: u.imgur.Enabled(), + Page: page, + } + + total, err := u.store.CountComments(r.Context(), q, user) + if err != nil { + data.Error = err.Error() + } else { + data.Total = total + data.TotalPages = total / pageSize + if total%pageSize != 0 { + data.TotalPages++ + } + if data.TotalPages == 0 { + data.TotalPages = 1 + } + if page > data.TotalPages { + page = data.TotalPages + data.Page = page + } + data.HasPrev = page > 1 + data.HasNext = page < data.TotalPages + data.PrevPage = page - 1 + data.NextPage = page + 1 + + offset := (page - 1) * pageSize + results, err := u.store.SearchComments(r.Context(), q, user, pageSize, offset) + if err != nil { + data.Error = err.Error() + } else { + data.Results = results + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := u.tmpl.ExecuteTemplate(w, "index.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +// HandleExport uploads a comment screenshot to Imgur (or returns existing URL). +func (u *UI) HandleExport(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + q := strings.TrimSpace(r.FormValue("q")) + if q == "" { + q = strings.TrimSpace(r.URL.Query().Get("q")) + } + user := strings.TrimSpace(r.FormValue("user")) + if user == "" { + user = strings.TrimSpace(r.URL.Query().Get("user")) + } + pageRaw := strings.TrimSpace(r.FormValue("page")) + if pageRaw == "" { + pageRaw = strings.TrimSpace(r.URL.Query().Get("page")) + } + page := parsePage(pageRaw) + + redirect := func(flash, errMsg string) { + values := url.Values{} + if q != "" { + values.Set("q", q) + } + if user != "" { + values.Set("user", user) + } + if page > 1 { + values.Set("page", strconv.Itoa(page)) + } + if flash != "" { + values.Set("flash", flash) + } + if errMsg != "" { + values.Set("error", errMsg) + } + target := "/" + if encoded := values.Encode(); encoded != "" { + target += "?" + encoded + } + http.Redirect(w, r, target, http.StatusSeeOther) + } + + comment, err := u.store.GetCommentByRedditID(r.Context(), id) + if err != nil { + redirect("", err.Error()) + return + } + if comment == nil { + redirect("", "comment not found") + return + } + if comment.ImgurURL != "" { + redirect("Already exported", "") + return + } + if comment.ScreenshotPath == "" { + redirect("", "no screenshot for this comment") + return + } + + absPath := comment.ScreenshotPath + if !filepath.IsAbs(absPath) { + absPath = filepath.Join(u.cfg.DataDir, absPath) + } + link, err := u.imgur.UploadFile(absPath) + if err != nil { + redirect("", err.Error()) + return + } + if err := u.store.SetImgurURL(r.Context(), id, link); err != nil { + redirect("", err.Error()) + return + } + redirect("Exported to Imgur", "") +} diff --git a/data/rcs.env.example b/data/rcs.env.example new file mode 100644 index 0000000..fb34020 --- /dev/null +++ b/data/rcs.env.example @@ -0,0 +1,10 @@ +# Copy to data/rcs.env and edit. Sourced by scripts/rcs-watchdog.sh. +# RCS_DATA_DIR is set to this repo's data/ by the watchdog unless you override it here. + +RCS_ADDR=127.0.0.1:8080 +# RCS_DATA_DIR=/absolute/path/to/data +# RCS_DB_PATH= +RCS_API_KEY= +RCS_PUBLIC_BASE_URL= +RCS_TRUST_PROXY= +IMGUR_CLIENT_ID= diff --git a/data/screenshots/.gitkeep b/data/screenshots/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..842819d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + rcs: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + RCS_ADDR: "0.0.0.0:8080" + RCS_DATA_DIR: /data + RCS_API_KEY: "${RCS_API_KEY:-}" + RCS_PUBLIC_BASE_URL: "${RCS_PUBLIC_BASE_URL:-}" + RCS_TRUST_PROXY: "${RCS_TRUST_PROXY:-}" + IMGUR_CLIENT_ID: "${IMGUR_CLIENT_ID:-}" + volumes: + - ./data:/data + restart: unless-stopped diff --git a/extension/background/service-worker.js b/extension/background/service-worker.js new file mode 100644 index 0000000..a28cfed --- /dev/null +++ b/extension/background/service-worker.js @@ -0,0 +1,171 @@ +// Background service worker: API proxy + screenshot crop. + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || !message.type) { + return false; + } + + if (message.type === "rcs.api") { + (async () => { + try { + const data = await proxyApi(message); + sendResponse({ ok: true, data }); + } catch (err) { + sendResponse({ ok: false, error: String(err && err.message ? err.message : err) }); + } + })(); + return true; + } + + if (message.type === "rcs.redditJson") { + (async () => { + try { + const data = await fetchRedditJson(message.path); + sendResponse({ ok: true, data }); + } catch (err) { + sendResponse({ ok: false, error: String(err && err.message ? err.message : err) }); + } + })(); + return true; + } + + if (message.type === "rcs.captureCrop") { + const tabId = sender.tab && sender.tab.id; + if (tabId == null) { + sendResponse({ ok: false, error: "no tab" }); + return false; + } + + (async () => { + try { + const dataUrl = await chrome.tabs.captureVisibleTab(sender.tab.windowId, { format: "png" }); + const cropped = await cropDataUrl(dataUrl, message.rect, message.dpr || 1); + sendResponse({ ok: true, dataUrl: cropped }); + } catch (err) { + sendResponse({ ok: false, error: String(err && err.message ? err.message : err) }); + } + })(); + return true; + } + + return false; +}); + +async function fetchRedditJson(path) { + const url = new URL(path, "https://www.reddit.com"); + if (url.hostname !== "www.reddit.com" && url.hostname !== "old.reddit.com") { + throw new Error("reddit host not allowed"); + } + const response = await fetch(url.toString(), { + method: "GET", + headers: { + Accept: "application/json", + }, + credentials: "include", + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`reddit ${response.status}: ${text.slice(0, 160)}`); + } + try { + return JSON.parse(text); + } catch { + throw new Error("reddit response was not JSON (blocked or logged-out wall?)"); + } +} + +async function getSettings() { + const stored = await chrome.storage.sync.get({ + backendUrl: "", + apiKey: "", + }); + return { + backendUrl: (stored.backendUrl || "").replace(/\/+$/, ""), + apiKey: stored.apiKey || "", + }; +} + +async function proxyApi(message) { + const settings = await getSettings(); + if (!settings.backendUrl) { + throw new Error("Backend URL not configured"); + } + + const headers = Object.assign( + { Accept: "application/json" }, + message.headers || {} + ); + if (settings.apiKey) { + headers["X-API-Key"] = settings.apiKey; + } + + const init = { + method: message.method || "GET", + headers, + credentials: "include", + }; + if (message.body != null && message.method && message.method.toUpperCase() !== "GET") { + init.body = message.body; + } + + const response = await fetch(`${settings.backendUrl}${message.path}`, init); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!response.ok) { + throw new Error(proxyAuthErrorMessage(response.status, text, data)); + } + // Pangolin may return 200 HTML login/redirect instead of JSON + if (data && data.raw != null && looksLikeHtml(data.raw)) { + throw new Error(proxyAuthErrorMessage(response.status, text, data)); + } + return data; +} + +function looksLikeHtml(text) { + return typeof text === "string" && /^\s* { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} diff --git a/extension/content/capture.js b/extension/content/capture.js new file mode 100644 index 0000000..b9d244c --- /dev/null +++ b/extension/content/capture.js @@ -0,0 +1,479 @@ +// Auto-capture visible comments/posts with backend dedupe and queued screenshots. +// Backend I/O goes through lib/api.js → background service worker (never localhost fetch here). + +(function () { + const processed = new Set(); // fully done (saved + screenshot, or blocked) + const needsScreenshot = new Set(); // saved text but still missing screenshot + const queue = []; + let busy = false; + let configured = false; + let observer = null; + let scanCount = 0; + let blockedUsers = new Set(); + + async function refreshBlockedUsers() { + const list = await rcsGetCachedBlocklist(); + blockedUsers = new Set((list || []).map(rcsNormalizeUsername).filter(Boolean)); + } + + function isAuthorBlocked(authorName) { + const normalized = rcsNormalizeUsername(authorName); + return Boolean(normalized) && blockedUsers.has(normalized); + } + + async function refreshConfig() { + const settings = await rcsGetSettings(); + configured = Boolean(settings.backendUrl); + return configured; + } + + function enqueue(job) { + queue.push(job); + pump(); + } + + async function pump() { + if (busy) return; + busy = true; + while (queue.length) { + const job = queue.shift(); + try { + await job(); + } catch (err) { + console.warn("[RCS] queue job failed:", err && err.message ? err.message : err); + } + await sleep(200); + } + busy = false; + } + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function resolveCommentElement(comment) { + if (comment && comment.element && comment.element.isConnected) { + return comment.element; + } + if (!window.RCSScrape || !comment || !comment.reddit_comment_id) return null; + return window.RCSScrape.findCommentElement(comment.reddit_comment_id); + } + + function clampRectToViewport(rect) { + const vw = window.innerWidth || document.documentElement.clientWidth; + const vh = window.innerHeight || document.documentElement.clientHeight; + const left = Math.max(0, rect.left); + const top = Math.max(0, rect.top); + const right = Math.min(vw, rect.right); + const bottom = Math.min(vh, rect.bottom); + return { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; + } + + function isElementInViewportEnough(element) { + if (!element || !element.getBoundingClientRect) return false; + const rect = element.getBoundingClientRect(); + const vh = window.innerHeight || document.documentElement.clientHeight; + const vw = window.innerWidth || document.documentElement.clientWidth; + if (rect.width < 40 || rect.height < 20) return false; + const visibleH = Math.min(rect.bottom, vh) - Math.max(rect.top, 0); + const visibleW = Math.min(rect.right, vw) - Math.max(rect.left, 0); + return visibleH >= Math.min(rect.height, 40) && visibleW >= Math.min(rect.width, 40); + } + + // Never scrolls the page — only crops if the element is already on-screen. + async function captureElementScreenshot(element) { + if (!element) return ""; + if (!isElementInViewportEnough(element)) return ""; + + const fresh = element.getBoundingClientRect(); + const clipped = clampRectToViewport(fresh); + if (clipped.width < 10 || clipped.height < 10) return ""; + + const response = await chrome.runtime.sendMessage({ + type: "rcs.captureCrop", + rect: clipped, + dpr: window.devicePixelRatio || 1, + }); + + if (!response || !response.ok) { + throw new Error((response && response.error) || "screenshot failed"); + } + return response.dataUrl || ""; + } + + // When Reddit hides comments in closed shadow DOM, render a card ourselves. + async function renderSyntheticScreenshot(comment) { + const width = 720; + const pad = 20; + const author = `u/${comment.author_name || "[deleted]"}`; + const subreddit = String(comment.subreddit || "").replace(/^r\//i, "").trim(); + const byline = subreddit ? `${author} · r/${subreddit}` : author; + const body = String(comment.body || "").trim() || "(empty comment)"; + const meta = comment.permalink || comment.reddit_comment_id || ""; + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + ctx.font = "16px system-ui, Segoe UI, sans-serif"; + + function wrap(text, maxWidth) { + const words = text.split(/\s+/); + const lines = []; + let line = ""; + words.forEach((word) => { + const test = line ? `${line} ${word}` : word; + if (ctx.measureText(test).width > maxWidth && line) { + lines.push(line); + line = word; + } else { + line = test; + } + }); + if (line) lines.push(line); + // Hard-wrap very long tokens + return lines.flatMap((l) => { + if (ctx.measureText(l).width <= maxWidth) return [l]; + const out = []; + let cur = ""; + for (const ch of l) { + const t = cur + ch; + if (ctx.measureText(t).width > maxWidth && cur) { + out.push(cur); + cur = ch; + } else { + cur = t; + } + } + if (cur) out.push(cur); + return out; + }); + } + + const maxText = width - pad * 2; + ctx.font = "bold 15px system-ui, Segoe UI, sans-serif"; + const bylineLines = wrap(byline, maxText); + ctx.font = "16px system-ui, Segoe UI, sans-serif"; + const bodyLines = wrap(body.slice(0, 4000), maxText); + ctx.font = "12px system-ui, Segoe UI, sans-serif"; + const metaLines = wrap(meta, maxText); + + const lineH = 22; + const height = pad * 2 + 28 + bylineLines.length * lineH + 8 + bodyLines.length * lineH + 12 + metaLines.length * 16 + 24; + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + ctx.scale(dpr, dpr); + + // Card background + ctx.fillStyle = "#1a1a1b"; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = "#343536"; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, width - 2, height - 2); + + let y = pad + 8; + ctx.fillStyle = "#818384"; + ctx.font = "12px system-ui, Segoe UI, sans-serif"; + ctx.fillText("RCS comment preview", pad, y); + y += 24; + + ctx.fillStyle = "#4fbcff"; + ctx.font = "bold 15px system-ui, Segoe UI, sans-serif"; + bylineLines.forEach((line) => { + // Draw author in blue, subreddit segment in muted if present on same line. + if (subreddit && line.includes(" · r/")) { + const parts = line.split(" · "); + let x = pad; + ctx.fillStyle = "#4fbcff"; + ctx.fillText(parts[0] || "", x, y); + x += ctx.measureText(parts[0] || "").width; + if (parts[1]) { + ctx.fillStyle = "#818384"; + const sep = " · "; + ctx.fillText(sep, x, y); + x += ctx.measureText(sep).width; + ctx.fillStyle = "#ff7a45"; + ctx.fillText(parts[1], x, y); + } + } else { + ctx.fillStyle = "#4fbcff"; + ctx.fillText(line, pad, y); + } + y += lineH; + }); + y += 6; + + ctx.fillStyle = "#d7dadc"; + ctx.font = "16px system-ui, Segoe UI, sans-serif"; + bodyLines.forEach((line) => { + ctx.fillText(line, pad, y); + y += lineH; + }); + y += 10; + + ctx.fillStyle = "#818384"; + ctx.font = "12px system-ui, Segoe UI, sans-serif"; + metaLines.forEach((line) => { + ctx.fillText(line, pad, y); + y += 16; + }); + + return canvas.toDataURL("image/png"); + } + + const pendingLogAt = new Map(); + + async function savePostIfNeeded(post) { + if (!post || !post.reddit_post_id || processed.has("post:" + post.reddit_post_id)) return; + processed.add("post:" + post.reddit_post_id); + const exists = await rcsPostExists(post.reddit_post_id); + if (exists) { + console.debug("[RCS] post already saved:", post.reddit_post_id); + return; + } + await rcsSavePost({ + reddit_post_id: post.reddit_post_id, + title: post.title, + body: post.body, + author_id: post.author_id, + author_name: post.author_name, + permalink: post.permalink, + subreddit: post.subreddit, + }); + console.info("[RCS] saved post:", post.reddit_post_id, post.title); + } + + async function captureAndMaybeBackfill(comment, status) { + const element = resolveCommentElement(comment); + comment.element = element; + + let screenshot = ""; + if (element && isElementInViewportEnough(element)) { + try { + screenshot = await captureElementScreenshot(element); + if (screenshot) { + console.info("[RCS] DOM screenshot captured (no scroll):", comment.reddit_comment_id); + } + } catch (err) { + console.warn("[RCS] DOM screenshot failed:", comment.reddit_comment_id, err && err.message ? err.message : err); + } + } + + if (!screenshot) { + // Off-screen / closed-shadow: render without moving the page. + try { + screenshot = await renderSyntheticScreenshot(comment); + console.info("[RCS] synthetic screenshot rendered:", comment.reddit_comment_id); + } catch (err) { + needsScreenshot.add(comment.reddit_comment_id); + const now = Date.now(); + const last = pendingLogAt.get(comment.reddit_comment_id) || 0; + if (now - last > 10000) { + pendingLogAt.set(comment.reddit_comment_id, now); + console.info("[RCS] screenshot pending (no DOM yet):", comment.reddit_comment_id); + } + return ""; + } + } + + if (!screenshot) { + needsScreenshot.add(comment.reddit_comment_id); + return ""; + } + + if (status && status.exists && !status.has_screenshot) { + await rcsAttachScreenshot(comment.reddit_comment_id, screenshot); + console.info("[RCS] backfilled screenshot:", comment.reddit_comment_id); + } + + needsScreenshot.delete(comment.reddit_comment_id); + pendingLogAt.delete(comment.reddit_comment_id); + return screenshot; + } + + async function saveCommentIfNeeded(comment) { + if (!comment || !comment.reddit_comment_id) return; + if (isAuthorBlocked(comment.author_name)) { + console.info("[RCS] skipped blocked user:", comment.author_name, comment.reddit_comment_id); + processed.add("comment:" + comment.reddit_comment_id); + return; + } + + const id = comment.reddit_comment_id; + const doneKey = "comment:" + id; + if (processed.has(doneKey) && !needsScreenshot.has(id)) return; + + const status = await rcsCommentExists(id); + + if (status.exists && status.has_screenshot) { + processed.add(doneKey); + needsScreenshot.delete(id); + console.debug("[RCS] comment complete:", id); + return; + } + + if (status.exists && !status.has_screenshot) { + const shot = await captureAndMaybeBackfill(comment, status); + if (shot) { + processed.add(doneKey); + } + return; + } + + // New comment + const screenshot = await captureAndMaybeBackfill(comment, status); + const result = await rcsSaveComment({ + reddit_comment_id: id, + body: comment.body, + author_id: comment.author_id, + author_name: comment.author_name, + reddit_post_id: comment.reddit_post_id, + post_title: comment.post_title, + post_body: comment.post_body, + post_author_id: comment.post_author_id, + post_author_name: comment.post_author_name, + post_permalink: comment.post_permalink, + subreddit: comment.subreddit, + permalink: comment.permalink, + screenshot_base64: screenshot, + }); + + if (screenshot) { + processed.add(doneKey); + needsScreenshot.delete(id); + } else { + needsScreenshot.add(id); + } + + console.info( + "[RCS] saved comment:", + id, + "by", + comment.author_name, + result && result.saved ? "(new)" : "(exists)", + screenshot ? "+screenshot" : "no-screenshot-yet" + ); + } + + function shouldProcessComment(comment) { + if (!comment || !comment.reddit_comment_id) return false; + const id = comment.reddit_comment_id; + if (processed.has("comment:" + id) && !needsScreenshot.has(id)) return false; + + const element = resolveCommentElement(comment); + comment.element = element; + + // Prefer comments that are on-screen (or have a node we can scroll to for backfill). + if (needsScreenshot.has(id) && element) return true; + if (window.RCSScrape.isElementMostlyVisible(element)) return true; + // JSON-only with no DOM: still allow text save once. + if (!element && !processed.has("comment:" + id)) return true; + return false; + } + + async function scan() { + if (!configured || !window.RCSScrape) return; + scanCount += 1; + const page = await window.RCSScrape.scrapePageAsync(); + const { post, comments, isOld } = page; + + // Re-attach DOM nodes for pending screenshot ids. + comments.forEach((c) => { + if (!c.element) c.element = resolveCommentElement(c); + }); + + const candidates = comments.filter(shouldProcessComment); + if (scanCount <= 5 || scanCount % 20 === 0 || comments.length === 0) { + console.info( + "[RCS] scan#" + scanCount, + isOld ? "classic-ui" : page.isShreddit ? "shreddit" : "www.reddit", + "post=", + post && post.reddit_post_id, + "dom=", + page.domCount != null ? page.domCount : "?", + "json=", + page.jsonCount != null ? page.jsonCount : "n/a", + "merged=", + comments.length, + "queue=", + candidates.length, + "needShot=", + needsScreenshot.size, + page.jsonError ? "jsonError=" + page.jsonError : "" + ); + if (comments.length === 0 && !isOld) { + console.info("[RCS] DOM hints:", window.RCSScrape.debugDomHints()); + } + } + if (post) { + enqueue(() => savePostIfNeeded(post)); + } + candidates.forEach((comment) => { + enqueue(() => saveCommentIfNeeded(comment)); + }); + } + + function startObserver() { + if (observer) return; + let timer = null; + observer = new MutationObserver(() => { + clearTimeout(timer); + timer = setTimeout(scan, 400); + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + window.addEventListener("scroll", () => { + clearTimeout(timer); + timer = setTimeout(scan, 400); + }, { passive: true }); + } + + async function boot() { + await refreshConfig(); + if (!configured) { + console.info("[RCS] backend URL not set — open the extension popup to configure."); + return; + } + console.info("[RCS] configured; capturing via background API proxy (no page→localhost fetch)."); + try { + await rcsHealth(); + console.info("[RCS] backend health OK"); + } catch (err) { + console.warn("[RCS] backend health failed:", err && err.message ? err.message : err); + } + try { + const list = await rcsPullBlocklist(); + blockedUsers = new Set(list.map(rcsNormalizeUsername)); + console.info("[RCS] blocklist synced:", list.length, "users"); + } catch (err) { + await refreshBlockedUsers(); + console.warn("[RCS] blocklist pull failed, using cache:", err && err.message ? err.message : err); + } + startObserver(); + scan(); + } + + chrome.storage.onChanged.addListener((changes, area) => { + if (area !== "sync") return; + if (changes.blockedUsernames) { + refreshBlockedUsers(); + } + if (changes.backendUrl || changes.apiKey) { + refreshConfig().then(() => { + if (configured) { + startObserver(); + scan(); + } + }); + } + }); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/extension/content/scrape.js b/extension/content/scrape.js new file mode 100644 index 0000000..ab6043d --- /dev/null +++ b/extension/content/scrape.js @@ -0,0 +1,511 @@ +// DOM scrapers for old.reddit.com and www.reddit.com (new / shreddit design). + +(function (global) { + function isOldReddit() { + if (location.hostname === "old.reddit.com") return true; + // www.reddit.com can still serve classic markup (user preference / redirects / extensions). + // Prefer classic scrapers whenever those nodes exist — don't rely on hostname alone. + return Boolean( + document.querySelector( + ".commentarea .thing.comment, #siteTable .thing.comment, .thing.comment[data-fullname^='t1_'], body.listing-page .thing.link" + ) + ); + } + + function isShreddit() { + return Boolean(document.querySelector("shreddit-post, shreddit-comment, shreddit-comment-tree, shreddit-app")); + } + + function cleanText(value) { + return (value || "").replace(/\s+/g, " ").trim(); + } + + function absoluteUrl(href) { + if (!href) return ""; + try { + return new URL(href, location.origin).href; + } catch { + return href; + } + } + + function normalizeThingId(raw, kind) { + if (!raw) return ""; + raw = String(raw); + if (raw.startsWith("t1_") || raw.startsWith("t3_")) return raw; + if (kind === "comment") return `t1_${raw.replace(/^t1_/, "")}`; + if (kind === "post") return `t3_${raw.replace(/^t3_/, "")}`; + return raw; + } + + // querySelectorAll does not pierce shadow roots; Reddit nests comments there. + function queryAllDeep(root, selector) { + const results = []; + const seen = new Set(); + + function visit(node) { + if (!node || !node.querySelectorAll) return; + let matches; + try { + matches = node.querySelectorAll(selector); + } catch { + return; + } + matches.forEach((el) => { + if (seen.has(el)) return; + seen.add(el); + results.push(el); + }); + node.querySelectorAll("*").forEach((el) => { + if (el.shadowRoot) visit(el.shadowRoot); + }); + } + + visit(root); + return results; + } + + function debugDomHints() { + const tags = new Set(); + let openShadows = 0; + walkDom(document.documentElement, (el) => { + const name = (el.tagName || "").toLowerCase(); + if ( + name.includes("comment") || + name.includes("shreddit") || + name.includes("faceplate") || + (el.id && String(el.id).includes("t1_")) || + (el.getAttribute && (el.getAttribute("thingid") || "").includes("t1_")) + ) { + tags.add(name || "(anon)"); + } + if (el.shadowRoot) openShadows += 1; + }); + return { tags: [...tags].sort().slice(0, 40), openShadows }; + } + + function scrapeOldPost() { + const thing = document.querySelector("#siteTable .thing.link"); + if (!thing) return null; + const postId = normalizeThingId(thing.getAttribute("data-fullname") || thing.id.replace(/^thing_/, ""), "post"); + const titleEl = thing.querySelector("a.title"); + const authorEl = thing.querySelector("a.author"); + const selftext = thing.querySelector(".usertext-body .md"); + const subreddit = thing.getAttribute("data-subreddit") || ""; + return { + reddit_post_id: postId, + title: cleanText(titleEl && titleEl.textContent), + body: selftext ? selftext.innerText.trim() : "", + author_id: thing.getAttribute("data-author-fullname") || "", + author_name: cleanText((authorEl && authorEl.textContent) || thing.getAttribute("data-author") || "[deleted]"), + permalink: absoluteUrl(thing.getAttribute("data-permalink") || (titleEl && titleEl.getAttribute("href"))), + subreddit, + element: thing, + }; + } + + function scrapeOldComments(post) { + const nodes = document.querySelectorAll(".commentarea .thing.comment"); + const comments = []; + nodes.forEach((node) => { + if (!node.getBoundingClientRect) return; + const id = normalizeThingId(node.getAttribute("data-fullname") || node.id.replace(/^thing_/, ""), "comment"); + if (!id) return; + const authorEl = node.querySelector(":scope > .entry a.author, :scope > .entry .tagline a.author"); + const bodyEl = node.querySelector(":scope > .entry .usertext-body .md, :scope > .entry .usertext-body"); + const permalinkEl = node.querySelector(':scope > .entry a[data-event-action="permalink"], :scope > .entry a.bylink'); + const shotTarget = node.querySelector(":scope > .entry") || node; + comments.push({ + reddit_comment_id: id, + body: bodyEl ? bodyEl.innerText.trim() : "", + author_id: node.getAttribute("data-author-fullname") || "", + author_name: cleanText((authorEl && authorEl.textContent) || node.getAttribute("data-author") || "[deleted]"), + reddit_post_id: post ? post.reddit_post_id : "", + post_title: post ? post.title : "", + post_body: post ? post.body : "", + post_author_id: post ? post.author_id : "", + post_author_name: post ? post.author_name : "", + post_permalink: post ? post.permalink : "", + subreddit: post ? post.subreddit : (node.getAttribute("data-subreddit") || ""), + permalink: absoluteUrl(permalinkEl && permalinkEl.getAttribute("href")), + element: shotTarget, + }); + }); + return comments; + } + + function scrapeNewPost() { + const post = + queryAllDeep(document, "shreddit-post")[0] || + document.querySelector('[id^="t3_"]') || + document.querySelector('div[data-testid="post-container"]'); + if (!post) { + const match = location.pathname.match(/\/comments\/([a-z0-9]+)\//i); + if (!match) return null; + return { + reddit_post_id: normalizeThingId(match[1], "post"), + title: cleanText(document.title.replace(/\s*:\s*r\/.*$/, "")), + body: "", + author_id: "", + author_name: "", + permalink: location.href.split("?")[0], + subreddit: (location.pathname.match(/\/r\/([^/]+)/) || [])[1] || "", + element: document.querySelector("main") || document.body, + }; + } + + const postId = + normalizeThingId(post.getAttribute("id") || post.getAttribute("thingid") || post.getAttribute("post-id"), "post") || + (() => { + const m = location.pathname.match(/\/comments\/([a-z0-9]+)\//i); + return m ? normalizeThingId(m[1], "post") : ""; + })(); + + const title = + post.getAttribute("post-title") || + cleanText((post.querySelector('h1, [slot="title"], a[slot="title"]') || {}).textContent) || + cleanText(document.title); + + const authorName = + post.getAttribute("author") || + cleanText((post.querySelector('a[href*="/user/"], [slot="authorName"]') || {}).textContent) || + ""; + + const bodyEl = + post.querySelector('[slot="text-body"], .text-container, [data-testid="post-content"]') || + post.querySelector("p"); + + const subreddit = + post.getAttribute("subreddit-name") || + (location.pathname.match(/\/r\/([^/]+)/) || [])[1] || + ""; + + const permalink = + absoluteUrl(post.getAttribute("permalink") || post.getAttribute("content-href")) || + location.href.split("?")[0]; + + return { + reddit_post_id: postId, + title, + body: bodyEl ? bodyEl.innerText.trim() : "", + author_id: post.getAttribute("author-id") || "", + author_name: authorName || "[deleted]", + permalink, + subreddit, + element: post, + }; + } + + function findCommentElement(commentId) { + const bare = String(commentId || "").replace(/^t1_/, ""); + const full = String(commentId || "").startsWith("t1_") ? String(commentId) : `t1_${bare}`; + const want = new Set([full, `t1_${bare}`, bare, `thing_${full}`, `thing_${bare}`].filter(Boolean)); + + // Classic old Reddit markup (www or old.reddit.com). + const classic = + document.querySelector(`.thing.comment[data-fullname="${full}"]`) || + document.querySelector(`.thing.comment[data-fullname="${bare}"]`) || + document.querySelector(`#thing_${full}`) || + document.querySelector(`#thing_${bare}`); + if (classic) { + return classic.querySelector(":scope > .entry") || classic; + } + + const selectors = [ + `shreddit-comment[thingid="${full}"]`, + `shreddit-comment[thingid="t1_${bare}"]`, + `shreddit-comment[id="${full}"]`, + `[thingid="${full}"]`, + `[thingid="t1_${bare}"]`, + `[comment-id="${full}"]`, + `[data-fullname="${full}"]`, + `[data-fullname="${bare}"]`, + `[id="t1_${bare}"]`, + `[id="${bare}"]`, + `[id="thing_${full}"]`, + `[id="thing_${bare}"]`, + ]; + for (const selector of selectors) { + try { + const hit = queryAllDeep(document, selector)[0]; + if (hit) { + if (hit.classList && hit.classList.contains("comment")) { + return hit.querySelector(":scope > .entry") || hit; + } + return preferShotTarget(hit); + } + } catch { + // ignore + } + } + + let found = null; + walkDom(document.documentElement, (el) => { + if (found || !el.getAttribute) return; + const candidates = [ + el.getAttribute("thingid"), + el.getAttribute("comment-id"), + el.getAttribute("data-fullname"), + el.getAttribute("id"), + el.getAttribute("data-comment-id"), + el.getAttribute("name"), + ]; + for (const raw of candidates) { + if (!raw) continue; + const cleaned = String(raw).replace(/^thing_/, ""); + const normalized = normalizeThingId(cleaned, "comment"); + const stripped = cleaned.replace(/^t1_/, ""); + if (want.has(raw) || want.has(cleaned) || want.has(normalized) || want.has(stripped)) { + if (el.classList && el.classList.contains("comment")) { + found = el.querySelector(":scope > .entry") || el; + } else { + found = preferShotTarget(el); + } + return; + } + } + if (el.tagName === "A") { + const href = el.getAttribute("href") || ""; + if (href.includes(`/${bare}`) || href.includes(full) || href.includes(`t1_${bare}`)) { + found = preferShotTarget(el); + } + } + }); + return found; + } + + function preferShotTarget(el) { + if (!el) return null; + // Prefer a comment-sized container over a tiny permalink icon/link. + let best = el; + let node = el; + for (let depth = 0; depth < 10 && node; depth += 1) { + const rect = node.getBoundingClientRect ? node.getBoundingClientRect() : null; + if (rect && rect.height >= 48 && rect.width >= 120) { + best = node; + // Prefer shreddit-comment host when available. + if ((node.tagName || "").toLowerCase() === "shreddit-comment") break; + } + node = node.parentElement; + } + return best; + } + + function walkDom(root, visit) { + const stack = [root]; + while (stack.length) { + const node = stack.pop(); + if (!node) continue; + visit(node); + if (node.shadowRoot) stack.push(node.shadowRoot); + const children = node.children; + if (children) { + for (let i = 0; i < children.length; i += 1) { + stack.push(children[i]); + } + } + } + } + + function scrapeNewComments(post) { + const selector = [ + "shreddit-comment", + "[thingid^='t1_']", + "[id^='t1_']", + 'div[data-testid="comment"]', + "faceplate-tracker[noun='comment']", + ].join(", "); + const nodes = queryAllDeep(document, selector); + const comments = []; + const seen = new Set(); + + nodes.forEach((node) => { + let id = + node.getAttribute("thingid") || + node.getAttribute("comment-id") || + node.getAttribute("id") || + ""; + // Some wrappers put the id on a child. + if (!id || (!id.startsWith("t1_") && !/^t1_/.test(id))) { + const nested = node.querySelector("[thingid^='t1_'], [id^='t1_']"); + if (nested) { + id = nested.getAttribute("thingid") || nested.id || id; + } + } + id = normalizeThingId(id, "comment"); + if (!id || !id.startsWith("t1_") || seen.has(id)) return; + seen.add(id); + + const authorName = + node.getAttribute("author") || + cleanText((node.querySelector('a[href*="/user/"]') || {}).textContent) || + "[deleted]"; + + const bodyEl = + node.querySelector('[slot="comment"], .md, [id$="-post-rtjson-content"], [data-testid="comment"]') || + node.querySelector("p"); + + const permalinkAttr = node.getAttribute("permalink"); + const permalinkLink = node.querySelector('a[href*="/comments/"]'); + const permalink = + absoluteUrl(permalinkAttr) || + absoluteUrl(permalinkLink && permalinkLink.getAttribute("href")) || + ""; + + comments.push({ + reddit_comment_id: id, + body: bodyEl ? bodyEl.innerText.trim() : cleanText(node.innerText).slice(0, 5000), + author_id: node.getAttribute("author-id") || "", + author_name: authorName, + reddit_post_id: post ? post.reddit_post_id : "", + post_title: post ? post.title : "", + post_body: post ? post.body : "", + post_author_id: post ? post.author_id : "", + post_author_name: post ? post.author_name : "", + post_permalink: post ? post.permalink : "", + subreddit: post ? post.subreddit : "", + permalink, + element: node, + }); + }); + return comments; + } + + function walkListingComments(children, out) { + if (!Array.isArray(children)) return; + children.forEach((child) => { + if (!child || child.kind !== "t1" || !child.data) return; + const d = child.data; + const id = normalizeThingId(d.name || d.id, "comment"); + if (!id) return; + out.push({ + reddit_comment_id: id, + body: d.body || "", + author_id: d.author_fullname || "", + author_name: d.author || "[deleted]", + permalink: absoluteUrl(d.permalink), + subreddit: d.subreddit || "", + }); + if (d.replies && d.replies.data && d.replies.data.children) { + walkListingComments(d.replies.data.children, out); + } + }); + } + + let jsonCache = { postId: "", at: 0, comments: [], error: "" }; + + async function fetchThreadCommentsJson(post) { + if (!post || !post.reddit_post_id) return []; + const now = Date.now(); + if (jsonCache.postId === post.reddit_post_id && now - jsonCache.at < 15000) { + if (jsonCache.error) throw new Error(jsonCache.error); + return jsonCache.comments.map((c) => ({ + ...c, + element: findCommentElement(c.reddit_comment_id), + })); + } + const bare = post.reddit_post_id.replace(/^t3_/, ""); + try { + const response = await chrome.runtime.sendMessage({ + type: "rcs.redditJson", + path: `/comments/${bare}.json?limit=500&raw_json=1`, + }); + if (!response || !response.ok) { + throw new Error((response && response.error) || "reddit json failed"); + } + const listing = response.data; + const comments = []; + if (Array.isArray(listing) && listing[1] && listing[1].data) { + walkListingComments(listing[1].data.children, comments); + } + const mapped = comments.map((c) => ({ + ...c, + reddit_post_id: post.reddit_post_id, + post_title: post.title, + post_body: post.body, + post_author_id: post.author_id, + post_author_name: post.author_name, + post_permalink: post.permalink, + subreddit: c.subreddit || post.subreddit, + })); + jsonCache = { postId: post.reddit_post_id, at: now, comments: mapped, error: "" }; + return mapped.map((c) => ({ + ...c, + element: findCommentElement(c.reddit_comment_id), + })); + } catch (err) { + jsonCache = { + postId: post.reddit_post_id, + at: now, + comments: [], + error: String(err && err.message ? err.message : err), + }; + throw err; + } + } + + function mergeComments(domComments, jsonComments) { + const byId = new Map(); + domComments.forEach((c) => byId.set(c.reddit_comment_id, c)); + jsonComments.forEach((c) => { + const existing = byId.get(c.reddit_comment_id); + if (!existing) { + byId.set(c.reddit_comment_id, c); + return; + } + // Prefer non-empty body / author from JSON when DOM is empty. + if (!existing.body && c.body) existing.body = c.body; + if ((!existing.author_name || existing.author_name === "[deleted]") && c.author_name) { + existing.author_name = c.author_name; + } + if (!existing.permalink && c.permalink) existing.permalink = c.permalink; + if (!existing.element && c.element) existing.element = c.element; + }); + return [...byId.values()]; + } + + function scrapePage() { + const classic = isOldReddit(); + const post = classic ? scrapeOldPost() : scrapeNewPost(); + const comments = classic ? scrapeOldComments(post) : scrapeNewComments(post); + return { post, comments, isOld: classic, isShreddit: !classic && isShreddit() }; + } + + async function scrapePageAsync() { + const base = scrapePage(); + if (base.isOld) return base; + try { + const jsonComments = await fetchThreadCommentsJson(base.post); + return { + ...base, + comments: mergeComments(base.comments, jsonComments), + jsonCount: jsonComments.length, + domCount: base.comments.length, + }; + } catch (err) { + return { + ...base, + jsonError: String(err && err.message ? err.message : err), + jsonCount: 0, + domCount: base.comments.length, + }; + } + } + + function isElementMostlyVisible(el) { + if (!el) return false; + const rect = el.getBoundingClientRect(); + if (rect.width < 2 || rect.height < 2) return false; + const vh = window.innerHeight || document.documentElement.clientHeight; + return rect.bottom > 0 && rect.top < vh; + } + + global.RCSScrape = { + scrapePage, + scrapePageAsync, + isElementMostlyVisible, + isOldReddit, + debugDomHints, + findCommentElement, + }; +})(typeof window !== "undefined" ? window : self); diff --git a/extension/lib/api.js b/extension/lib/api.js new file mode 100644 index 0000000..0eb1691 --- /dev/null +++ b/extension/lib/api.js @@ -0,0 +1,176 @@ +// Shared API helpers. +// Content scripts must NEVER fetch the backend directly (Chrome Private Network +// Access would prompt "allow Reddit to access local network"). All page-context +// calls go through the background service worker instead. + +async function rcsGetSettings() { + const stored = await chrome.storage.sync.get({ + backendUrl: "", + apiKey: "", + }); + return { + backendUrl: (stored.backendUrl || "").replace(/\/+$/, ""), + apiKey: stored.apiKey || "", + }; +} + +function rcsMustProxyViaBackground() { + // Popup/options are chrome-extension:// and may fetch directly. + // Content scripts run on https://reddit.com and must proxy. + try { + return typeof location === "undefined" || location.protocol !== "chrome-extension:"; + } catch { + return true; + } +} + +async function rcsFetchViaBackground(path, options = {}) { + const response = await chrome.runtime.sendMessage({ + type: "rcs.api", + path, + method: options.method || "GET", + headers: options.headers || {}, + body: options.body || null, + }); + if (!response || !response.ok) { + throw new Error((response && response.error) || "backend request failed"); + } + return response.data; +} + +async function rcsFetchDirect(path, options = {}) { + const settings = await rcsGetSettings(); + if (!settings.backendUrl) { + throw new Error("Backend URL not configured"); + } + const headers = Object.assign( + { Accept: "application/json" }, + options.headers || {} + ); + if (settings.apiKey) { + headers["X-API-Key"] = settings.apiKey; + } + const response = await fetch(`${settings.backendUrl}${path}`, { + ...options, + headers, + credentials: "include", + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + if (!response.ok) { + throw new Error(rcsProxyAuthErrorMessage(response.status, text, data)); + } + if (data && data.raw != null && rcsLooksLikeHtml(data.raw)) { + throw new Error(rcsProxyAuthErrorMessage(response.status, text, data)); + } + return data; +} + +function rcsLooksLikeHtml(text) { + return typeof text === "string" && /^\s*", + "https://www.reddit.com/*", + "https://old.reddit.com/*", + "https://reddit.com/*", + "http://127.0.0.1/*", + "http://localhost/*" + ], + "optional_host_permissions": [ + "http://*/*", + "https://*/*" + ], + "background": { + "service_worker": "background/service-worker.js" + }, + "action": { + "default_title": "RCS", + "default_popup": "popup/popup.html" + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false + }, + "content_scripts": [ + { + "matches": [ + "https://www.reddit.com/*", + "https://old.reddit.com/*", + "https://reddit.com/*" + ], + "js": [ + "lib/api.js", + "content/scrape.js", + "content/capture.js" + ], + "run_at": "document_idle" + } + ] +} diff --git a/extension/options/options.html b/extension/options/options.html new file mode 100644 index 0000000..8ca1c3b --- /dev/null +++ b/extension/options/options.html @@ -0,0 +1,45 @@ + + + + + RCS Options + + + +

RCS settings

+ + + +

+ +

Blocked authors

+

Synced with the backend. Comments from these users are not saved.

+
+ + +
+

+ + + + + + diff --git a/extension/options/options.js b/extension/options/options.js new file mode 100644 index 0000000..741372a --- /dev/null +++ b/extension/options/options.js @@ -0,0 +1,102 @@ +const backendUrlInput = document.getElementById("backendUrl"); +const apiKeyInput = document.getElementById("apiKey"); +const msg = document.getElementById("msg"); +const blocklistEl = document.getElementById("blocklist"); +const blockUserInput = document.getElementById("blockUserInput"); +const blocklistStatus = document.getElementById("blocklistStatus"); + +function renderBlocklist(usernames) { + blocklistEl.innerHTML = ""; + if (!usernames.length) { + const empty = document.createElement("li"); + empty.className = "muted"; + empty.textContent = "No blocked authors."; + blocklistEl.appendChild(empty); + return; + } + usernames.forEach((name) => { + const li = document.createElement("li"); + const label = document.createElement("span"); + label.textContent = name; + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = "Remove"; + remove.addEventListener("click", async () => { + blocklistStatus.textContent = "Syncing…"; + try { + const list = await rcsRemoveBlocked(name); + renderBlocklist(list); + blocklistStatus.textContent = "Synced"; + } catch (err) { + blocklistStatus.textContent = String(err && err.message ? err.message : err); + blocklistStatus.classList.add("error"); + } + }); + li.appendChild(label); + li.appendChild(remove); + blocklistEl.appendChild(li); + }); +} + +async function syncBlocklist() { + blocklistStatus.classList.remove("error"); + blocklistStatus.textContent = "Syncing…"; + try { + const list = await rcsPullBlocklist(); + renderBlocklist(list); + blocklistStatus.textContent = `Synced (${list.length})`; + } catch (err) { + const cached = await rcsGetCachedBlocklist(); + renderBlocklist(cached); + blocklistStatus.textContent = "Sync failed — showing cache. " + (err && err.message ? err.message : err); + blocklistStatus.classList.add("error"); + } +} + +chrome.storage.sync.get({ backendUrl: "", apiKey: "" }, (settings) => { + backendUrlInput.value = settings.backendUrl || ""; + apiKeyInput.value = settings.apiKey || ""; + if (settings.backendUrl) { + syncBlocklist(); + } else { + rcsGetCachedBlocklist().then(renderBlocklist); + } +}); + +document.getElementById("save").addEventListener("click", async () => { + const backendUrl = backendUrlInput.value.trim().replace(/\/+$/, ""); + const apiKey = apiKeyInput.value.trim(); + try { + if (backendUrl) new URL(backendUrl); + } catch { + msg.textContent = "Invalid URL"; + return; + } + if (backendUrl) { + try { + const origin = new URL(backendUrl).origin + "/*"; + await chrome.permissions.request({ origins: [origin] }); + } catch { + // ignore + } + } + await chrome.storage.sync.set({ backendUrl, apiKey }); + msg.textContent = "Saved."; + if (backendUrl) await syncBlocklist(); +}); + +document.getElementById("blockAdd").addEventListener("click", async () => { + const username = blockUserInput.value.trim(); + if (!username) return; + blocklistStatus.classList.remove("error"); + blocklistStatus.textContent = "Syncing…"; + try { + const list = await rcsAddBlocked(username); + blockUserInput.value = ""; + renderBlocklist(list); + blocklistStatus.textContent = "Synced"; + } catch (err) { + blocklistStatus.textContent = String(err && err.message ? err.message : err); + blocklistStatus.classList.add("error"); + } +}); diff --git a/extension/popup/popup.css b/extension/popup/popup.css new file mode 100644 index 0000000..ebc77e1 --- /dev/null +++ b/extension/popup/popup.css @@ -0,0 +1,134 @@ +body { + width: 320px; + margin: 0; + padding: 14px; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + background: #121820; + color: #e8eef4; +} + +h1 { + margin: 0; + font-size: 1.25rem; + letter-spacing: 0.04em; +} + +.sub { + margin: 0.2rem 0 1rem; + color: #8b98a5; + font-size: 0.85rem; +} + +label { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-bottom: 0.7rem; + font-size: 0.8rem; + color: #8b98a5; +} + +input { + padding: 0.45rem 0.55rem; + border-radius: 6px; + border: 1px solid #2c3640; + background: #0f1419; + color: #e8eef4; +} + +button { + width: 100%; + border: 0; + border-radius: 6px; + padding: 0.55rem 0.7rem; + background: #ff4500; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +button.secondary { + background: #2c3640; + margin-top: 0.6rem; +} + +.error { + color: #ff8f8f; + font-size: 0.8rem; +} + +a { + color: #4a9eff; +} + +.muted { + color: #8b98a5; + font-size: 0.8rem; +} + +.blocklist { + margin: 1rem 0 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid #2c3640; +} + +.blocklist h2 { + margin: 0 0 0.25rem; + font-size: 0.95rem; +} + +.blocklist .hint { + margin: 0 0 0.6rem; + color: #8b98a5; + font-size: 0.75rem; +} + +.blocklist-add { + display: flex; + gap: 0.4rem; + margin-bottom: 0.5rem; +} + +.blocklist-add input { + flex: 1; + min-width: 0; +} + +.blocklist-add button { + width: auto; + padding: 0.45rem 0.7rem; +} + +.blocklist-items { + list-style: none; + margin: 0.4rem 0 0; + padding: 0; + max-height: 160px; + overflow: auto; +} + +.blocklist-items li { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.35rem 0; + border-bottom: 1px solid #1c2430; + font-size: 0.85rem; +} + +.blocklist-items button.remove { + width: auto; + padding: 0.25rem 0.5rem; + background: #2c3640; + font-size: 0.75rem; +} + +#blocklistStatus { + margin: 0.25rem 0; +} + +#blocklistStatus.error { + color: #ff8f8f; +} + diff --git a/extension/popup/popup.html b/extension/popup/popup.html new file mode 100644 index 0000000..44fcd2e --- /dev/null +++ b/extension/popup/popup.html @@ -0,0 +1,47 @@ + + + + + RCS + + + +

RCS

+

Reddit Comment Saver

+ + + + + + + + + diff --git a/extension/popup/popup.js b/extension/popup/popup.js new file mode 100644 index 0000000..817935b --- /dev/null +++ b/extension/popup/popup.js @@ -0,0 +1,198 @@ +const setupEl = document.getElementById("setup"); +const statusEl = document.getElementById("status"); +const backendUrlInput = document.getElementById("backendUrl"); +const apiKeyInput = document.getElementById("apiKey"); +const setupError = document.getElementById("setupError"); +const statusLine = document.getElementById("statusLine"); +const openBackend = document.getElementById("openBackend"); +const blocklistEl = document.getElementById("blocklist"); +const blockUserInput = document.getElementById("blockUserInput"); +const blocklistStatus = document.getElementById("blocklistStatus"); + +function showSetup(settings) { + setupEl.hidden = false; + statusEl.hidden = true; + backendUrlInput.value = settings.backendUrl || "http://127.0.0.1:8080"; + apiKeyInput.value = settings.apiKey || ""; +} + +function setBlocklistStatus(text, isError) { + if (!text) { + blocklistStatus.hidden = true; + blocklistStatus.textContent = ""; + return; + } + blocklistStatus.hidden = false; + blocklistStatus.textContent = text; + blocklistStatus.classList.toggle("error", Boolean(isError)); +} + +function renderBlocklist(usernames) { + blocklistEl.innerHTML = ""; + if (!usernames.length) { + const empty = document.createElement("li"); + empty.className = "muted"; + empty.textContent = "No blocked authors."; + blocklistEl.appendChild(empty); + return; + } + usernames.forEach((name) => { + const li = document.createElement("li"); + const label = document.createElement("span"); + label.textContent = name; + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "remove"; + remove.textContent = "Remove"; + remove.addEventListener("click", async () => { + setBlocklistStatus("Syncing…"); + try { + const list = await rcsRemoveBlocked(name); + renderBlocklist(list); + setBlocklistStatus("Synced"); + } catch (err) { + setBlocklistStatus(String(err && err.message ? err.message : err), true); + } + }); + li.appendChild(label); + li.appendChild(remove); + blocklistEl.appendChild(li); + }); +} + +async function syncBlocklistFromBackend() { + setBlocklistStatus("Syncing…"); + try { + const list = await rcsPullBlocklist(); + renderBlocklist(list); + setBlocklistStatus(`Synced (${list.length})`); + } catch (err) { + const cached = await rcsGetCachedBlocklist(); + renderBlocklist(cached); + setBlocklistStatus("Sync failed — showing cache. " + (err && err.message ? err.message : err), true); + } +} + +function showStatus(settings, healthOk, healthHint) { + setupEl.hidden = true; + statusEl.hidden = false; + if (healthOk) { + statusLine.textContent = `Connected to ${settings.backendUrl}`; + } else if (healthHint) { + statusLine.textContent = healthHint; + } else { + statusLine.textContent = `Saved ${settings.backendUrl} (health check failed — is the backend running?)`; + } + openBackend.href = settings.backendUrl; + if (healthOk) { + syncBlocklistFromBackend(); + } else { + rcsGetCachedBlocklist().then(renderBlocklist); + setBlocklistStatus("Connect to sync blocklist", true); + } +} + +async function load() { + const settings = await chrome.storage.sync.get({ backendUrl: "", apiKey: "" }); + if (!settings.backendUrl) { + showSetup(settings); + return; + } + try { + const headers = { Accept: "application/json" }; + if (settings.apiKey) headers["X-API-Key"] = settings.apiKey; + const response = await fetch(`${settings.backendUrl.replace(/\/+$/, "")}/api/health`, { + headers, + credentials: "include", + }); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = { raw: text }; + } + const looksHtml = typeof text === "string" && /^\s* { + setupError.hidden = true; + const backendUrl = backendUrlInput.value.trim().replace(/\/+$/, ""); + const apiKey = apiKeyInput.value.trim(); + if (!backendUrl) { + setupError.textContent = "Backend URL is required."; + setupError.hidden = false; + return; + } + try { + new URL(backendUrl); + } catch { + setupError.textContent = "Invalid URL."; + setupError.hidden = false; + return; + } + + const granted = await ensureHostPermission(backendUrl); + if (!granted) { + setupError.textContent = "Permission to reach the backend was denied."; + setupError.hidden = false; + return; + } + + await chrome.storage.sync.set({ backendUrl, apiKey }); + await load(); +}); + +document.getElementById("edit").addEventListener("click", async () => { + const settings = await chrome.storage.sync.get({ backendUrl: "", apiKey: "" }); + showSetup(settings); +}); + +document.getElementById("blockAdd").addEventListener("click", async () => { + const username = blockUserInput.value.trim(); + if (!username) return; + setBlocklistStatus("Syncing…"); + try { + const list = await rcsAddBlocked(username); + blockUserInput.value = ""; + renderBlocklist(list); + setBlocklistStatus("Synced"); + } catch (err) { + setBlocklistStatus(String(err && err.message ? err.message : err), true); + } +}); + +blockUserInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + document.getElementById("blockAdd").click(); + } +}); + +load(); diff --git a/scripts/prune-dev-data.sh b/scripts/prune-dev-data.sh new file mode 100755 index 0000000..9982cd9 --- /dev/null +++ b/scripts/prune-dev-data.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Remove local seed/test rows from data/rcs.db; keep real Reddit captures. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DB="${RCS_DB_PATH:-$ROOT/data/rcs.db}" +SHOT_DIR="${RCS_DATA_DIR:-$ROOT/data}/screenshots" + +if [[ ! -f "$DB" ]]; then + echo "No database at $DB — nothing to prune." + exit 0 +fi + +before="$(sqlite3 "$DB" 'SELECT COUNT(*) FROM comments;')" + +sqlite3 "$DB" <<'SQL' +PRAGMA foreign_keys = ON; + +DELETE FROM comments +WHERE reddit_comment_id = 't1_testcomment1' + OR reddit_comment_id LIKE 't1_page_%'; + +DELETE FROM posts +WHERE reddit_post_id IN ('t3_testpost1', 't3_onlypost', 't3_seedpost'); + +DELETE FROM users +WHERE reddit_user_id IN ('t2_user1', 't2_op', 't2_a', 't2_seed'); + +-- Orphan posts with no comments left +DELETE FROM posts +WHERE id NOT IN (SELECT DISTINCT post_id FROM comments); + +-- Orphan users not referenced by comments or posts +DELETE FROM users +WHERE id NOT IN (SELECT DISTINCT author_id FROM comments) + AND id NOT IN (SELECT DISTINCT author_id FROM posts); +SQL + +rm -f "$SHOT_DIR/t1_testcomment1.png" + +after="$(sqlite3 "$DB" 'SELECT COUNT(*) FROM comments;')" +posts="$(sqlite3 "$DB" 'SELECT COUNT(*) FROM posts;')" +users="$(sqlite3 "$DB" 'SELECT COUNT(*) FROM users;')" + +echo "Pruned seed/test data from $DB" +echo " comments: $before -> $after" +echo " posts: $posts" +echo " users: $users" diff --git a/scripts/rcs-watchdog.sh b/scripts/rcs-watchdog.sh new file mode 100755 index 0000000..6d6e73f --- /dev/null +++ b/scripts/rcs-watchdog.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Ensure the RCS backend is running (intended for user crontab every minute). +# Does not rebuild the binary — run `make build` first. +set -euo pipefail + +REPO="$(cd "$(dirname "$0")/.." && pwd)" +BIN="${RCS_BIN:-$REPO/bin/rcs}" +DATA_DIR="${RCS_DATA_DIR_OVERRIDE:-$REPO/data}" +PIDFILE="${RCS_PIDFILE:-$DATA_DIR/rcs.pid}" +LOG="${RCS_LOG:-$DATA_DIR/rcs.log}" +ENVFILE="${RCS_ENV_FILE:-$DATA_DIR/rcs.env}" +LOCKFILE="${RCS_LOCKFILE:-$DATA_DIR/rcs.watchdog.lock}" + +mkdir -p "$DATA_DIR" + +exec 9>"$LOCKFILE" +if ! flock -n 9; then + # Another watchdog instance is starting the process. + exit 0 +fi + +log_msg() { + local line="[$(date -Iseconds)] $*" + echo "$line" >>"$LOG" +} + +if [[ ! -x "$BIN" ]]; then + log_msg "ERROR: binary missing or not executable: $BIN (run: make build)" + echo "rcs-watchdog: binary missing: $BIN" >&2 + exit 1 +fi + +if [[ -f "$PIDFILE" ]]; then + old_pid="$(tr -d '[:space:]' <"$PIDFILE" || true)" + if [[ -n "${old_pid:-}" ]] && kill -0 "$old_pid" 2>/dev/null; then + exit 0 + fi + rm -f "$PIDFILE" +fi + +# Absolute data dir so cron cwd ($HOME) does not matter. +export RCS_DATA_DIR="$DATA_DIR" + +if [[ -f "$ENVFILE" ]]; then + # shellcheck disable=SC1090 + set -a + # shellcheck disable=SC1090 + source "$ENVFILE" + set +a + # Keep data under the repo unless the env file overrides with an absolute path. + if [[ -z "${RCS_DATA_DIR:-}" || "${RCS_DATA_DIR}" == "data" || "${RCS_DATA_DIR}" == "./data" ]]; then + export RCS_DATA_DIR="$DATA_DIR" + fi +fi + +cd "$REPO" +nohup "$BIN" >>"$LOG" 2>&1 & +new_pid=$! +echo "$new_pid" >"$PIDFILE" +log_msg "started $BIN pid=$new_pid" +exit 0