Files
OwnCord/Server/api/gif_handler.go
T
J3vbandClaude Fable 5 01f7795557 feat(api): proxy Klipy GIF requests server-side so no key ships to clients
The client held the Klipy key in VITE_KLIPY_API_KEY, which Vite inlines into
the shipped bundle by design — a build variable can never hold a secret. Move
the integration behind the server:

- New authenticated GET /api/v1/gif/search and /api/v1/gif/trending. The key
  comes from the new `gif.api_key` config section (koanf,
  OWNCORD_GIF_API_KEY) and never leaves the server.
- Default-off: with no key, both endpoints return 503 GIF_DISABLED so clients
  can hide the picker instead of showing a broken one. Auth is checked first,
  so anonymous callers cannot probe whether a key is configured.
- Outbound call reuses the existing SSRF-guarded dialer (exported as
  plugin.GuardedDialContext) rather than a bare http.Get: resolve once,
  reject private/loopback/link-local/CGN, dial only vetted IPs. Redirects are
  not followed and the response body is size-capped.
- Only id/title/media_formats.{tinygif,gif}.url are forwarded — decoding into
  the narrow struct is the allowlist, so an upstream that echoed the key
  could not leak it. Upstream errors become a generic 502 and the key is
  redacted from anything that reaches the logs.
- Dedicated `gif:` rate-limit bucket (30/min per IP) so debounced search
  traffic cannot exhaust the shared bucket used by password/TOTP endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 13:29:46 +02:00

230 lines
7.5 KiB
Go

