diff --git a/README.md b/README.md index 1254579..8835165 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Pinata takes very little memory to run, about 8MB of memory with an Alpine docke Port 8080 is needed to run with this method; Docker is most recommended if that is taken. * Clone this repo. +* (optional, but bookmarks will be unavailable) ``head -c 32 /dev/urandom | base64`` and then ``export PINATA_BOOKMARK_KEY=resultofpreviouscommand``. * ``go build -trimpath -ldflags="-s -w" -o pinata ./main.go`` * Wait a few seconds for that tasty binary. * Run in background with ``./pinata &`` @@ -22,6 +23,6 @@ Port 8080 is needed to run with this method; Docker is most recommended if that ### Docker Compose (recommended) * Clone this repo. -* Tweak ``compose.yml`` as you see fit. +* Tweak ``compose.yml`` as you see fit and follow instructions if you want to enable bookmarks. * ``sudo docker compose up -d`` to build and run. -* Need to update? ``git pull && docker compose up -d --build`` \ No newline at end of file +* Need to update? ``git pull && docker compose up -d --build`` diff --git a/compose.yml b/compose.yml index 2d772ff..104e3cc 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,12 @@ -version: "3.8" services: pinata: build: . ports: - "127.0.0.1:8080:8080" + environment: + # Set this to a key generated with the "head -c 32 /dev/urandom | base64" command if you want to enable bookmarks for users; this allows cookies to be encrypted so you'll never see their searches. + - PINATA_BOOKMARK_KEY= + # The reverse image search uses Tineye, which often requires Cloudflare! If you aren't comfortable with it, set this variable to 0. + - PINATA_DISABLE_REVERSE=1 restart: unless-stopped + diff --git a/main.go b/main.go index 9059781..89b6567 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,9 @@ package main import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" "encoding/base64" "encoding/json" "html" @@ -11,11 +14,12 @@ import ( "net" "net/http" "net/url" + "os" "strings" + "sync" "time" ) -// ------------------- Config & globals ------------------- var httpClient = &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{ @@ -23,96 +27,240 @@ var httpClient = &http.Client{ Timeout: 8 * time.Second, KeepAlive: 30 * time.Second, }).DialContext, - MaxIdleConns: 10, // small pool to reduce memory - MaxIdleConnsPerHost: 6, + MaxIdleConns: 6, + MaxIdleConnsPerHost: 3, IdleConnTimeout: 60 * time.Second, TLSHandshakeTimeout: 8 * time.Second, }, } -const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/" +var copyBufPool = sync.Pool{ + New: func() any { + b := make([]byte, 32*1024) + return &b + }, +} -// ------------------- Static CSS & HTML header/footer ------------------- +const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/" +const cookieName = "pinata_bm" + +// -- bookmark encryption key (32 bytes) -- +var bookmarkKey []byte +var bookmarkingEnabled bool +var disableReverse bool + +func init() { + if kb := os.Getenv("PINATA_BOOKMARK_KEY"); kb != "" { + if decoded, err := base64.StdEncoding.DecodeString(kb); err == nil && len(decoded) == 32 { + bookmarkKey = decoded + bookmarkingEnabled = true + log.Println("Bookmarking enabled") + } else { + log.Println("PINATA_BOOKMARK_KEY provided but invalid; bookmarking disabled") + bookmarkingEnabled = false + } + } else { + log.Println("PINATA_BOOKMARK_KEY not set; bookmarking disabled") + bookmarkingEnabled = false + } + + // New: whether to disable reverse image search (Tineye) + disableEnv := strings.ToLower(strings.TrimSpace(os.Getenv("PINATA_DISABLE_REVERSE"))) + if disableEnv == "1" || disableEnv == "true" || disableEnv == "yes" { + disableReverse = true + log.Println("Reverse image search disabled via PINATA_DISABLE_REVERSE") + } else { + disableReverse = false + } +} + +// encrypt a JSON list of strings -> base64 url safe +func encryptBookmarks(items []string) (string, error) { + plain, _ := json.Marshal(items) + block, err := aes.NewCipher(bookmarkKey) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ciphertext := gcm.Seal(nonce, nonce, plain, nil) + return base64.RawURLEncoding.EncodeToString(ciphertext), nil +} + +// decrypt base64 cookie -> list of strings +func decryptBookmarks(encoded string) ([]string, error) { + data, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(bookmarkKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + ns := gcm.NonceSize() + if len(data) < ns { + return nil, err + } + nonce := data[:ns] + ct := data[ns:] + plain, err := gcm.Open(nil, nonce, ct, nil) + if err != nil { + return nil, err + } + var items []string + if err := json.Unmarshal(plain, &items); err != nil { + return nil, err + } + return items, nil +} + +// helper to read bookmarks from request cookie (returns empty slice if none or invalid) +func readBookmarksFromReq(r *http.Request) []string { + if !bookmarkingEnabled { + return nil + } + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + return nil + } + items, err := decryptBookmarks(c.Value) + if err != nil { + return nil + } + return items +} + +// helper to set bookmarks cookie +func setBookmarksCookie(w http.ResponseWriter, items []string) { + if !bookmarkingEnabled { + return + } + // sanitize and truncate each item + trunc := make([]string, 0, len(items)) + for _, s := range items { + s = strings.TrimSpace(s) + if s == "" { + continue + } + if len(s) > 64 { + s = s[:64] + } + trunc = append(trunc, s) + if len(trunc) >= 30 { + break + } + } + enc, err := encryptBookmarks(trunc) + if err != nil { + // fail silently (do not set) + return + } + c := &http.Cookie{ + Name: cookieName, + Value: enc, + Path: "/", + HttpOnly: true, // not accessible to JS (we have no JS but keeps it private) + SameSite: http.SameSiteLaxMode, + // Secure: true, // keep commented for local HTTP dev - set true in production behind TLS + MaxAge: 60 * 60 * 24 * 365 * 10, // ~10 years + } + http.SetCookie(w, c) +} + +// remove bookmark and reset cookie +func removeBookmarkCookie(w http.ResponseWriter, items []string) { + // if empty, clear cookie + if len(items) == 0 { + c := &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + } + http.SetCookie(w, c) + return + } + setBookmarksCookie(w, items) +} + +// ----- CSS and HTML (no JS) ----- const cssContent = ` :root{--bg:#0b0f17;--muted:#94a3b8;--text:#e6e6ff;--accent:#7c3aed;--card-shadow:rgba(0,0,0,0.6)} -*{box-sizing:border-box}body{margin:0;padding:20px;background:linear-gradient(180deg,#071020 0%,#0b0f17 100%);color:var(--text);font-family:ui-monospace,Menlo,Monaco,monospace} -.header{display:flex;gap:12px;align-items:center;margin-bottom:18px}.brand{font-size:20px;font-weight:700;color:var(--accent);text-decoration:none}.search-box{margin-left:auto;display:flex;gap:8px;align-items:center}input[type="text"]{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:8px 12px;color:var(--text);min-width:240px;border-radius:8px;outline:none}button[type="submit"]{background:linear-gradient(90deg,var(--accent),#5b21b6);color:white;border:none;padding:8px 12px;border-radius:8px;cursor:pointer} -.img-container{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:16px;align-items:start;margin-top:18px} -.card{position:relative;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)} -.card img{display:block;width:100%;height:auto;object-fit:cover;background:#08101a} +*{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} +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} +.search-box{margin-left:auto;display:flex;gap:8px;align-items:center;flex:0 1 auto} +.search-block{width:100%;display:flex;gap:8px;margin-top:14px} +.search-inline{display:flex;gap:8px;align-items:center;min-width:0} +input[type="text"]{background:transparent;border:1px solid rgba(255,255,255,0.06);padding:8px 12px;color:var(--text);min-width:120px;border-radius:8px;outline:none} +button[type="submit"],.btn-save{background:linear-gradient(90deg,var(--accent),#5b21b6);color:white;border:none;padding:8px 12px;border-radius:8px;cursor:pointer} +.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; } .magnifier{position:absolute;top:8px;right:8px;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;transition:transform .12s ease,background .12s ease} -.magnifier:hover{transform:translateY(-2px);background:linear-gradient(180deg,rgba(124,58,237,0.14),rgba(124,58,237,0.08));color:white;cursor:pointer} -.pagination{text-align:center;margin:26px 0}.pagination a{color:var(--accent);text-decoration:none;padding:8px 12px;border-radius:8px;border:1px solid rgba(124,58,237,0.12);background:rgba(124,58,237,0.02)} +.magnifier:hover{transform:translateY(-2px);background:linear-gradient(180deg,rgba(124,58,237,0.14),rgba(124,58,237,0.08));color:white} +.bookmarks{margin-left:12px;color:var(--muted);font-size:14px} +.bookmark-list{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap} +.bookmark-pill{background:rgba(255,255,255,0.03);padding:6px 8px;border-radius:999px;border:1px solid rgba(255,255,255,0.04);font-size:13px;display:flex;gap:6px;align-items:center} +.bookmark-pill form{display:inline} +.bookmark-remove-btn{background:transparent;border:none;color:#ff7b7b;font-weight:700;cursor:pointer;padding:0 6px} +.pagination{text-align:center;margin:26px 0} +.pagination a{color:var(--accent);text-decoration:none;padding:8px 12px;border-radius:8px;border:1px solid rgba(124,58,237,0.12);background:rgba(124,58,237,0.02)} .footer-note{color:var(--muted);font-size:12px;margin-top:22px} -` - -const indexHTML = ` - - - -Pinata - Search - - - -
- Pinata - -
-
Search images from Pinterest — click an image to open, or use the magnifier to search Tineye.
- - -` - -// header for streamed results; we write query directly escaped -func resultsHeader(query string) string { - return ` - - - -` + html.EscapeString(query) + ` - Pinata - - - -
- Pinata -
-
- - -
-
-
-

