diff --git a/main.go b/main.go
index 6c76658..a50c046 100644
--- a/main.go
+++ b/main.go
@@ -1,6 +1,7 @@
package main
import (
+ "bytes"
"context"
"crypto/aes"
"crypto/cipher"
@@ -9,8 +10,14 @@ import (
"encoding/json"
"fmt"
"html"
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "image/jpeg"
"io"
"log"
+ "math"
"net"
"net/http"
"net/url"
@@ -431,22 +438,31 @@ func settingsPostHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, next, http.StatusSeeOther)
}
-func renderCardHTML(q, next, u string) string {
- esc := url.QueryEscape(u)
- b64 := base64.StdEncoding.EncodeToString([]byte(u))
+func renderCardHTML(q, next, u string, thumbMobile, thumbDesktop, thumbHigh int) string {
+ full := "/image_proxy?url=" + url.QueryEscape(u)
+ tm := thumbURL(u, thumbMobile)
+ td := thumbURL(u, thumbDesktop)
+ th := thumbURL(u, thumbHigh)
+
+ srcset := fmt.Sprintf("%s %dw, %s %dw, %s %dw", tm, thumbMobile, td, thumbDesktop, th, thumbHigh)
+ sizes := fmt.Sprintf("(max-width:640px) %dpx, %dpx", thumbMobile, thumbDesktop)
var b strings.Builder
- b.Grow(len(u)*2 + 512)
+ b.Grow(len(u)*2 + 768)
b.WriteString(`
`)
- b.WriteString(`

- b.WriteString(esc)
+ b.WriteString(`<a href=)

+ b.WriteString(html.EscapeString(td))
+ b.WriteString(`)
`)
b.WriteString(`
`)
if !disableReverse {
b.WriteString(`
🔍`)
}
if bookmarkingEnabled {
@@ -461,13 +477,13 @@ func renderCardHTML(q, next, u string) string {
return b.String()
}
-func writeChunkedCards(w http.ResponseWriter, q, next string, urls []string) {
+func writeChunkedCards(w http.ResponseWriter, q, next string, urls []string, thumbMobile, thumbDesktop, thumbHigh int) {
if len(urls) == 0 {
return
}
if !chunkedMode || len(urls) == 1 {
for _, u := range urls {
- _, _ = io.WriteString(w, renderCardHTML(q, next, u))
+ _, _ = io.WriteString(w, renderCardHTML(q, next, u, thumbMobile, thumbDesktop, thumbHigh))
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
@@ -497,7 +513,7 @@ func writeChunkedCards(w http.ResponseWriter, q, next string, urls []string) {
go func() {
defer wg.Done()
for j := range jobs {
- results <- result{idx: j.idx, html: renderCardHTML(q, next, j.u)}
+ results <- result{idx: j.idx, html: renderCardHTML(q, next, j.u, thumbMobile, thumbDesktop, thumbHigh)}
}
}()
}
@@ -631,6 +647,7 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
}
accent, imgScale := getThemeVars(r)
+ thumbMobile, thumbDesktop, thumbHigh := thumbWidths(imgScale)
accentRgba := hexToRGBA(accent, 0.12)
inlineStyle := fmt.Sprintf(``, html.EscapeString(accent), html.EscapeString(accentRgba), html.EscapeString(imgScale))
@@ -695,11 +712,11 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
if chunkedMode {
chunk = append(chunk, u)
if len(chunk) >= chunkSize {
- writeChunkedCards(w, q, nextSearch, chunk)
+ writeChunkedCards(w, q, nextSearch, chunk, thumbMobile, thumbDesktop, thumbHigh)
chunk = chunk[:0]
}
} else {
- _, _ = io.WriteString(w, renderCardHTML(q, nextSearch, u))
+ _, _ = io.WriteString(w, renderCardHTML(q, nextSearch, u, thumbMobile, thumbDesktop, thumbHigh))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
@@ -719,7 +736,7 @@ func searchHandler(w http.ResponseWriter, r *http.Request) {
}
if chunkedMode && len(chunk) > 0 {
- writeChunkedCards(w, q, nextSearch, chunk)
+ writeChunkedCards(w, q, nextSearch, chunk, thumbMobile, thumbDesktop, thumbHigh)
}
_, _ = io.WriteString(w, `
`)
@@ -791,6 +808,160 @@ func imageProxyHandler(w http.ResponseWriter, r *http.Request) {
copyBufPool.Put(bufPtr)
}
+func thumbWidths(scaleStr string) (int, int, int) {
+ scale := 1.0
+ if v, err := strconv.ParseFloat(scaleStr, 64); err == nil && v > 0 {
+ scale = v
+ }
+ mobile := int(math.Round(180 * scale))
+ desktop := int(math.Round(260 * scale))
+ high := int(math.Round(520 * scale))
+ if mobile < 120 {
+ mobile = 120
+ }
+ if desktop < mobile {
+ desktop = mobile
+ }
+ if high < desktop {
+ high = desktop
+ }
+ return mobile, desktop, high
+}
+
+func thumbURL(u string, w int) string {
+ return "/thumb_proxy?url=" + url.QueryEscape(u) + "&w=" + strconv.Itoa(w)
+}
+
+func resizeNearest(src image.Image, dstW int) image.Image {
+ b := src.Bounds()
+ sw := b.Dx()
+ sh := b.Dy()
+ if dstW <= 0 || sw <= 0 || sh <= 0 || dstW >= sw {
+ return src
+ }
+ dstH := int(math.Round(float64(sh) * float64(dstW) / float64(sw)))
+ if dstH < 1 {
+ dstH = 1
+ }
+ dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
+ for y := 0; y < dstH; y++ {
+ sy := b.Min.Y + int(float64(y)*float64(sh)/float64(dstH))
+ if sy >= b.Max.Y {
+ sy = b.Max.Y - 1
+ }
+ for x := 0; x < dstW; x++ {
+ sx := b.Min.X + int(float64(x)*float64(sw)/float64(dstW))
+ if sx >= b.Max.X {
+ sx = b.Max.X - 1
+ }
+ dst.Set(x, y, src.At(sx, sy))
+ }
+ }
+ return dst
+}
+
+func thumbImageProxyHandler(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 {
+ http.Error(w, "invalid url", http.StatusBadRequest)
+ return
+ }
+ parsed, err := url.Parse(orig)
+ if err != nil {
+ http.Error(w, "invalid url", http.StatusBadRequest)
+ return
+ }
+ if parsed.Scheme != "https" {
+ http.Error(w, "proxy allowed for https only", http.StatusForbidden)
+ return
+ }
+ if !strings.EqualFold(parsed.Hostname(), "i.pinimg.com") {
+ http.Error(w, "proxy allowed only for i.pinimg.com", http.StatusForbidden)
+ return
+ }
+
+ targetW, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("w")))
+ if err != nil || targetW < 1 {
+ targetW = 260
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+
+ req, err := http.NewRequestWithContext(ctx, "GET", parsed.String(), nil)
+ if err != nil {
+ http.Error(w, "failed", http.StatusBadGateway)
+ return
+ }
+ req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:145.0) Gecko/20100101 Firefox/145.0")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ http.Error(w, "failed to fetch", http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ http.Error(w, "failed to read", http.StatusBadGateway)
+ return
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ 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)
+ _, _ = w.Write(data)
+ return
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(data))
+ if err != nil {
+ if ct := resp.Header.Get("Content-Type"); ct != "" {
+ w.Header().Set("Content-Type", ct)
+ } else {
+ w.Header().Set("Content-Type", http.DetectContentType(data))
+ }
+ if cc := resp.Header.Get("Cache-Control"); cc != "" {
+ w.Header().Set("Cache-Control", cc)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(data)
+ return
+ }
+
+ if targetW >= img.Bounds().Dx() {
+ if ct := resp.Header.Get("Content-Type"); ct != "" {
+ w.Header().Set("Content-Type", ct)
+ } else {
+ w.Header().Set("Content-Type", http.DetectContentType(data))
+ }
+ if cc := resp.Header.Get("Cache-Control"); cc != "" {
+ w.Header().Set("Cache-Control", cc)
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(data)
+ return
+ }
+
+ w.Header().Set("Content-Type", "image/jpeg")
+ if cc := resp.Header.Get("Cache-Control"); cc != "" {
+ w.Header().Set("Cache-Control", cc)
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = jpeg.Encode(w, resizeNearest(img, targetW), &jpeg.Options{Quality: 82})
+}
+
func revsearchHandler(w http.ResponseWriter, r *http.Request) {
if disableReverse {
http.Error(w, "reverse disabled", http.StatusNotFound)
@@ -1017,6 +1188,7 @@ func main() {
mux.HandleFunc("/search", searchHandler)
mux.HandleFunc("/image_proxy", imageProxyHandler)
mux.HandleFunc("/revsearch", revsearchHandler)
+ mux.HandleFunc("/thumb_proxy", thumbImageProxyHandler)
// bookmark endpoints
mux.HandleFunc("/bookmark", bookmarkPostHandler)