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 = ` - -
- -