diff --git a/main.go b/main.go index 0400016..8e70d5a 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "fmt" "html" "io" "log" @@ -15,6 +16,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "sync" "time" @@ -37,7 +39,7 @@ var httpClient = &http.Client{ var copyBufPool = sync.Pool{ New: func() any { - b := make([]byte, 32*1024) // 32KB + b := make([]byte, 32*1024) return &b }, } @@ -223,10 +225,81 @@ func clearBookmarksCookie(w http.ResponseWriter) { http.SetCookie(w, c) } -// ---------- CSS and HTML (no JS) ---------- +// ---------- theme helpers ---------- + +// validate and normalize a hex color; returns "#rrggbb" or empty string if invalid +func normalizeHexColor(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + // allow with or without leading '#' + if strings.HasPrefix(s, "#") { + s = s[1:] + } + if len(s) != 6 { + return "" + } + for _, r := range s { + if !(('0' <= r && r <= '9') || ('a' <= r && r <= 'f') || ('A' <= r && r <= 'F')) { + return "" + } + } + return "#" + strings.ToLower(s) +} + +// hex to rgba string with alpha +func hexToRGBA(hex string, alpha float64) string { + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return "rgba(124,58,237,0.12)" // fallback purple-ish + } + rv, _ := strconv.ParseUint(hex[0:2], 16, 8) + gv, _ := strconv.ParseUint(hex[2:4], 16, 8) + bv, _ := strconv.ParseUint(hex[4:6], 16, 8) + return fmt.Sprintf("rgba(%d,%d,%d,%.2f)", rv, gv, bv, alpha) +} + +// get theme variables from cookies; returns accent (hex) and imgScale (float like "1.00") +func getThemeVars(r *http.Request) (string, string) { + // Default accent + accent := "#7c3aed" + imgScale := "1.00" // default 100% + if c, err := r.Cookie("pinata_accent"); err == nil { + if val := normalizeHexColor(c.Value); val != "" { + accent = val + } + } + if c2, err := r.Cookie("pinata_img_scale"); err == nil { + // expect integer percent + if p, err := strconv.Atoi(c2.Value); err == nil { + if p < 50 { + p = 50 + } + if p > 200 { + p = 200 + } + // convert to scale + scale := float64(p) / 100.0 + imgScale = fmt.Sprintf("%.2f", scale) + } + } + return accent, imgScale +} + +// ---------- CSS (uses CSS vars; defaults are present but overridden per-request via inline style) ---------- const cssContent = ` -:root{--bg:#0b0f17;--muted:#94a3b8;--text:#e6e6ff;--accent:#7c3aed;--card-shadow:rgba(0,0,0,0.6)} -*{box-sizing:border-box}html,body{height:100%}body{margin:0;padding:20px;background:linear-gradient(180deg,#071020 0%,#0b0f17 100%);color:var(--text);font-family:ui-monospace,Menlo,Monaco,monospace} +:root{ + --bg:#0b0f17; + --muted:#94a3b8; + --text:#e6e6ff; + --accent:#7c3aed; /* default; overridden by inline style */ + --accent-rgba: rgba(124,58,237,0.12); + --img-scale: 1; +} +*{box-sizing:border-box} +html,body{height:100%} +body{margin:0;padding:20px;background:linear-gradient(180deg,#071020 0%,var(--bg) 100%);color:var(--text);font-family:ui-monospace,Menlo,Monaco,monospace} a{color:inherit} .header{display:flex;gap:12px;align-items:center;margin-bottom:18px;flex-wrap:wrap} .brand{font-size:20px;font-weight:700;color:var(--accent);text-decoration:none} @@ -238,9 +311,9 @@ button[type="submit"],.btn-save{background:linear-gradient(90deg,var(--accent),# .btn-save{font-weight:600} .img-container { column-width: 260px; column-gap: 16px; width: 100%; max-width: 1400px; margin-top: 18px; } .card { display:inline-block; width:100%; margin:0 0 16px; border-radius:10px; overflow:hidden; background:linear-gradient(180deg,rgba(255,255,255,0.01),rgba(255,255,255,0.02)); box-shadow:0 6px 18px rgba(3,7,18,0.6); border:1px solid rgba(124,58,237,0.06); break-inside: avoid; -webkit-column-break-inside: avoid; -moz-column-break-inside: avoid; min-height:0; position:relative; } -.card img { display:block; width:100%; height:auto; object-fit:cover; background:#08101a; } +.card img { display:block; width:100%; height:auto; object-fit:cover; background:#08101a; transform-origin: top center; transform: scale(var(--img-scale)); } .card-controls { position:absolute; top:8px; right:8px; display:flex; gap:8px; align-items:center; } -.btn-save-mini { background: rgba(124,58,237,0.12); color: var(--text); border: none; padding:6px; border-radius:999px; cursor:pointer; font-weight:700; } +.btn-save-mini { background: rgba(0,0,0,0.45); border:1px solid rgba(255,255,255,0.06); color: var(--text); padding:6px; border-radius:999px; cursor:pointer; font-weight:700; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; text-decoration:none; } .magnifier{background:rgba(0,0,0,0.45);border:1px solid rgba(255,255,255,0.06);color:var(--text);padding:6px;border-radius:999px;font-size:14px;width:34px;height:34px;display:inline-flex;align-items:center;justify-content:center;text-decoration:none} .bookmarks{margin-left:12px;color:var(--muted);font-size:14px} .bookmark-list{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap} @@ -255,18 +328,85 @@ button[type="submit"],.btn-save{background:linear-gradient(90deg,var(--accent),# ` // ---------- handlers ---------- + func styleHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/css; charset=utf8") + w.Header().Set("Content-Type", "text/css; charset=utf-8") _, _ = io.WriteString(w, cssContent) } +// settings POST handler: sets accent color and image scale cookies +func settingsPostHandler(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + accent := normalizeHexColor(r.FormValue("accent")) + scaleStr := r.FormValue("scale") // expected as integer percent like "100" + if accent == "" { + accent = "#7c3aed" + } + percent := 100 + if ss := strings.TrimSpace(scaleStr); ss != "" { + if p, err := strconv.Atoi(ss); err == nil { + if p < 50 { + p = 50 + } + if p > 200 { + p = 200 + } + percent = p + } + } + // set cookies (non-encrypted, not sensitive) + http.SetCookie(w, &http.Cookie{ + Name: "pinata_accent", + Value: accent, + Path: "/", + MaxAge: 60 * 60 * 24 * 365 * 5, + }) + http.SetCookie(w, &http.Cookie{ + Name: "pinata_img_scale", + Value: strconv.Itoa(percent), + Path: "/", + MaxAge: 60 * 60 * 24 * 365 * 5, + }) + next := r.FormValue("next") + if next == "" { + next = "/" + } + http.Redirect(w, r, next, http.StatusSeeOther) +} + +// Index (front) - server-rendered bookmarks and settings form (no JS) func indexHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf8") - _, _ = io.WriteString(w, `Pinata - Search`) + accent, imgScale := getThemeVars(r) + // produce small inline style that overrides css vars + accentRgba := hexToRGBA(accent, 0.12) + inlineStyle := fmt.Sprintf(``, html.EscapeString(accent), html.EscapeString(accentRgba), html.EscapeString(imgScale)) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, `Pinata - Search`+inlineStyle+``) _, _ = io.WriteString(w, `
Pinata
`) - _, _ = io.WriteString(w, `
Search images from Pinterest — submit a search to view results.
`) + _, _ = io.WriteString(w, `
Search images from Pinterest — submit a search to view results.
`) _, _ = io.WriteString(w, `
`) + // Settings form (color + scale) + _, _ = io.WriteString(w, `
`) + _, _ = io.WriteString(w, ``) + _, _ = io.WriteString(w, ``) + _, _ = io.WriteString(w, `
`) + + // bookmarks shown only on index if bookmarkingEnabled { items := readBookmarksFromReq(r) _, _ = io.WriteString(w, `
Saved bookmarks
`) @@ -280,15 +420,15 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `
`) } _, _ = io.WriteString(w, `
`) - _, _ = io.WriteString(w, `
`) _, _ = io.WriteString(w, `
`) _, _ = io.WriteString(w, `
`) } - _, _ = io.WriteString(w, ``) + _, _ = io.WriteString(w, ``) } +// searchHandler: streaming results, include inline style variables from cookies func searchHandler(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) if len(q) < 1 || len(q) > 64 { @@ -343,8 +483,14 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { } } + accent, imgScale := getThemeVars(r) + accentRgba := hexToRGBA(accent, 0.12) + inlineStyle := fmt.Sprintf(``, html.EscapeString(accent), html.EscapeString(accentRgba), html.EscapeString(imgScale)) + + // Start streaming HTML w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = io.WriteString(w, ``+html.EscapeString(q)+` - Pinata`) + _, _ = io.WriteString(w, ``+html.EscapeString(q)+` - Pinata`+inlineStyle+``) + // header: inline search and Save-search form _, _ = io.WriteString(w, `
Pinata`) // card-controls + _, _ = io.WriteString(w, `
`) // card + if f, ok := w.(http.Flusher); ok { f.Flush() } @@ -442,10 +590,88 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { next := "/search?q=" + qenc + "&bookmark=" + benc + cenc _, _ = io.WriteString(w, ``) } - _, _ = io.WriteString(w, ``) + _, _ = io.WriteString(w, ``) } -// ---------- bookmark handlers ---------- +// ---------- secure image proxy (only https i.pinimg.com) ---------- +func imageProxyHandler(w http.ResponseWriter, r *http.Request) { + uq := r.URL.Query().Get("url") + if uq == "" { + http.Error(w, "url required", http.StatusBadRequest) + return + } + orig, err := url.QueryUnescape(uq) + if err != nil { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + parsed, err := url.Parse(orig) + if err != nil { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + // require https and exact host + if parsed.Scheme != "https" { + http.Error(w, "proxy allowed for https only", http.StatusForbidden) + return + } + if !strings.EqualFold(parsed.Hostname(), "i.pinimg.com") { + http.Error(w, "proxy allowed only for i.pinimg.com", http.StatusForbidden) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil) + if err != nil { + http.Error(w, "failed", http.StatusBadGateway) + return + } + req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0") + resp, err := httpClient.Do(req) + if err != nil { + http.Error(w, "failed to fetch", http.StatusBadGateway) + return + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); ct != "" { + w.Header().Set("Content-Type", ct) + } + if cc := resp.Header.Get("Cache-Control"); cc != "" { + w.Header().Set("Cache-Control", cc) + } + w.WriteHeader(resp.StatusCode) + bufPtr := copyBufPool.Get().(*[]byte) + buf := *bufPtr + _, _ = io.CopyBuffer(w, resp.Body, buf) + copyBufPool.Put(bufPtr) +} + +func revsearchHandler(w http.ResponseWriter, r *http.Request) { + if disableReverse { + http.Error(w, "reverse disabled", http.StatusNotFound) + return + } + b64 := r.URL.Query().Get("b64") + if b64 == "" { + http.Error(w, "b64 required", http.StatusBadRequest) + return + } + bs, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + http.Error(w, "invalid b64", http.StatusBadRequest) + return + } + orig := string(bs) + if !(strings.HasPrefix(orig, "http://") || strings.HasPrefix(orig, "https://")) { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + tineye := "https://tineye.com/search?url=" + url.QueryEscape(orig) + http.Redirect(w, r, tineye, http.StatusSeeOther) +} + +// ---------- bookmark handlers (unchanged from previous) ---------- + func bookmarkPostHandler(w http.ResponseWriter, r *http.Request) { if !bookmarkingEnabled { http.Redirect(w, r, "/", http.StatusSeeOther) @@ -637,98 +863,11 @@ func bookmarksImportHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } -// ---------- image proxy & revsearch ---------- -func imageProxyHandler(w http.ResponseWriter, r *http.Request) { - uq := r.URL.Query().Get("url") - if uq == "" { - http.Error(w, "url required", http.StatusBadRequest) - return - } - orig, err := url.QueryUnescape(uq) - if err != nil { - http.Error(w, "invalid url", http.StatusBadRequest) - return - } - - parsed, err := url.Parse(orig) - if err != nil { - http.Error(w, "invalid url", http.StatusBadRequest) - return - } - - // Require https scheme - if parsed.Scheme != "https" { - http.Error(w, "proxy allowed for https only", http.StatusForbidden) - return - } - - // Require exact host i.pinimg.com (case insensitive) - if !strings.EqualFold(parsed.Hostname(), "i.pinimg.com") { - http.Error(w, "proxy allowed only for i.pinimg.com", http.StatusForbidden) - return - } - - // Build request with timeout - ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) - defer cancel() - req, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil) - if err != nil { - http.Error(w, "failed", http.StatusBadGateway) - return - } - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0") - - resp, err := httpClient.Do(req) - if err != nil { - http.Error(w, "failed to fetch", http.StatusBadGateway) - return - } - defer resp.Body.Close() - - // Copy select headers and stream body using pooled buffer - if ct := resp.Header.Get("Content-Type"); ct != "" { - w.Header().Set("Content-Type", ct) - } - if cc := resp.Header.Get("Cache-Control"); cc != "" { - w.Header().Set("Cache-Control", cc) - } - w.WriteHeader(resp.StatusCode) - - bufPtr := copyBufPool.Get().(*[]byte) - buf := *bufPtr - _, _ = io.CopyBuffer(w, resp.Body, buf) - copyBufPool.Put(bufPtr) -} - - -func revsearchHandler(w http.ResponseWriter, r *http.Request) { - if disableReverse { - http.Error(w, "reverse disabled", http.StatusNotFound) - return - } - b64 := r.URL.Query().Get("b64") - if b64 == "" { - http.Error(w, "b64 required", http.StatusBadRequest) - return - } - bs, err := base64.StdEncoding.DecodeString(b64) - if err != nil { - http.Error(w, "invalid b64", http.StatusBadRequest) - return - } - orig := string(bs) - if !(strings.HasPrefix(orig, "http://") || strings.HasPrefix(orig, "https://")) { - http.Error(w, "invalid url", http.StatusBadRequest) - return - } - tineye := "https://tineye.com/search?url=" + url.QueryEscape(orig) - http.Redirect(w, r, tineye, http.StatusSeeOther) -} - // ---------- main ---------- func main() { mux := http.NewServeMux() mux.HandleFunc("/static/style.css", styleHandler) + mux.HandleFunc("/settings", settingsPostHandler) mux.HandleFunc("/", indexHandler) mux.HandleFunc("/search", searchHandler) mux.HandleFunc("/image_proxy", imageProxyHandler)