image bookmarking, bookmarks export/import

This commit is contained in:
gigirassy
2025-10-17 15:10:18 -04:00
parent 3f7d0ff600
commit 42a133243c
+337 -125
View File
@@ -20,6 +20,9 @@ import (
"time" "time"
) )
// --------------------------------------------------------------------
// Configuration: HTTP client and buffer pool (memory tuned)
// --------------------------------------------------------------------
var httpClient = &http.Client{ var httpClient = &http.Client{
Timeout: 15 * time.Second, Timeout: 15 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
@@ -36,7 +39,7 @@ var httpClient = &http.Client{
var copyBufPool = sync.Pool{ var copyBufPool = sync.Pool{
New: func() any { New: func() any {
b := make([]byte, 32*1024) b := make([]byte, 32*1024) // 32KB buffer reused
return &b return &b
}, },
} }
@@ -44,39 +47,63 @@ var copyBufPool = sync.Pool{
const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/" const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/"
const cookieName = "pinata_bm" const cookieName = "pinata_bm"
// -- bookmark encryption key (32 bytes) -- // --------------------------------------------------------------------
// Bookmarks: types, key, limits
// --------------------------------------------------------------------
type BookmarkEntry struct {
Type string `json:"type"` // "q" or "img"
Value string `json:"value"` // query or image URL
}
var bookmarkKey []byte var bookmarkKey []byte
var bookmarkingEnabled bool var bookmarkingEnabled bool
var disableReverse bool var disableReverse bool
const maxBookmarks = 30
const maxItemLen = 256
// --------------------------------------------------------------------
// init: read env variables
// - PINATA_BOOKMARK_KEY: base64 32-byte key to enable bookmarks
// - PINATA_DISABLE_REVERSE: "1"/"true"/"yes" disables reverse search link
// --------------------------------------------------------------------
func init() { func init() {
// PINATA_BOOKMARK_KEY
if kb := os.Getenv("PINATA_BOOKMARK_KEY"); kb != "" { if kb := os.Getenv("PINATA_BOOKMARK_KEY"); kb != "" {
if decoded, err := base64.StdEncoding.DecodeString(kb); err == nil && len(decoded) == 32 { if decoded, err := base64.StdEncoding.DecodeString(kb); err == nil && len(decoded) == 32 {
bookmarkKey = decoded bookmarkKey = decoded
bookmarkingEnabled = true bookmarkingEnabled = true
log.Println("Bookmarking enabled") log.Println("Bookmarking enabled")
} else { } else {
log.Println("PINATA_BOOKMARK_KEY provided but invalid; bookmarking disabled")
bookmarkingEnabled = false bookmarkingEnabled = false
log.Println("PINATA_BOOKMARK_KEY provided but invalid; bookmarking disabled")
} }
} else { } else {
log.Println("PINATA_BOOKMARK_KEY not set; bookmarking disabled")
bookmarkingEnabled = false bookmarkingEnabled = false
log.Println("PINATA_BOOKMARK_KEY not set; bookmarking disabled")
} }
// New: whether to disable reverse image search (Tineye) // PINATA_DISABLE_REVERSE
disableEnv := strings.ToLower(strings.TrimSpace(os.Getenv("PINATA_DISABLE_REVERSE"))) switch strings.ToLower(strings.TrimSpace(os.Getenv("PINATA_DISABLE_REVERSE"))) {
if disableEnv == "1" || disableEnv == "true" || disableEnv == "yes" { case "1", "true", "yes":
disableReverse = true disableReverse = true
log.Println("Reverse image search disabled via PINATA_DISABLE_REVERSE") log.Println("Reverse image search disabled via PINATA_DISABLE_REVERSE")
} else { default:
disableReverse = false disableReverse = false
} }
} }
// encrypt a JSON list of strings -> base64 url safe // --------------------------------------------------------------------
func encryptBookmarks(items []string) (string, error) { // Encryption helpers for cookie storage (AES-GCM)
plain, _ := json.Marshal(items) // --------------------------------------------------------------------
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) block, err := aes.NewCipher(bookmarkKey)
if err != nil { if err != nil {
return "", err return "", err
@@ -89,12 +116,14 @@ func encryptBookmarks(items []string) (string, error) {
if _, err := rand.Read(nonce); err != nil { if _, err := rand.Read(nonce); err != nil {
return "", err return "", err
} }
ciphertext := gcm.Seal(nonce, nonce, plain, nil) ct := gcm.Seal(nonce, nonce, plain, nil)
return base64.RawURLEncoding.EncodeToString(ciphertext), nil return base64.RawURLEncoding.EncodeToString(ct), nil
} }
// decrypt base64 cookie -> list of strings func decryptBookmarks(encoded string) ([]BookmarkEntry, error) {
func decryptBookmarks(encoded string) ([]string, error) { if !bookmarkingEnabled {
return nil, nil
}
data, err := base64.RawURLEncoding.DecodeString(encoded) data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -109,7 +138,7 @@ func decryptBookmarks(encoded string) ([]string, error) {
} }
ns := gcm.NonceSize() ns := gcm.NonceSize()
if len(data) < ns { if len(data) < ns {
return nil, err return nil, io.ErrUnexpectedEOF
} }
nonce := data[:ns] nonce := data[:ns]
ct := data[ns:] ct := data[ns:]
@@ -117,15 +146,27 @@ func decryptBookmarks(encoded string) ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var items []string // Try new format ([]BookmarkEntry)
if err := json.Unmarshal(plain, &items); err != nil { var entries []BookmarkEntry
return nil, err if err := json.Unmarshal(plain, &entries); err == nil {
return entries, nil
} }
return items, nil // Fallback to legacy []string -> convert to type "q"
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
} }
// helper to read bookmarks from request cookie (returns empty slice if none or invalid) // --------------------------------------------------------------------
func readBookmarksFromReq(r *http.Request) []string { // Cookie helpers: read, set, clear
// --------------------------------------------------------------------
func readBookmarksFromReq(r *http.Request) []BookmarkEntry {
if !bookmarkingEnabled { if !bookmarkingEnabled {
return nil return nil
} }
@@ -133,54 +174,59 @@ func readBookmarksFromReq(r *http.Request) []string {
if err != nil || c.Value == "" { if err != nil || c.Value == "" {
return nil return nil
} }
items, err := decryptBookmarks(c.Value) entries, err := decryptBookmarks(c.Value)
if err != nil { if err != nil {
// invalid cookie -> ignore
return nil return nil
} }
return items return entries
} }
// helper to set bookmarks cookie func setBookmarksCookie(w http.ResponseWriter, entries []BookmarkEntry) {
func setBookmarksCookie(w http.ResponseWriter, items []string) {
if !bookmarkingEnabled { if !bookmarkingEnabled {
return return
} }
// sanitize and truncate each item seen := map[string]bool{}
trunc := make([]string, 0, len(items)) out := make([]BookmarkEntry, 0, len(entries))
for _, s := range items { for _, e := range entries {
s = strings.TrimSpace(s) v := strings.TrimSpace(e.Value)
if s == "" { if v == "" {
continue continue
} }
if len(s) > 64 { if len(v) > maxItemLen {
s = s[:64] v = v[:maxItemLen]
} }
trunc = append(trunc, s) key := e.Type + "|" + v
if len(trunc) >= 30 { if seen[key] {
continue
}
seen[key] = true
if e.Type != "q" && e.Type != "img" {
e.Type = "q"
}
out = append(out, BookmarkEntry{Type: e.Type, Value: v})
if len(out) >= maxBookmarks {
break break
} }
} }
enc, err := encryptBookmarks(trunc) enc, err := encryptBookmarks(out)
if err != nil { if err != nil {
// fail silently (do not set) // fail silently
return return
} }
c := &http.Cookie{ c := &http.Cookie{
Name: cookieName, Name: cookieName,
Value: enc, Value: enc,
Path: "/", Path: "/",
HttpOnly: true, // not accessible to JS (we have no JS but keeps it private) HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
// Secure: true, // keep commented for local HTTP dev - set true in production behind TLS // Secure: true, // enable when serving over HTTPS
MaxAge: 60 * 60 * 24 * 365 * 10, // ~10 years MaxAge: 60 * 60 * 24 * 365 * 10,
} }
http.SetCookie(w, c) http.SetCookie(w, c)
} }
// remove bookmark and reset cookie func clearBookmarksCookie(w http.ResponseWriter) {
func removeBookmarkCookie(w http.ResponseWriter, items []string) {
// if empty, clear cookie
if len(items) == 0 {
c := &http.Cookie{ c := &http.Cookie{
Name: cookieName, Name: cookieName,
Value: "", Value: "",
@@ -189,12 +235,11 @@ func removeBookmarkCookie(w http.ResponseWriter, items []string) {
MaxAge: -1, MaxAge: -1,
} }
http.SetCookie(w, c) http.SetCookie(w, c)
return
}
setBookmarksCookie(w, items)
} }
// ----- CSS and HTML (no JS) ----- // --------------------------------------------------------------------
// Stylesheet and minimal HTML templates (no JS)
// --------------------------------------------------------------------
const cssContent = ` const cssContent = `
:root{--bg:#0b0f17;--muted:#94a3b8;--text:#e6e6ff;--accent:#7c3aed;--card-shadow:rgba(0,0,0,0.6)} :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} *{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}
@@ -210,29 +255,22 @@ button[type="submit"],.btn-save{background:linear-gradient(90deg,var(--accent),#
.img-container { column-width: 260px; column-gap: 16px; width: 100%; max-width: 1400px; margin-top: 18px; } .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 { 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; }
.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} .card-controls { position:absolute; top:8px; right:8px; display:flex; gap:8px; align-items:center; }
.magnifier:hover{transform:translateY(-2px);background:linear-gradient(180deg,rgba(124,58,237,0.14),rgba(124,58,237,0.08));color:white} .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; }
.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} .bookmarks{margin-left:12px;color:var(--muted);font-size:14px}
.bookmark-list{margin-top:10px;display:flex;gap:8px;flex-wrap:wrap} .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{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-pill form{display:inline}
.bookmark-remove-btn{background:transparent;border:none;color:#ff7b7b;font-weight:700;cursor:pointer;padding:0 6px} .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{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)} .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} .footer-note{color:var(--muted);font-size:12px;margin-top:22px}
@media (max-width:640px){ @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} }
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}
}
` `
<<<<<<< HEAD
// Static index page (no JS). Bookmarks rendered server-side only here. // Static index page (no JS). Bookmarks rendered server-side only here.
func indexHandler(w http.ResponseWriter, r *http.Request) { func indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf8") w.Header().Set("Content-Type", "text/html; charset=utf8")
@@ -258,9 +296,50 @@ func indexHandler(w http.ResponseWriter, r *http.Request) {
} }
io.WriteString(w, `<div class="footer-note">Powered by Pinata • Reverse search via Tineye</div></body></html>`) io.WriteString(w, `<div class="footer-note">Powered by Pinata • Reverse search via Tineye</div></body></html>`)
=======
// --------------------------------------------------------------------
// Handlers
// --------------------------------------------------------------------
func styleHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
_, _ = io.WriteString(w, cssContent)
>>>>>>> cc7ebb9 (image bookmarking, bookmarks export/import)
} }
// Search streaming handler (same streaming approach as before), includes server-side Save form when bookmarkingEnabled // Index (front page) - server-rendered bookmarks and import/export forms
func indexHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// header and search block
_, _ = io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pinata - Search</title><link rel="stylesheet" href="/static/style.css"></head><body>`)
_, _ = io.WriteString(w, `<div class="header"><a class="brand" href="/">Pinata</a><div class="search-box"></div></div>`)
_, _ = io.WriteString(w, `<div style="color:#94a3b8; margin-bottom:12px;">Search images from Pinterest — submit a search to view results.</div>`)
_, _ = io.WriteString(w, `<form class="search-block" method="get" action="/search"><input type="text" name="q" placeholder="Search Image" required maxlength="64"><button type="submit">Search</button></form>`)
// bookmarks shown only on index
if bookmarkingEnabled {
items := readBookmarksFromReq(r)
_, _ = io.WriteString(w, `<div class="bookmarks"><div style="font-size:14px;color:var(--muted);margin-top:8px">Saved bookmarks</div><div class="bookmark-list">`)
for _, e := range items {
escaped := html.EscapeString(e.Value)
if e.Type == "q" {
_, _ = io.WriteString(w, `<span class="bookmark-pill"><a href="/search?q=`+url.QueryEscape(e.Value)+`">`+escaped+`</a>`)
} else {
_, _ = io.WriteString(w, `<span class="bookmark-pill"><a href="/image_proxy?url=`+url.QueryEscape(e.Value)+`">`+escaped+`</a>`)
}
_, _ = io.WriteString(w, `<form method="post" action="/bookmark_remove" style="display:inline;margin:0 0 0 6px;"><input type="hidden" name="type" value="`+html.EscapeString(e.Type)+`"><input type="hidden" name="value" value="`+html.EscapeString(e.Value)+`"><button class="bookmark-remove-btn" type="submit" title="Remove">✕</button></form></span>`)
}
_, _ = io.WriteString(w, `</div>`)
// export and import
_, _ = io.WriteString(w, `<div class="export-form"><form method="get" action="/bookmarks/export"><button type="submit" class="btn-save">Export JSON</button></form>`)
_, _ = io.WriteString(w, `<form method="post" action="/bookmarks/import" enctype="multipart/form-data" style="margin-left:8px;"><input type="file" name="file" accept="application/json" required><button type="submit" class="btn-save" style="margin-left:8px">Import JSON</button></form></div>`)
_, _ = io.WriteString(w, `</div>`)
}
_, _ = io.WriteString(w, `<div class="footer-note">Powered by Pinata • Reverse search via Tineye</div></body></html>`)
}
// Streaming search handler (no JS). Writes cards as results are decoded.
func searchHandler(w http.ResponseWriter, r *http.Request) { func searchHandler(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q")) q := strings.TrimSpace(r.URL.Query().Get("q"))
if len(q) < 1 || len(q) > 64 { if len(q) < 1 || len(q) > 64 {
@@ -315,22 +394,19 @@ 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.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>`+html.EscapeString(q)+` - Pinata</title><link rel="stylesheet" href="/static/style.css"></head><body>`) _, _ = io.WriteString(w, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>`+html.EscapeString(q)+` - Pinata</title><link rel="stylesheet" href="/static/style.css"></head><body>`)
// header with inline search and server-side Save form (if enabled) // header: inline search and Save-search form
io.WriteString(w, `<div class="header" style="margin-bottom:8px;"><a class="brand" href="/">Pinata</a><div class="search-box">`) _, _ = io.WriteString(w, `<div class="header" style="margin-bottom:8px;"><a class="brand" href="/">Pinata</a><div class="search-box">`)
// inline search form _, _ = io.WriteString(w, `<form class="search-inline" method="get" action="/search"><input type="text" name="q" value="`+html.EscapeString(q)+`" maxlength="64"><button type="submit">Search</button></form>`)
io.WriteString(w, `<form class="search-inline" method="get" action="/search"><input type="text" name="q" value="`+html.EscapeString(q)+`" maxlength="64"><button type="submit">Search</button></form>`)
// Save form: POSTs to /bookmark with q and next back to the current results page
if bookmarkingEnabled { if bookmarkingEnabled {
// next param to return to this search page (use URL-encoded /search?q=...)
next := "/search?q=" + url.QueryEscape(q) next := "/search?q=" + url.QueryEscape(q)
io.WriteString(w, `<form method="post" action="/bookmark" style="margin-left:8px;"><input type="hidden" name="q" value="`+html.EscapeString(q)+`"><input type="hidden" name="next" value="`+html.EscapeString(next)+`"><button class="btn-save" type="submit">Save</button></form>`) _, _ = io.WriteString(w, `<form method="post" action="/bookmark" style="margin-left:8px;"><input type="hidden" name="q" value="`+html.EscapeString(q)+`"><input type="hidden" name="next" value="`+html.EscapeString(next)+`"><button class="btn-save" type="submit">Save</button></form>`)
} }
io.WriteString(w, `</div></div>`) _, _ = io.WriteString(w, `</div></div>`)
io.WriteString(w, `<h2 style="margin:4px 0 0 0;">Results for "`+html.EscapeString(q)+`"</h2>`) _, _ = io.WriteString(w, `<h2 style="margin:4px 0 0 0;">Results for "`+html.EscapeString(q)+`"</h2>`)
io.WriteString(w, `<div class="img-container">`) _, _ = io.WriteString(w, `<div class="img-container">`)
dec := json.NewDecoder(resp.Body) dec := json.NewDecoder(resp.Body)
var nextBookmark string var nextBookmark string
@@ -376,18 +452,26 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
} }
esc := url.QueryEscape(u) esc := url.QueryEscape(u)
b64 := base64.StdEncoding.EncodeToString([]byte(u)) b64 := base64.StdEncoding.EncodeToString([]byte(u))
var cardBuilder strings.Builder
cardBuilder.WriteString(`<div class="card"><a href="/image_proxy?url=` + esc + `" style="display:block;"><img loading="lazy" src="/image_proxy?url=` + esc + `" alt="image"></a>`) // Write card (with magnifier optional and Save image form)
_, _ = io.WriteString(w, `<div class="card">`)
_, _ = io.WriteString(w, `<a href="/image_proxy?url=`+esc+`" style="display:block;"><img loading="lazy" src="/image_proxy?url=`+esc+`" alt="image"></a>`)
_, _ = io.WriteString(w, `<div class="card-controls">`)
if !disableReverse { if !disableReverse {
cardBuilder.WriteString(`<a class="magnifier" href="/revsearch?b64=` + b64 + `" title="Search Tineye" target="_blank">🔍</a>`) _, _ = io.WriteString(w, `<a class="magnifier" href="/revsearch?b64=`+b64+`" title="Search Tineye" target="_blank">🔍</a>`)
} }
cardBuilder.WriteString(`</div>`) if bookmarkingEnabled {
io.WriteString(w, cardBuilder.String()) next := "/search?q=" + url.QueryEscape(q)
_, _ = io.WriteString(w, `<form method="post" action="/bookmark_image" style="display:inline;margin:0;"><input type="hidden" name="url" value="`+html.EscapeString(u)+`"><input type="hidden" name="next" value="`+html.EscapeString(next)+`"><button class="btn-save-mini" type="submit" title="Save image">❤</button></form>`)
}
_, _ = io.WriteString(w, `</div>`) // card-controls
_, _ = io.WriteString(w, `</div>`) // card
if f, ok := w.(http.Flusher); ok { if f, ok := w.(http.Flusher); ok {
f.Flush() f.Flush()
} }
} }
_, _ = dec.Token() // consume closing _, _ = dec.Token()
case "bookmark": case "bookmark":
t, err := dec.Token() t, err := dec.Token()
if err == nil { if err == nil {
@@ -400,10 +484,8 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
} }
} }
// close container // finish results
io.WriteString(w, `</div>`) _, _ = io.WriteString(w, `</div>`)
// show pagination link if present
if nextBookmark != "" { if nextBookmark != "" {
qenc := url.QueryEscape(q) qenc := url.QueryEscape(q)
benc := url.QueryEscape(nextBookmark) benc := url.QueryEscape(nextBookmark)
@@ -414,13 +496,15 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
cenc = "&csrftoken=" + url.QueryEscape(csrftoken) cenc = "&csrftoken=" + url.QueryEscape(csrftoken)
} }
next := "/search?q=" + qenc + "&bookmark=" + benc + cenc next := "/search?q=" + qenc + "&bookmark=" + benc + cenc
io.WriteString(w, `<div class="pagination"><a href="`+html.EscapeString(next)+`">Next page</a></div>`) _, _ = io.WriteString(w, `<div class="pagination"><a href="`+html.EscapeString(next)+`">Next page</a></div>`)
} }
_, _ = io.WriteString(w, `<div class="footer-note">Powered by Pinata • Reverse search via Tineye</div></body></html>`)
io.WriteString(w, `<div class="footer-note">Powered by Pinata • Reverse search via Tineye</div></body></html>`)
} }
// POST /bookmark : server-side saving of a bookmark (no JS). expects form q and optional next. // --------------------------------------------------------------------
// Bookmark endpoints (server-side, no JS)
// --------------------------------------------------------------------
func bookmarkPostHandler(w http.ResponseWriter, r *http.Request) { func bookmarkPostHandler(w http.ResponseWriter, r *http.Request) {
if !bookmarkingEnabled { if !bookmarkingEnabled {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -439,25 +523,54 @@ func bookmarkPostHandler(w http.ResponseWriter, r *http.Request) {
if next == "" { if next == "" {
next = "/" next = "/"
} }
// read existing entries := readBookmarksFromReq(r)
items := readBookmarksFromReq(r) new := []BookmarkEntry{{Type: "q", Value: q}}
// remove if exists then prepend for _, e := range entries {
newItems := make([]string, 0, 32) if e.Type == "q" && e.Value == q {
newItems = append(newItems, q)
for _, v := range items {
if v == q {
continue continue
} }
newItems = append(newItems, v) new = append(new, e)
if len(newItems) >= 30 { if len(new) >= maxBookmarks {
break break
} }
} }
setBookmarksCookie(w, newItems) setBookmarksCookie(w, new)
http.Redirect(w, r, next, http.StatusSeeOther)
}
func bookmarkImagePostHandler(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
}
u := strings.TrimSpace(r.FormValue("url"))
if u == "" || !(strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
next := r.FormValue("next")
if next == "" {
next = "/"
}
entries := readBookmarksFromReq(r)
new := []BookmarkEntry{{Type: "img", Value: u}}
for _, e := range entries {
if e.Type == "img" && e.Value == u {
continue
}
new = append(new, e)
if len(new) >= maxBookmarks {
break
}
}
setBookmarksCookie(w, new)
http.Redirect(w, r, next, http.StatusSeeOther) http.Redirect(w, r, next, http.StatusSeeOther)
} }
// POST /bookmark_remove : remove a bookmark (form q)
func bookmarkRemoveHandler(w http.ResponseWriter, r *http.Request) { func bookmarkRemoveHandler(w http.ResponseWriter, r *http.Request) {
if !bookmarkingEnabled { if !bookmarkingEnabled {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
@@ -467,25 +580,125 @@ func bookmarkRemoveHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
q := strings.TrimSpace(r.FormValue("q")) typ := r.FormValue("type")
items := readBookmarksFromReq(r) val := r.FormValue("value")
if len(items) == 0 { if typ == "" || val == "" {
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
return return
} }
out := make([]string, 0, len(items)) entries := readBookmarksFromReq(r)
for _, v := range items { out := make([]BookmarkEntry, 0, len(entries))
if v == q { for _, e := range entries {
if e.Type == typ && e.Value == val {
continue continue
} }
out = append(out, v) out = append(out, e)
}
if len(out) == 0 {
clearBookmarksCookie(w)
} else {
setBookmarksCookie(w, out)
} }
// set or clear cookie
removeBookmarkCookie(w, out)
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// imageProxy uses pooled buffer for io.CopyBuffer func bookmarksExportHandler(w http.ResponseWriter, r *http.Request) {
if !bookmarkingEnabled {
http.Error(w, "bookmarks disabled", http.StatusNotFound)
return
}
entries := readBookmarksFromReq(r)
if entries == nil {
entries = []BookmarkEntry{}
}
js, err := json.MarshalIndent(entries, "", " ")
if err != nil {
http.Error(w, "failed to export", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", "attachment; filename=\"pinata_bookmarks.json\"")
_, _ = w.Write(js)
}
func bookmarksImportHandler(w http.ResponseWriter, r *http.Request) {
if !bookmarkingEnabled {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 2<<20) // 2MB
if err := r.ParseMultipartForm(2 << 20); err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
defer file.Close()
dec := json.NewDecoder(file)
var entries []BookmarkEntry
if err := dec.Decode(&entries); err == nil {
// ok
} else {
if _, err := file.Seek(0, io.SeekStart); err == nil {
var arr []string
dec2 := json.NewDecoder(file)
if err2 := dec2.Decode(&arr); err2 == nil {
entries = make([]BookmarkEntry, 0, len(arr))
for _, s := range arr {
entries = append(entries, BookmarkEntry{Type: "q", Value: s})
}
} else {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
} else {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
existing := readBookmarksFromReq(r)
merged := make([]BookmarkEntry, 0, maxBookmarks)
seen := map[string]bool{}
add := func(e BookmarkEntry) {
key := e.Type + "|" + e.Value
if seen[key] {
return
}
seen[key] = true
merged = append(merged, e)
}
for _, e := range entries {
e.Value = strings.TrimSpace(e.Value)
if e.Value == "" {
continue
}
if len(e.Value) > maxItemLen {
e.Value = e.Value[:maxItemLen]
}
if e.Type != "q" && e.Type != "img" {
e.Type = "q"
}
add(e)
if len(merged) >= maxBookmarks {
break
}
}
for _, e := range existing {
add(e)
if len(merged) >= maxBookmarks {
break
}
}
setBookmarksCookie(w, merged)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// --------------------------------------------------------------------
// Image proxy and reverse search
// --------------------------------------------------------------------
func imageProxyHandler(w http.ResponseWriter, r *http.Request) { func imageProxyHandler(w http.ResponseWriter, r *http.Request) {
uq := r.URL.Query().Get("url") uq := r.URL.Query().Get("url")
if uq == "" { if uq == "" {
@@ -511,7 +724,6 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "" { if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct) w.Header().Set("Content-Type", ct)
} }
@@ -519,17 +731,15 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", cc) w.Header().Set("Cache-Control", cc)
} }
w.WriteHeader(resp.StatusCode) w.WriteHeader(resp.StatusCode)
bufPtr := copyBufPool.Get().(*[]byte) bufPtr := copyBufPool.Get().(*[]byte)
buf := *bufPtr buf := *bufPtr
_, _ = io.CopyBuffer(w, resp.Body, buf) _, _ = io.CopyBuffer(w, resp.Body, buf)
copyBufPool.Put(bufPtr) copyBufPool.Put(bufPtr)
} }
// revsearch redirect to tineye
func revsearchHandler(w http.ResponseWriter, r *http.Request) { func revsearchHandler(w http.ResponseWriter, r *http.Request) {
if disableReverse { if disableReverse {
http.Error(w, "Reverse image search disabled", http.StatusNotFound) http.Error(w, "reverse disabled", http.StatusNotFound)
return return
} }
b64 := r.URL.Query().Get("b64") b64 := r.URL.Query().Get("b64")
@@ -551,12 +761,9 @@ func revsearchHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, tineye, http.StatusSeeOther) http.Redirect(w, r, tineye, http.StatusSeeOther)
} }
// static CSS // --------------------------------------------------------------------
func styleHandler(w http.ResponseWriter, r *http.Request) { // main: route setup
w.Header().Set("Content-Type", "text/css; charset=utf-8") // --------------------------------------------------------------------
io.WriteString(w, cssContent)
}
func main() { func main() {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/static/style.css", styleHandler) mux.HandleFunc("/static/style.css", styleHandler)
@@ -564,17 +771,22 @@ func main() {
mux.HandleFunc("/search", searchHandler) mux.HandleFunc("/search", searchHandler)
mux.HandleFunc("/image_proxy", imageProxyHandler) mux.HandleFunc("/image_proxy", imageProxyHandler)
mux.HandleFunc("/revsearch", revsearchHandler) mux.HandleFunc("/revsearch", revsearchHandler)
// bookmark endpoints (POST)
mux.HandleFunc("/bookmark", bookmarkPostHandler)
mux.HandleFunc("/bookmark_remove", bookmarkRemoveHandler)
srv := &http.Server{ // bookmark endpoints
mux.HandleFunc("/bookmark", bookmarkPostHandler)
mux.HandleFunc("/bookmark_image", bookmarkImagePostHandler)
mux.HandleFunc("/bookmark_remove", bookmarkRemoveHandler)
mux.HandleFunc("/bookmarks/export", bookmarksExportHandler)
mux.HandleFunc("/bookmarks/import", bookmarksImportHandler)
server := &http.Server{
Addr: ":8080", Addr: ":8080",
Handler: mux, Handler: mux,
ReadTimeout: 12 * time.Second, ReadTimeout: 12 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second, IdleTimeout: 60 * time.Second,
} }
log.Println("Pinata listening on :8080")
log.Fatal(srv.ListenAndServe()) log.Println("Pinata listening on :8080. Bookmarking enabled:", bookmarkingEnabled, " Reverse disabled:", disableReverse)
log.Fatal(server.ListenAndServe())
} }