feat: avatar upload via presigned PUT URL flow
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

- 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
This commit is contained in:
Admin
2026-03-10 17:05:43 +05:00
parent 72eed89f59
commit 52f876d8e8
18 changed files with 302 additions and 234 deletions

View File

@@ -649,3 +649,64 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
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})
}

View File

@@ -165,6 +165,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /api/presign/chapter/{slug}/{n}", s.handlePresignChapter)
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
mux.HandleFunc("GET /api/presign/voice-sample/{voice}", s.handlePresignVoiceSample)
mux.HandleFunc("GET /api/presign/avatar-upload/{userId}", s.handlePresignAvatarUpload)
mux.HandleFunc("GET /api/presign/avatar/{userId}", s.handlePresignAvatar)
// Plain-text chapter content (used server-side for audio generation)
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
// Voices list (proxied from Kokoro)