Results for "` + html.EscapeString(query) + `"

-
` +@media (max-width:640px){ + body{padding:12px;font-size:18px} + .brand{font-size:22px} + input[type="text"]{min-width:120px;padding:12px 14px;font-size:16px} + button[type="submit"],.btn-save{padding:10px 14px;font-size:16px;border-radius:10px} + .img-container{column-width:180px;column-gap:12px} + .search-block{gap:10px;flex-direction:column} + .search-inline{width:100%} + .search-box{margin-left:0;width:100%} + .bookmarks{order:3;width:100%;margin-top:8px} } - -const resultsFooterPrefix = `
` // close img-container; will append pagination and footer after streaming - -const footerHTML = ` -{{PAGINATION}} - - - ` -// ------------------- Handlers ------------------- -func styleHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/css; charset=utf-8") - _, _ = w.Write([]byte(cssContent)) -} - +// Static index page (no JS). Bookmarks rendered server-side only here. func indexHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(indexHTML)) + // header and intro + io.WriteString(w, `Pinata - Search`) + io.WriteString(w, `
Pinata
`) + io.WriteString(w, `
Search images from Pinterest — submit a search to view results.
`) + // search block + io.WriteString(w, `
`) + + // bookmarks area (server-rendered) + if bookmarkingEnabled { + items := readBookmarksFromReq(r) + io.WriteString(w, `
Saved searches
`) + for _, q := range items { + escaped := html.EscapeString(q) + // Each bookmark pill: link to /search?q=... and a small form to remove + io.WriteString(w, ``+escaped+``) + // remove form + io.WriteString(w, `
`) + } + io.WriteString(w, `
`) + } + + io.WriteString(w, ``) } -// searchHandler streams Pinterest response and writes each card as it's decoded. -// It captures csrftoken cookie and bookmark; bookmark is used to render the "Next" link at the end. +// Search streaming handler (same streaming approach as before), includes server-side Save form when bookmarkingEnabled func searchHandler(w http.ResponseWriter, r *http.Request) { q := strings.TrimSpace(r.URL.Query().Get("q")) if len(q) < 1 || len(q) > 64 { @@ -122,7 +270,6 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { bookmark := r.URL.Query().Get("bookmark") csrftoken := r.URL.Query().Get("csrftoken") - // Build data param JSON (same structure as PHP) dataObj := map[string]any{"options": map[string]any{"query": q}} if bookmark != "" { dataObj["options"].(map[string]any)["bookmarks"] = []string{bookmark} @@ -160,7 +307,6 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { } defer resp.Body.Close() - // capture csrftoken if set by server (we return it via pagination link) var newCsrf string for _, c := range resp.Cookies() { if strings.EqualFold(c.Name, "csrftoken") { @@ -169,44 +315,49 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { } } - // Start streaming HTML + // start streaming HTML w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte(resultsHeader(q))) + io.WriteString(w, ``+html.EscapeString(q)+` - Pinata`) + // header with inline search and server-side Save form (if enabled) + io.WriteString(w, `
Pinata
`) + io.WriteString(w, `

