mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020) * fix(db): 1 defect(s) (OC-0096) * fix(admin): 1 defect(s) (OC-0097) * fix(auth): 2 defect(s) (OC-0099, OC-0021) * fix(voice): 1 defect(s) (OC-0018) * fix(admin): 1 defect(s) (OC-0045) * fix(api): 1 defect(s) (OC-0103) * fix(client): 1 defect(s) (OC-0105) * fix(client): 1 defect(s) (OC-0107) * fix(api): 1 defect(s) (OC-0109) * fix(api): 1 defect(s) (OC-0112) * test(admin): compare restore bytes with bytes.Equal Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(voice): 2 defect(s) (OC-0095, OC-0014) OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext. OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token. * fix(profile): 2 defect(s) (OC-0100, OC-0102) * fix(service): 1 defect(s) (OC-0022) Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate. * fix(api): 1 defect(s) (OC-0048) * chore(workflows): correct stale model labels in bughunt-fix phase details Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(client): 1 defect(s) (OC-0015) * fix(voice): 1 defect(s) (OC-0002) * test: fix two CI-only failures in the batch-4 test suite The delete-account broadcast test now observes member_ban on a second client's socket: the hub broadcasts and then force-disconnects the target, so on a slow runner the close could beat the target's own copy of the frame. The observer is also the party the event exists for. The voice e2e mock now echoes the real joined channel id on voice_leave (it hardcoded channel_id 0, which the dispatcher's channel-matched self-leave teardown correctly ignores), and the rejoin test waits for the mock's delayed echoes to settle before clicking the row again — clicking inside the echo window toggled a leave instead of a join. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
235 lines
7.8 KiB
Go
235 lines
7.8 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. It
|
|
// matches both the literal key and its percent-encoded query-string form
|
|
// (url.Error embeds the encoded request URL, and params.Encode() escapes any
|
|
// character outside [A-Za-z0-9-_.~] — common in base64-style keys) so an
|
|
// encoded form is caught too.
|
|
func redactKey(s, apiKey string) string {
|
|
if apiKey == "" {
|
|
return s
|
|
}
|
|
s = strings.ReplaceAll(s, apiKey, "[REDACTED]")
|
|
return strings.ReplaceAll(s, url.QueryEscape(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
|
|
}
|