package main
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// ---------- tuned HTTP client and buffer pool ----------
var httpClient = &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 8 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 6,
MaxIdleConnsPerHost: 3,
IdleConnTimeout: 60 * time.Second,
TLSHandshakeTimeout: 8 * time.Second,
},
}
var copyBufPool = sync.Pool{
New: func() any {
b := make([]byte, 32*1024)
return &b
},
}
const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/"
const cookieName = "pinata_bm"
// ---------- bookmarks types / config ----------
type BookmarkEntry struct {
Type string `json:"type"` // "q" or "img"
Value string `json:"value"` // query or image URL
}
var bookmarkKey []byte
var bookmarkingEnabled bool
var disableReverse bool
var chunkedMode bool
var chunkSize = 8
var chunkWorkers = 4
const maxBookmarks = 30
const maxItemLen = 256
// ---------- init: read env ----------
func init() {
// PINATA_BOOKMARK_KEY: base64 32-byte key
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 {
bookmarkingEnabled = false
log.Println("PINATA_BOOKMARK_KEY present but invalid; bookmarking disabled")
}
} else {
bookmarkingEnabled = false
log.Println("PINATA_BOOKMARK_KEY not set; bookmarking disabled")
}
// PINATA_DISABLE_REVERSE: "1"/"true"/"yes" disables reverse search
switch strings.ToLower(strings.TrimSpace(os.Getenv("PINATA_DISABLE_REVERSE"))) {
case "1", "true", "yes":
disableReverse = true
log.Println("Reverse image search disabled via PINATA_DISABLE_REVERSE")
default:
disableReverse = false
}
// CHUNK enables chunked/threaded rendering of result cards.
// Examples:
// CHUNK=0/false/no/off -> disabled
// CHUNK=1/true/yes/on -> enabled with default chunk size
// CHUNK=12 -> enabled with 12-item batches
if raw := strings.TrimSpace(os.Getenv("CHUNK")); raw != "" {
switch strings.ToLower(raw) {
case "0", "false", "no", "off":
chunkedMode = false
default:
chunkedMode = true
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
chunkSize = n
}
}
}
if chunkSize < 4 {
chunkSize = 4
}
if chunkSize > 16 {
chunkSize = 16
}
cpus := runtime.GOMAXPROCS(0)
if cpus < 1 {
cpus = 1
}
if cpus > 4 {
cpus = 4
}
chunkWorkers = cpus
if chunkedMode {
log.Printf("Chunked mode enabled: chunkSize=%d workers=%d", chunkSize, chunkWorkers)
}
}
// ---------- encryption helpers (AES-GCM) ----------
func encryptBookmarks(entries []BookmarkEntry) (string, error) {
if !bookmarkingEnabled {
return "", nil
}
plain, err := json.Marshal(entries)
if err != nil {
return "", err
}
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
}
ct := gcm.Seal(nonce, nonce, plain, nil)
return base64.RawURLEncoding.EncodeToString(ct), nil
}
func decryptBookmarks(encoded string) ([]BookmarkEntry, error) {
if !bookmarkingEnabled {
return nil, nil
}
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, io.ErrUnexpectedEOF
}
nonce := data[:ns]
ct := data[ns:]
plain, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return nil, err
}
// try new format first ([]BookmarkEntry)
var entries []BookmarkEntry
if err := json.Unmarshal(plain, &entries); err == nil {
return entries, nil
}
// fallback to legacy []string
var arr []string
if err := json.Unmarshal(plain, &arr); err == nil {
out := make([]BookmarkEntry, 0, len(arr))
for _, s := range arr {
out = append(out, BookmarkEntry{Type: "q", Value: s})
}
return out, nil
}
return nil, io.ErrUnexpectedEOF
}
// ---------- cookie helpers ----------
func readBookmarksFromReq(r *http.Request) []BookmarkEntry {
if !bookmarkingEnabled {
return nil
}
c, err := r.Cookie(cookieName)
if err != nil || c.Value == "" {
return nil
}
entries, err := decryptBookmarks(c.Value)
if err != nil {
return nil
}
return entries
}
func setBookmarksCookie(w http.ResponseWriter, entries []BookmarkEntry) {
if !bookmarkingEnabled {
return
}
seen := map[string]bool{}
out := make([]BookmarkEntry, 0, len(entries))
for _, e := range entries {
v := strings.TrimSpace(e.Value)
if v == "" {
continue
}
if len(v) > maxItemLen {
v = v[:maxItemLen]
}
if e.Type != "q" && e.Type != "img" {
e.Type = "q"
}
key := e.Type + "|" + v
if seen[key] {
continue
}
seen[key] = true
out = append(out, BookmarkEntry{Type: e.Type, Value: v})
if len(out) >= maxBookmarks {
break
}
}
enc, err := encryptBookmarks(out)
if err != nil {
return
}
c := &http.Cookie{
Name: cookieName,
Value: enc,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
// Secure: true, // enable in production with HTTPS
MaxAge: 60 * 60 * 24 * 365 * 10,
}
http.SetCookie(w, c)
}
func clearBookmarksCookie(w http.ResponseWriter) {
c := &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
}
http.SetCookie(w, c)
}
// ---------- 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; /* 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}
.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}
/* NOTE: column-width now scales with --img-scale so the image/card width grows by percentage */
.img-container { column-width: calc(260px * var(--img-scale)); column-gap: 16px; width: 100%; max-width: 1400px; margin-top: 18px; }
/* card sizing driven by column width; image fills the card without transform (no zoom) */
.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; /* removed transform scale to avoid zooming */ }
.card-controls { position:absolute; top:8px; right:8px; display:flex; gap:8px; align-items:center; }
.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}
.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}
.export-form{margin-top:12px;display:flex;gap:8px;align-items:center}
.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}
/* Mobile: scale base column width by the same variable */
@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: calc(180px * var(--img-scale)); 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}
}
`
// ---------- handlers ----------
func styleHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf8")
_, _ = 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)
}
func renderCardHTML(q, next, u string) string {
esc := url.QueryEscape(u)
b64 := base64.StdEncoding.EncodeToString([]byte(u))
var b strings.Builder
b.Grow(len(u)*2 + 512)
b.WriteString(`
`)
b.WriteString(``)
b.WriteString(`
`)
if !disableReverse {
b.WriteString(`🔍`)
}
if bookmarkingEnabled {
b.WriteString(``)
}
b.WriteString(`
`)
return b.String()
}
func writeChunkedCards(w http.ResponseWriter, q, next string, urls []string) {
if len(urls) == 0 {
return
}
if !chunkedMode || len(urls) == 1 {
for _, u := range urls {
_, _ = io.WriteString(w, renderCardHTML(q, next, u))
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return
}
type job struct {
idx int
u string
}
type result struct {
idx int
html string
}
jobs := make(chan job, len(urls))
results := make(chan result, len(urls))
workers := chunkWorkers
if workers > len(urls) {
workers = len(urls)
}
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for j := range jobs {
results <- result{idx: j.idx, html: renderCardHTML(q, next, j.u)}
}
}()
}
for i, u := range urls {
jobs <- job{idx: i, u: u}
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
out := make([]string, len(urls))
for r := range results {
out[r.idx] = r.html
}
for _, s := range out {
_, _ = io.WriteString(w, s)
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// Index (front) - server-rendered bookmarks and settings form (no JS)
func indexHandler(w http.ResponseWriter, r *http.Request) {
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 is an alternate frontend to Pinterest with support for reverse image search, encrypted bookmarks, and image proxying! None of your data ever reaches Pinterest or their servers while using this frontend, and the instance owner can not ever see what you view or bookmarks.
`)
_, _ = io.WriteString(w, ``)
// Settings form (color + scale)
_, _ = io.WriteString(w, ``)
// bookmarks shown only on index
if bookmarkingEnabled {
items := readBookmarksFromReq(r)
_, _ = io.WriteString(w, `