Results for "`+html.EscapeString(q)+`"

`) + io.WriteString(w, `
`) dec := json.NewDecoder(resp.Body) - - // We'll find "results" array and iterate it; also capture "bookmark" value var nextBookmark string - // Use token streaming to find the "results" key and the "bookmark" key. for { tk, err := dec.Token() if err != nil { if err == io.EOF { break } - // parsing error: finish gracefully log.Printf("json token error: %v", err) break } - // We're looking for string keys key, ok := tk.(string) if !ok { continue } switch key { case "results": - // next token should be '[' t, err := dec.Token() if err != nil { log.Printf("unexpected json after results: %v", err) continue } if delim, ok := t.(json.Delim); !ok || delim != '[' { - // not an array, continue continue } - // iterate results array: decode each result object into a small struct var rObj struct { Images struct { Orig struct { @@ -215,7 +366,6 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { } `json:"images"` } for dec.More() { - // decode one result; this allocates a very small struct each time if err := dec.Decode(&rObj); err != nil { log.Printf("error decoding result item: %v", err) break @@ -224,25 +374,21 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { if u == "" { continue } - // write card HTML for this image (escape values) esc := url.QueryEscape(u) b64 := base64.StdEncoding.EncodeToString([]byte(u)) - // card html - _, _ = io.WriteString(w, `
`) - _, _ = io.WriteString(w, ``) - _, _ = io.WriteString(w, `image`) - _, _ = io.WriteString(w, ``) - _, _ = io.WriteString(w, `🔍`) - _, _ = io.WriteString(w, `
`) - // flush to client if possible (net/http does buffering internally) + var cardBuilder strings.Builder + cardBuilder.WriteString(`
image`) + if !disableReverse { + cardBuilder.WriteString(`🔍`) + } + cardBuilder.WriteString(`
`) + io.WriteString(w, cardBuilder.String()) if f, ok := w.(http.Flusher); ok { f.Flush() } } - // consume closing ']' token - _, _ = dec.Token() + _, _ = dec.Token() // consume closing case "bookmark": - // next token should be the bookmark string (or null) t, err := dec.Token() if err == nil { if s, ok := t.(string); ok { @@ -250,17 +396,15 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { } } default: - // ignore other keys continue } } - // Close the img-container - _, _ = io.WriteString(w, resultsFooterPrefix) + // close container + io.WriteString(w, `
`) - // Render pagination if we have a bookmark + // show pagination link if present if nextBookmark != "" { - // Build next link with csrftoken if present qenc := url.QueryEscape(q) benc := url.QueryEscape(nextBookmark) cenc := "" @@ -270,17 +414,78 @@ func searchHandler(w http.ResponseWriter, r *http.Request) { cenc = "&csrftoken=" + url.QueryEscape(csrftoken) } next := "/search?q=" + qenc + "&bookmark=" + benc + cenc - _, _ = io.WriteString(w, ``) + io.WriteString(w, ``) } - // Footer - _, _ = io.WriteString(w, ``) - _, _ = io.WriteString(w, ``) - - // done + io.WriteString(w, ``) } -// imageProxyHandler streams remote image bodies directly to the client (no buffering) +// POST /bookmark : server-side saving of a bookmark (no JS). expects form q and optional next. +func bookmarkPostHandler(w http.ResponseWriter, r *http.Request) { + if !bookmarkingEnabled { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + q := strings.TrimSpace(r.FormValue("q")) + if q == "" || len(q) > 64 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + next := r.FormValue("next") + if next == "" { + next = "/" + } + // read existing + items := readBookmarksFromReq(r) + // remove if exists then prepend + newItems := make([]string, 0, 32) + newItems = append(newItems, q) + for _, v := range items { + if v == q { + continue + } + newItems = append(newItems, v) + if len(newItems) >= 30 { + break + } + } + setBookmarksCookie(w, newItems) + http.Redirect(w, r, next, http.StatusSeeOther) +} + +// POST /bookmark_remove : remove a bookmark (form q) +func bookmarkRemoveHandler(w http.ResponseWriter, r *http.Request) { + if !bookmarkingEnabled { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + q := strings.TrimSpace(r.FormValue("q")) + items := readBookmarksFromReq(r) + if len(items) == 0 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + out := make([]string, 0, len(items)) + for _, v := range items { + if v == q { + continue + } + out = append(out, v) + } + // set or clear cookie + removeBookmarkCookie(w, out) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +// imageProxy uses pooled buffer for io.CopyBuffer func imageProxyHandler(w http.ResponseWriter, r *http.Request) { uq := r.URL.Query().Get("url") if uq == "" { @@ -299,7 +504,7 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed", http.StatusBadGateway) return } - req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:144.0) Gecko/20100101 Firefox/144.0") + req.Header.Set("User-Agent", "PinataGo/1.0") resp, err := httpClient.Do(req) if err != nil { http.Error(w, "failed to fetch", http.StatusBadGateway) @@ -307,7 +512,6 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) { } defer resp.Body.Close() - // copy only needed headers if ct := resp.Header.Get("Content-Type"); ct != "" { w.Header().Set("Content-Type", ct) } @@ -315,11 +519,19 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", cc) } w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) + + bufPtr := copyBufPool.Get().(*[]byte) + buf := *bufPtr + _, _ = io.CopyBuffer(w, resp.Body, buf) + copyBufPool.Put(bufPtr) } -// revsearchHandler decodes base64 and redirects to Tineye +// revsearch redirect to tineye func revsearchHandler(w http.ResponseWriter, r *http.Request) { + if disableReverse { + http.Error(w, "Reverse image search disabled", http.StatusNotFound) + return + } b64 := r.URL.Query().Get("b64") if b64 == "" { http.Error(w, "b64 required", http.StatusBadRequest) @@ -339,6 +551,12 @@ func revsearchHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, tineye, http.StatusSeeOther) } +// static CSS +func styleHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + io.WriteString(w, cssContent) +} + func main() { mux := http.NewServeMux() mux.HandleFunc("/static/style.css", styleHandler) @@ -346,6 +564,9 @@ func main() { mux.HandleFunc("/search", searchHandler) mux.HandleFunc("/image_proxy", imageProxyHandler) mux.HandleFunc("/revsearch", revsearchHandler) + // bookmark endpoints (POST) + mux.HandleFunc("/bookmark", bookmarkPostHandler) + mux.HandleFunc("/bookmark_remove", bookmarkRemoveHandler) srv := &http.Server{ Addr: ":8080", @@ -354,6 +575,6 @@ func main() { WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } - log.Println("Pinata listening on :8080 (streaming mode, low allocations)") + log.Println("Pinata listening on :8080") log.Fatal(srv.ListenAndServe()) } diff --git a/screenies/pinata.png b/screenies/pinata.png index 5d5988c..5624d8b 100644 Binary files a/screenies/pinata.png and b/screenies/pinata.png differ