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, ``)
- _, _ = 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, ``)
+
+ // 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, ``) // 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)