From f30262134ffacc497a466f8b5dda68bbc0fdad60 Mon Sep 17 00:00:00 2001 From: gigirassy Date: Wed, 15 Oct 2025 15:52:36 -0400 Subject: [PATCH] first commit --- Dockerfile | 36 ++++++ compose.yml | 7 + go.mod | 3 + main.go | 359 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 405 insertions(+) create mode 100644 Dockerfile create mode 100644 compose.yml create mode 100644 go.mod create mode 100644 main.go diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..199bc7e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# syntax=docker/dockerfile:1.4 +# +# Multi-arch build (use BuildKit/docker buildx). Produces a tiny scratch image. +# +FROM --platform=$BUILDPLATFORM golang:1.24.7-alpine AS builder +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT + +WORKDIR /src + +# Cache deps +COPY go.mod ./ +RUN apk add --no-cache ca-certificates git && go mod download + +# Copy sources and build a tiny static binary for the target platform +COPY . . +# Build static binary; CGO disabled so we can use scratch +RUN CGO_ENABLED=0 \ + GOOS=${TARGETOS:-linux} \ + GOARCH=${TARGETARCH:-amd64} \ + go build -trimpath -ldflags="-s -w" -o /pinata ./main.go + +# Final stage: minimal runtime +FROM scratch AS runtime +# copy binary +COPY --from=builder /pinata /pinata +# copy CA bundle so TLS works +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +# run as a non-root numeric UID (no passwd required in scratch) +USER 65532 + +EXPOSE 8080 + +ENTRYPOINT ["/pinata"] diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..2d772ff --- /dev/null +++ b/compose.yml @@ -0,0 +1,7 @@ +version: "3.8" +services: + pinata: + build: . + ports: + - "127.0.0.1:8080:8080" + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c956508 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module codeberg.org/gigirassy/pinata + +go 1.24.7 diff --git a/main.go b/main.go new file mode 100644 index 0000000..9059781 --- /dev/null +++ b/main.go @@ -0,0 +1,359 @@ +// main.go +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "html" + "io" + "log" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +// ------------------- Config & globals ------------------- +var httpClient = &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 8 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 10, // small pool to reduce memory + MaxIdleConnsPerHost: 6, + IdleConnTimeout: 60 * time.Second, + TLSHandshakeTimeout: 8 * time.Second, + }, +} + +const pinterestSearchURL = "https://www.pinterest.com/resource/BaseSearchResource/get/" + +// ------------------- Static CSS & HTML header/footer ------------------- +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} +.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)} +.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) + `"

+
` +} + +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)) +} + +func indexHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +// 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. +func searchHandler(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + if len(q) < 1 || len(q) > 64 { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + 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} + } + jb, err := json.Marshal(dataObj) + if err != nil { + http.Error(w, "internal", http.StatusInternalServerError) + return + } + dataParam := url.QueryEscape(string(jb)) + + var req *http.Request + if bookmark == "" { + u := pinterestSearchURL + "?data=" + dataParam + req, err = http.NewRequestWithContext(r.Context(), "GET", u, nil) + } else { + body := "data=" + dataParam + req, err = http.NewRequestWithContext(r.Context(), "POST", pinterestSearchURL, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + if err != nil { + http.Error(w, "failed to build request", http.StatusInternalServerError) + return + } + req.Header.Set("x-pinterest-pws-handler", "www/search/[scope].js") + if csrftoken != "" { + req.Header.Set("x-csrftoken", csrftoken) + req.Header.Set("Cookie", "csrftoken="+csrftoken) + } + + resp, err := httpClient.Do(req) + if err != nil { + http.Error(w, "failed to fetch", http.StatusBadGateway) + return + } + 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") { + newCsrf = c.Value + break + } + } + + // Start streaming HTML + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(resultsHeader(q))) + + 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 { + URL string `json:"url"` + } `json:"orig"` + } `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 + } + u := strings.TrimSpace(rObj.Images.Orig.URL) + 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) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + } + // consume closing ']' token + _, _ = dec.Token() + case "bookmark": + // next token should be the bookmark string (or null) + t, err := dec.Token() + if err == nil { + if s, ok := t.(string); ok { + nextBookmark = s + } + } + default: + // ignore other keys + continue + } + } + + // Close the img-container + _, _ = io.WriteString(w, resultsFooterPrefix) + + // Render pagination if we have a bookmark + if nextBookmark != "" { + // Build next link with csrftoken if present + qenc := url.QueryEscape(q) + benc := url.QueryEscape(nextBookmark) + cenc := "" + if newCsrf != "" { + cenc = "&csrftoken=" + url.QueryEscape(newCsrf) + } else if csrftoken != "" { + cenc = "&csrftoken=" + url.QueryEscape(csrftoken) + } + next := "/search?q=" + qenc + "&bookmark=" + benc + cenc + _, _ = io.WriteString(w, ``) + } + + // Footer + _, _ = io.WriteString(w, ``) + _, _ = io.WriteString(w, ``) + + // done +} + +// imageProxyHandler streams remote image bodies directly to the client (no buffering) +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 || !(strings.HasPrefix(orig, "http://") || strings.HasPrefix(orig, "https://")) { + http.Error(w, "invalid url", http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, "GET", orig, nil) + if err != nil { + 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") + resp, err := httpClient.Do(req) + if err != nil { + http.Error(w, "failed to fetch", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + // copy only needed headers + 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) + _, _ = io.Copy(w, resp.Body) +} + +// revsearchHandler decodes base64 and redirects to Tineye +func revsearchHandler(w http.ResponseWriter, r *http.Request) { + 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) +} + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/static/style.css", styleHandler) + mux.HandleFunc("/", indexHandler) + mux.HandleFunc("/search", searchHandler) + mux.HandleFunc("/image_proxy", imageProxyHandler) + mux.HandleFunc("/revsearch", revsearchHandler) + + srv := &http.Server{ + Addr: ":8080", + Handler: mux, + ReadTimeout: 12 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Println("Pinata listening on :8080 (streaming mode, low allocations)") + log.Fatal(srv.ListenAndServe()) +}