// gif_handler.go — server-side proxy for the Klipy GIF API.
//
// The Klipy API key lives in server config and never leaves the server: the
// client asks its own server for GIFs and the server does the upstream call.
// This closes the "secret in the client bundle" hole — a VITE_ variable is
// inlined into the shipped bundle by design and can never hold a credential.
//
// Default-off contract: with no gif.api_key configured both endpoints answer
// 503 with error code GIF_DISABLED, which the client uses to hide/disable the
// GIF picker instead of showing a broken one.
package api
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/auth"
"github.com/owncord/server/config"
"github.com/owncord/server/db"
"github.com/owncord/server/plugin"
)
// gifAPIBase is the upstream Klipy API root. It is a var only so tests can
// point it at a local stub; production never reassigns it.
var gifAPIBase = "https://api.klipy.com/v2"
const (
// gifDefaultLimit / gifMaxLimit bound the number of results requested.
gifDefaultLimit = 20
gifMaxLimit = 50
// gifMaxQueryLen caps the search term length before it is forwarded.
gifMaxQueryLen = 100
// gifUpstreamTimeout is the total budget for one upstream call.
gifUpstreamTimeout = 10 * time.Second
// gifMaxResponseBytes caps the upstream body we are willing to read so a
// hostile or oversized response cannot exhaust server memory.
gifMaxResponseBytes = 2 << 20 // 2 MiB
)
// gifClient performs the upstream call. It reuses the same SSRF-guarded dialer
// as the plugin host_http capability (resolve once, reject private/loopback/
// link-local/CGN addresses, dial only vetted IPs) rather than a bare
// http.Get, and refuses to follow redirects — the upstream host is fixed.
var gifClient = &http.Client{
Timeout: gifUpstreamTimeout,
Transport: &http.Transport{DialContext: plugin.GuardedDialContext()},
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
},
}
// gifMediaFormat is a single renderable variant of a GIF.
type gifMediaFormat struct {
URL string `json:"url"`
}
// gifResult is one GIF. Decoding the upstream body into this struct and
// re-encoding it IS the field allowlist: anything Klipy returns that is not
// declared here (including any echo of our API key) is dropped on the floor
// and never reaches the client.
type gifResult struct {
ID string `json:"id"`
Title string `json:"title"`
MediaFormats struct {
TinyGif *gifMediaFormat `json:"tinygif,omitempty"`
Gif *gifMediaFormat `json:"gif,omitempty"`
} `json:"media_formats"`
}
// gifResponse is the JSON envelope returned by both GIF endpoints.
type gifResponse struct {
Results []gifResult `json:"results"`
}
// MountGIFRoutes registers the authenticated GIF proxy endpoints.
//
// Both routes require a session (same as sibling content endpoints) and share
// a dedicated per-IP rate-limit bucket — the picker searches on every debounced
// keystroke, so it must not share the empty-prefix bucket used by password and
// TOTP endpoints.
func MountGIFRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, cfg *config.Config) {
r.Route("/api/v1/gif", func(r chi.Router) {
r.Use(AuthMiddleware(database))
r.Use(rateLimitMiddlewareWithPrefix(limiter, "gif:", gifRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies))
r.Get("/search", handleGIFProxy(cfg.GIF.APIKey, "/search", true))
r.Get("/trending", handleGIFProxy(cfg.GIF.APIKey, "/featured", false))
})
}
// handleGIFProxy returns a handler that forwards a GIF request upstream with
// the server-held API key. requireQuery marks the endpoints that take a `q`
// search term (search) versus those that do not (trending).
func handleGIFProxy(apiKey, upstreamPath string, requireQuery bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if apiKey == "" {
writeJSON(w, http.StatusServiceUnavailable, errorResponse{
Error: "GIF_DISABLED",
Message: "GIF search is not configured on this server",
})
return
}
params := url.Values{
"key": {apiKey},
"media_filter": {"gif,tinygif"},
}
limit, ok := parseGIFLimit(r.URL.Query().Get("limit"))
if !ok {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "limit must be an integer between 1 and " + strconv.Itoa(gifMaxLimit),
})
return
}
params.Set("limit", strconv.Itoa(limit))
if requireQuery {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "q is required",
})
return
}
if len(q) > gifMaxQueryLen {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "q must be at most " + strconv.Itoa(gifMaxQueryLen) + " characters",
})
return
}
params.Set("q", q)
}
results, err := fetchGIFs(r, gifAPIBase+upstreamPath+"?"+params.Encode(), apiKey, limit)
if err != nil {
writeJSON(w, http.StatusBadGateway, errorResponse{
Error: "BAD_GATEWAY",
Message: "GIF provider is unavailable",
})
return
}
writeJSON(w, http.StatusOK, gifResponse{Results: results})
}
}
// fetchGIFs performs the upstream request and returns the allowlisted results.
// It never returns the upstream error to the caller and never logs the request
// URL, because that URL carries the API key.
func fetchGIFs(r *http.Request, upstreamURL, apiKey string, limit int) ([]gifResult, error) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
slog.Warn("gif proxy: building upstream request failed", "error", redactKey(err.Error(), apiKey))
return nil, err
}
resp, err := gifClient.Do(req)
if err != nil {
// url.Error embeds the request URL, which contains the API key.
slog.Warn("gif proxy: upstream request failed", "error", redactKey(err.Error(), apiKey))
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
slog.Warn("gif proxy: upstream returned non-200", "status", resp.StatusCode)
return nil, errGIFUpstream
}
var upstream gifResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, gifMaxResponseBytes)).Decode(&upstream); err != nil {
slog.Warn("gif proxy: decoding upstream response failed", "error", redactKey(err.Error(), apiKey))
return nil, err
}
// Drop entries missing either renderable format and honour our own limit
// even if upstream ignored it. Non-nil so the JSON is [] and never null.
results := make([]gifResult, 0, len(upstream.Results))
for _, g := range upstream.Results {
if g.MediaFormats.TinyGif == nil || g.MediaFormats.Gif == nil {
continue
}
if len(results) >= limit {
break
}
results = append(results, g)
}
return results, nil
}
// errGIFUpstream marks a non-200 upstream response.
var errGIFUpstream = errors.New("gif proxy: upstream error")
// redactKey removes the API key from a string destined for the logs.
func redactKey(s, apiKey string) string {
if apiKey == "" {
return s
}
return strings.ReplaceAll(s, apiKey, "[REDACTED]")
}
// parseGIFLimit parses and validates the `limit` query param. An empty value
// yields the default; anything non-numeric or out of range is rejected.
func parseGIFLimit(raw string) (int, bool) {
if raw == "" {
return gifDefaultLimit, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > gifMaxLimit {
return 0, false
}
return n, true
}