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
+
+
+
+
+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
+
+
+
+
+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, `
`)
+ _, _ = 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, `