Add self-hosted RCS backend, extension, and ops tooling
Ship the Go/SQLite API and Web UI, Chrome/Brave capture addon, Docker Compose, Pangolin reverse-proxy support, and a user-crontab watchdog so the binary stays running without systemd.
This commit is contained in:
@@ -0,0 +1,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", "")
|
||||
}
|
||||
Reference in New Issue
Block a user