Files
libnovel/scraper/internal/server/handlers_audio.go
Admin 52f876d8e8
Some checks failed
CI / Scraper / Lint (push) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 27s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 47s
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
Release / UI / Docker (push) Successful in 5m1s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (push) Failing after 9m10s
iOS CI / Build (pull_request) Failing after 4m3s
iOS CI / Test (pull_request) Has been skipped
feat: avatar upload via presigned PUT URL flow
- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
  Store interface, implement on HybridStore+MinioClient, register
  GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
  the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
  chip components into shared ChipButton in CommonViews.swift
2026-03-10 17:05:43 +05:00

713 lines
23 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// ─── Audio generation via Kokoro /v1/audio/speech ────────────────────────────
//
// handleAudioGenerate handles POST /api/audio/{slug}/{n}.
//
// The handler is non-blocking: it creates an audio_jobs record in PocketBase
// with status="pending", then fires a background goroutine to call Kokoro.
// The caller should poll GET /api/audio/status/{slug}/{n} to track progress.
//
// If audio is already cached (audio_cache hit) the handler returns
// status=200 with the proxy URL immediately — no job is created.
//
// Concurrent requests for the same key are deduplicated via audioJobIDs:
// the second caller gets a 202 with the existing job_id immediately.
func (s *Server) handleAudioGenerate(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.Error(w, `{"error":"invalid chapter"}`, http.StatusBadRequest)
return
}
// Parse optional voice from JSON body.
voice := s.kokoroVoice
var body struct {
Voice string `json:"voice"`
MaxChars int `json:"max_chars"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
if body.Voice != "" {
voice = body.Voice
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
// Fast path: already generated (check persistent store first).
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
s.writeAudioResponse(w, slug, n, voice, filename)
return
}
// Deduplicate concurrent generation for the same key.
// If a goroutine is already running for this key, return the existing job_id.
s.audioMu.Lock()
if jobID, ok := s.audioJobIDs[cacheKey]; ok {
s.audioMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "generating"})
return
}
// Create the PocketBase job record.
jobID, createErr := s.store.CreateAudioJob(r.Context(), slug, n, voice)
if createErr != nil {
s.audioMu.Unlock()
s.log.Warn("audio: failed to create job record", "slug", slug, "chapter", n, "err", createErr)
// Non-fatal: still proceed, just won't have a persistent job record.
jobID = ""
}
s.audioJobIDs[cacheKey] = jobID
s.audioMu.Unlock()
// Fire background goroutine — request context must NOT be used here since
// the handler returns immediately.
maxChars := body.MaxChars
go func() {
defer func() {
s.audioMu.Lock()
delete(s.audioJobIDs, cacheKey)
s.audioMu.Unlock()
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
s.runAudioGeneration(bgCtx, jobID, slug, n, voice, maxChars, cacheKey)
}()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(map[string]string{"job_id": jobID, "status": "pending"})
}
// runAudioGeneration performs the actual Kokoro TTS work in a goroutine.
// It updates the audio_jobs record as it progresses and writes to audio_cache
// and MinIO on success.
func (s *Server) runAudioGeneration(ctx context.Context, jobID, slug string, n int, voice string, maxChars int, cacheKey string) {
markFailed := func(msg string) {
if jobID == "" {
return
}
if err := s.store.UpdateAudioJob(ctx, jobID, "failed", msg, time.Now()); err != nil {
s.log.Warn("audio: failed to update job to failed", "job_id", jobID, "err", err)
}
}
// Transition to "generating".
if jobID != "" {
if err := s.store.UpdateAudioJob(ctx, jobID, "generating", "", time.Time{}); err != nil {
s.log.Warn("audio: failed to mark job generating", "job_id", jobID, "err", err)
}
}
// Load and validate chapter text.
raw, err := s.store.ReadChapter(ctx, slug, n)
if err != nil {
s.log.Error("audio: chapter not found", "slug", slug, "chapter", n, "err", err)
markFailed("chapter not found")
return
}
text := stripMarkdown(raw)
if text == "" {
markFailed("chapter text is empty")
return
}
if maxChars > 0 && len([]rune(text)) > maxChars {
text = string([]rune(text)[:maxChars])
}
if s.kokoroURL == "" {
markFailed("kokoro not configured")
return
}
// Call Kokoro.
filename, err := s.generateSpeech(ctx, text, voice, 1.0)
if err != nil {
s.log.Error("audio: kokoro speech generation failed", "slug", slug, "chapter", n, "err", err)
markFailed(err.Error())
return
}
if err := s.store.SetAudioCache(ctx, cacheKey, filename); err != nil {
s.log.Warn("audio: cache write failed", "slug", slug, "chapter", n, "err", err)
}
// Download from Kokoro and persist to MinIO.
minioKey := s.store.AudioObjectKey(slug, n, voice)
audioData, dlErr := s.downloadFromKokoro(ctx, filename)
if dlErr != nil {
s.log.Warn("audio: MinIO upload skipped: kokoro download failed",
"slug", slug, "chapter", n, "filename", filename, "err", dlErr)
} else if putErr := s.store.PutAudio(ctx, minioKey, audioData); putErr != nil {
s.log.Warn("audio: MinIO upload failed",
"slug", slug, "chapter", n, "key", minioKey, "err", putErr)
} else {
s.log.Info("audio: uploaded to MinIO", "slug", slug, "chapter", n, "key", minioKey)
}
// Mark job done.
if jobID != "" {
if err := s.store.UpdateAudioJob(ctx, jobID, "done", "", time.Now()); err != nil {
s.log.Warn("audio: failed to mark job done", "job_id", jobID, "err", err)
}
}
s.log.Info("audio: generation complete", "slug", slug, "chapter", n, "filename", filename)
}
// handleAudioStatus handles GET /api/audio/status/{slug}/{n}.
// Returns the current generation status for the given chapter + voice.
//
// Query params: voice (optional, defaults to server default).
//
// Possible responses:
// - 200 {"status":"done","url":"/api/audio-proxy/..."} — audio ready
// - 200 {"status":"pending"|"generating","job_id":"..."} — in progress
// - 200 {"status":"idle"} — no job yet
// - 200 {"status":"failed","error":"..."} — last job failed
func (s *Server) handleAudioStatus(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
w.Header().Set("Content-Type", "application/json")
// Fast path: audio already in audio_cache → done.
if filename, ok := s.store.GetAudioCache(r.Context(), cacheKey); ok {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "done",
"url": proxyURL,
"filename": filename,
})
return
}
// Check in-flight map for live job ID.
s.audioMu.Lock()
liveJobID, inFlight := s.audioJobIDs[cacheKey]
s.audioMu.Unlock()
if inFlight {
// Look up persistent record for richer status.
if job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey); ok {
_ = json.NewEncoder(w).Encode(map[string]string{
"status": job.Status,
"job_id": liveJobID,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]string{
"status": "generating",
"job_id": liveJobID,
})
return
}
// Not in-flight: check persistent record for last known result.
job, ok, _ := s.store.GetAudioJob(r.Context(), cacheKey)
if !ok {
_ = json.NewEncoder(w).Encode(map[string]string{"status": "idle"})
return
}
resp := map[string]string{
"status": job.Status,
"job_id": job.ID,
}
if job.Status == "failed" && job.ErrorMessage != "" {
resp["error"] = job.ErrorMessage
}
_ = json.NewEncoder(w).Encode(resp)
}
// generateSpeech calls POST /v1/audio/speech on Kokoro with return_download_link=true
// and returns the filename from the X-Download-Path response header.
func (s *Server) generateSpeech(ctx context.Context, text, voice string, speed float64) (string, error) {
reqBody, _ := json.Marshal(map[string]interface{}{
"model": "kokoro",
"input": text,
"voice": voice,
"response_format": "mp3",
"speed": speed,
"stream": false,
"return_download_link": true,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
s.kokoroURL+"/v1/audio/speech", bytes.NewReader(reqBody))
if err != nil {
return "", fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("kokoro request: %w", err)
}
defer resp.Body.Close()
// Drain body so the connection can be reused.
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("kokoro status %d", resp.StatusCode)
}
// X-Download-Path is e.g. "/download/speech_abc123.mp3"
dlPath := resp.Header.Get("X-Download-Path")
if dlPath == "" {
return "", fmt.Errorf("kokoro did not return X-Download-Path header")
}
// Extract just the filename from the path.
filename := dlPath
if idx := strings.LastIndex(dlPath, "/"); idx >= 0 {
filename = dlPath[idx+1:]
}
if filename == "" {
return "", fmt.Errorf("empty filename in X-Download-Path: %q", dlPath)
}
return filename, nil
}
// downloadFromKokoro downloads a generated audio file from Kokoro's temp storage
// using GET /v1/download/{filename} and returns the raw bytes.
func (s *Server) downloadFromKokoro(ctx context.Context, filename string) ([]byte, error) {
url := s.kokoroURL + "/v1/download/" + filename
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build download request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("kokoro download request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("kokoro download status %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read kokoro download body: %w", err)
}
return data, nil
}
// writeAudioResponse writes the JSON response for an already-cached audio chapter.
// The URL points to our proxy handler GET /api/audio-proxy/{slug}/{n}.
func (s *Server) writeAudioResponse(w http.ResponseWriter, slug string, n int, voice string, filename string) {
proxyURL := fmt.Sprintf("/api/audio-proxy/%s/%d?voice=%s", slug, n, voice)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"url": proxyURL,
"filename": filename,
})
}
// handleAudioProxy handles GET /api/audio-proxy/{slug}/{n}.
// It looks up the Kokoro download filename for this chapter (voice) and
// proxies GET /v1/download/{filename} from the Kokoro server back to the browser.
func (s *Server) handleAudioProxy(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 {
http.NotFound(w, r)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
cacheKey := fmt.Sprintf("%s/%d/%s", slug, n, voice)
filename, ok := s.store.GetAudioCache(r.Context(), cacheKey)
if !ok {
http.Error(w, "audio not generated yet", http.StatusNotFound)
return
}
kokoroURL := s.kokoroURL + "/v1/download/" + filename
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, kokoroURL, nil)
if err != nil {
http.Error(w, "failed to build proxy request", http.StatusInternalServerError)
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
http.Error(w, "kokoro download failed", http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
http.Error(w, fmt.Sprintf("kokoro returned %d", resp.StatusCode), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "audio/mpeg")
w.Header().Set("Cache-Control", "public, max-age=3600")
if cl := resp.Header.Get("Content-Length"); cl != "" {
w.Header().Set("Content-Length", cl)
}
_, _ = io.Copy(w, resp.Body)
}
// ─── Presigned URL handlers ───────────────────────────────────────────────────
// handlePresignChapter handles GET /api/presign/chapter/{slug}/{n}.
// Returns a short-lived presigned MinIO URL for the chapter markdown object.
// The SvelteKit server uses this to fetch chapter content server-side.
func (s *Server) handlePresignChapter(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
url, err := s.store.PresignChapter(r.Context(), slug, n, 15*time.Minute)
if err != nil {
s.log.Error("presign chapter failed", "slug", slug, "n", n, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}
// handlePresignAudio handles GET /api/presign/audio/{slug}/{n}.
// Returns a presigned MinIO URL for the audio object (if it has been generated).
// Query params: voice (optional, defaults to server default).
func (s *Server) handlePresignAudio(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
n, err := strconv.Atoi(r.PathValue("n"))
if err != nil || n < 1 || slug == "" {
http.Error(w, `{"error":"invalid params"}`, http.StatusBadRequest)
return
}
voice := r.URL.Query().Get("voice")
if voice == "" {
voice = s.kokoroVoice
}
key := s.store.AudioObjectKey(slug, n, voice)
// Return 404 when the object hasn't been uploaded yet — the client treats
// this as "audio not ready" and will either poll or trigger generation.
if !s.store.AudioExists(r.Context(), key) {
http.NotFound(w, r)
return
}
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
if err != nil {
s.log.Error("presign audio failed", "slug", slug, "n", n, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}
// ─── Voices API ───────────────────────────────────────────────────────────────
// handleVoices handles GET /api/voices.
// Returns the list of available Kokoro voices as JSON: {"voices": [...]}
func (s *Server) handleVoices(w http.ResponseWriter, _ *http.Request) {
voices := s.voices()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{"voices": voices})
}
// ─── Voice sample generation ──────────────────────────────────────────────────
// voiceSampleText is the short passage used for voice sample previews.
const voiceSampleText = "The ancient library held secrets older than memory itself, its dust-laden shelves stretching upward into shadow. She reached for the worn leather spine, fingers trembling with anticipation."
// voiceSampleKey returns the MinIO object key for a voice sample.
// Key: _voice-samples/{voice}.mp3
func voiceSampleKey(voice string) string {
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, voice)
return fmt.Sprintf("_voice-samples/%s.mp3", safe)
}
// warmVoiceSamples runs at startup in a background goroutine.
// It generates a short audio sample for every available Kokoro voice that
// doesn't already have one in MinIO, so the UI voice selector has playable
// previews without requiring a manual trigger.
// It respects ctx cancellation and waits up to 30 s for Kokoro to become
// reachable before giving up.
func (s *Server) warmVoiceSamples(ctx context.Context) {
if s.kokoroURL == "" {
return
}
// Wait for Kokoro to be reachable (it may still be starting up).
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, s.kokoroURL+"/v1/audio/voices", nil)
resp, err := http.DefaultClient.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
break
}
}
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
}
voices := s.voices()
s.log.Info("warming voice samples", "voices", len(voices))
generated, skipped, failed := 0, 0, 0
for _, voice := range voices {
if ctx.Err() != nil {
return
}
key := voiceSampleKey(voice)
if s.store.AudioExists(ctx, key) {
skipped++
continue
}
filename, err := s.generateSpeech(ctx, voiceSampleText, voice, 1.0)
if err != nil {
s.log.Warn("voice sample warmup: generation failed", "voice", voice, "err", err)
failed++
continue
}
audioData, err := s.downloadFromKokoro(ctx, filename)
if err != nil {
s.log.Warn("voice sample warmup: download failed", "voice", voice, "err", err)
failed++
continue
}
if err := s.store.PutAudio(ctx, key, audioData); err != nil {
s.log.Warn("voice sample warmup: upload failed", "voice", voice, "key", key, "err", err)
failed++
continue
}
s.log.Debug("voice sample warmed", "voice", voice)
generated++
}
s.log.Info("voice sample warmup complete",
"generated", generated, "skipped", skipped, "failed", failed)
}
// handleGenerateVoiceSamples handles POST /api/audio/voice-samples.
// It generates short audio samples for each available voice and stores them
// in the audio MinIO bucket so the UI can play them during voice selection.
// Already-generated samples are skipped (idempotent).
// Optional JSON body: {"voices": ["af_bella", ...]} to generate a subset.
// Returns: {"generated": [...], "skipped": [...], "failed": [...]}
func (s *Server) handleGenerateVoiceSamples(w http.ResponseWriter, r *http.Request) {
if s.kokoroURL == "" {
http.Error(w, `{"error":"kokoro not configured"}`, http.StatusServiceUnavailable)
return
}
// Parse optional voice list from body.
var body struct {
Voices []string `json:"voices"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&body)
}
targetVoices := body.Voices
if len(targetVoices) == 0 {
targetVoices = s.voices()
}
type result struct {
Generated []string `json:"generated"`
Skipped []string `json:"skipped"`
Failed []string `json:"failed"`
}
var res result
for _, voice := range targetVoices {
key := voiceSampleKey(voice)
// Skip if already uploaded.
if s.store.AudioExists(r.Context(), key) {
res.Skipped = append(res.Skipped, voice)
s.log.Debug("voice sample already exists, skipping", "voice", voice)
continue
}
// Generate via Kokoro (speed 1.0 for samples).
filename, err := s.generateSpeech(r.Context(), voiceSampleText, voice, 1.0)
if err != nil {
s.log.Warn("voice sample generation failed", "voice", voice, "err", err)
res.Failed = append(res.Failed, voice)
continue
}
// Download from Kokoro and upload to MinIO.
audioData, dlErr := s.downloadFromKokoro(r.Context(), filename)
if dlErr != nil {
s.log.Warn("voice sample kokoro download failed", "voice", voice, "err", dlErr)
res.Failed = append(res.Failed, voice)
continue
}
if putErr := s.store.PutAudio(r.Context(), key, audioData); putErr != nil {
s.log.Warn("voice sample MinIO upload failed", "voice", voice, "key", key, "err", putErr)
res.Failed = append(res.Failed, voice)
continue
}
s.log.Info("voice sample generated", "voice", voice, "key", key)
res.Generated = append(res.Generated, voice)
}
if res.Generated == nil {
res.Generated = []string{}
}
if res.Skipped == nil {
res.Skipped = []string{}
}
if res.Failed == nil {
res.Failed = []string{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(res)
}
// handlePresignVoiceSample handles GET /api/presign/voice-sample/{voice}.
// Returns a presigned URL for the voice sample audio file stored in MinIO.
// Returns 404 if the sample has not been generated yet.
func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request) {
voice := r.PathValue("voice")
if voice == "" {
http.Error(w, `{"error":"missing voice"}`, http.StatusBadRequest)
return
}
key := voiceSampleKey(voice)
if !s.store.AudioExists(r.Context(), key) {
http.NotFound(w, r)
return
}
url, err := s.store.PresignAudio(r.Context(), key, 1*time.Hour)
if err != nil {
s.log.Error("presign voice sample failed", "voice", voice, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}
// handlePresignAvatarUpload handles GET /api/presign/avatar-upload/{userId}.
// Returns a short-lived presigned PUT URL for uploading an avatar image directly
// to MinIO, along with the object key to record in PocketBase after the upload.
// Query param: ext — image extension (jpg, png, webp). Defaults to "jpg".
func (s *Server) handlePresignAvatarUpload(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
return
}
ext := r.URL.Query().Get("ext")
switch ext {
case "jpg", "jpeg":
ext = "jpg"
case "png":
ext = "png"
case "webp":
ext = "webp"
default:
ext = "jpg"
}
uploadURL, key, err := s.store.PresignAvatarUpload(r.Context(), userID, ext)
if err != nil {
s.log.Error("presign avatar upload failed", "userId", userID, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"upload_url": uploadURL,
"key": key,
})
}
// handlePresignAvatar handles GET /api/presign/avatar/{userId}.
// Returns a presigned GET URL for a user's existing avatar, or 404 if none.
func (s *Server) handlePresignAvatar(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userId")
if userID == "" {
http.Error(w, `{"error":"missing userId"}`, http.StatusBadRequest)
return
}
url, found, err := s.store.PresignAvatarURL(r.Context(), userID)
if err != nil {
s.log.Error("presign avatar failed", "userId", userID, "err", err)
http.Error(w, `{"error":"presign failed"}`, http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
}