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

@@ -50,7 +50,7 @@
/* Begin PBXFileReference section */
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
@@ -337,7 +337,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
LastUpgradeCheck = 2630;
};
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
developmentRegion = en;
@@ -522,6 +522,7 @@
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
@@ -544,6 +545,7 @@
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.10;
@@ -616,6 +618,7 @@
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -631,6 +634,7 @@
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";

View File

@@ -1,11 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
LastUpgradeVersion = "2630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
@@ -27,8 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
@@ -51,8 +49,6 @@
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -74,8 +70,6 @@
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "LIBNOVEL_BASE_URL"
@@ -100,8 +94,6 @@
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

View File

@@ -73,27 +73,6 @@ struct ChapterIndexBrief: Codable, Hashable {
let title: String
}
// MARK: - Progress
struct ReadingProgress: Codable {
var id: String?
let sessionId: String
var userId: String?
let slug: String
var chapter: Int
var audioTime: Double?
let updated: String
enum CodingKeys: String, CodingKey {
case id
case sessionId = "session_id"
case userId = "user_id"
case slug, chapter
case audioTime = "audio_time"
case updated
}
}
// MARK: - User Settings
struct UserSettings: Codable {
@@ -230,12 +209,6 @@ struct RankingItem: Codable, Identifiable {
// MARK: - Home
struct HomeData {
let continueReading: [ContinueReadingItem]
let recentlyUpdated: [Book]
let stats: HomeStats
}
struct ContinueReadingItem: Identifiable {
var id: String { book.id }
let book: Book
@@ -286,10 +259,3 @@ struct BookBrief: Codable {
enum NextPrefetchStatus {
case none, prefetching, prefetched, failed
}
// MARK: - PocketBase list response
struct PBList<T: Codable>: Codable {
let items: [T]
let totalItems: Int
}

View File

@@ -11,7 +11,6 @@ actor APIClient {
var baseURL: URL
private var authCookie: String? // raw "libnovel_auth=<token>" header value
private var sessionId: String? // anon session id (UUID)
// URLSession with persistent cookie storage
private let session: URLSession = {
@@ -51,10 +50,6 @@ actor APIClient {
}
}
func setSessionId(_ id: String) {
sessionId = id
}
// MARK: - Low-level request builder
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
@@ -95,13 +90,6 @@ actor APIClient {
}
}
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
let req = try makeRequest(path, method: method, body: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
return (data, http)
}
// MARK: - Auth
struct LoginRequest: Encodable {
@@ -125,7 +113,7 @@ actor APIClient {
}
func logout() async throws {
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
let _: EmptyResponse = try await fetch("/api/auth/logout", method: "POST")
await setAuthCookie(nil)
}
@@ -163,13 +151,6 @@ actor APIClient {
// MARK: - Browse
struct BrowseParams: Encodable {
let page: Int
let genre: String
let sort: String
let status: String
}
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
return try await fetch("/api/browse-page\(query)")
@@ -286,40 +267,50 @@ actor APIClient {
// MARK: - Avatar
struct AvatarPresignResponse: Decodable {
let uploadURL: String
let key: String
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
}
struct AvatarResponse: Decodable {
let avatarURL: String?
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
}
/// Upload a JPEG image as the user's profile avatar.
/// Returns the presigned URL for the uploaded avatar.
/// Upload a profile avatar using a two-step presigned PUT flow:
/// 1. POST /api/profile/avatar get a presigned PUT URL + object key
/// 2. PUT image bytes directly to MinIO via the presigned URL
/// 3. PATCH /api/profile/avatar with the key to record it in PocketBase
/// Returns the presigned GET URL for the uploaded avatar.
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
let boundary = "Boundary-\(UUID().uuidString)"
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ "/api/profile/avatar"
guard let url = URL(string: urlString) else { throw APIError.invalidResponse }
// Step 1: request a presigned PUT URL from the SvelteKit server
let presign: AvatarPresignResponse = try await fetch(
"/api/profile/avatar",
method: "POST",
body: ["mime_type": mimeType]
)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
// Step 2: PUT the image bytes directly to MinIO
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
var putReq = URLRequest(url: putURL)
putReq.httpMethod = "PUT"
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
putReq.httpBody = imageData
var body = Data()
let ext = mimeType.contains("png") ? "png" : mimeType.contains("webp") ? "webp" : "jpg"
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.\(ext)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(imageData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = body
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
guard (200..<300).contains(http.statusCode) else {
let raw = String(data: data, encoding: .utf8) ?? ""
throw APIError.httpError(http.statusCode, raw)
let (_, putResp) = try await session.data(for: putReq)
guard let putHttp = putResp as? HTTPURLResponse,
(200..<300).contains(putHttp.statusCode) else {
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
throw APIError.httpError(code, "MinIO PUT failed")
}
let result = try JSONDecoder.iso8601.decode(AvatarResponse.self, from: data)
// Step 3: record the key in PocketBase and get back a presigned GET URL
let result: AvatarResponse = try await fetch(
"/api/profile/avatar",
method: "PATCH",
body: ["key": presign.key]
)
return result.avatarURL
}
}
@@ -392,11 +383,6 @@ struct BrowseResponse: Decodable {
let novels: [BrowseNovel]
let page: Int
let hasNext: Bool
enum CodingKeys: String, CodingKey {
case novels, page
case hasNext = "hasNext"
}
}
struct BrowseNovel: Decodable, Identifiable, Hashable {

View File

@@ -2,6 +2,7 @@ import Foundation
import AVFoundation
import MediaPlayer
import Combine
import Kingfisher
// MARK: - PlaybackProgress
// Isolated ObservableObject for high-frequency playback state (currentTime,
@@ -79,20 +80,6 @@ final class AudioPlayerService: ObservableObject {
}
}
/// Absolute previous chapter number (current - 1), or nil if at first chapter
var absolutePrevChapter: Int? {
guard chapter > 1 else { return nil }
return chapter - 1
}
/// Absolute next chapter number (current + 1), or nil if at last chapter
var absoluteNextChapter: Int? {
guard !chapters.isEmpty else { return nil }
let maxChapter = chapters.map(\.number).max() ?? chapter
guard chapter < maxChapter else { return nil }
return chapter + 1
}
// MARK: - Private
private var player: AVPlayer?
@@ -523,14 +510,17 @@ final class AudioPlayerService: ObservableObject {
private func prefetchCoverArtwork(from urlString: String) {
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
guard let self, let data, let image = UIImage(data: data) else { return }
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
guard let self else { return }
if case .success(let value) = result {
let image = value.image
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
Task { @MainActor in
self.cachedCoverArtwork = artwork
self.updateNowPlaying()
}
}.resume()
}
}
}
// MARK: - Audio Session

View File

@@ -9,7 +9,6 @@ final class BookDetailViewModel: ObservableObject {
@Published var saved: Bool = false
@Published var lastChapter: Int?
@Published var isLoading = false
@Published var chaptersLoading = false
@Published var error: String?
init(slug: String) {

View File

@@ -8,7 +8,6 @@ struct BookDetailView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService
@State private var summaryExpanded = false
@State private var chapterPage = 0
@State private var scrollOffset: CGFloat = 0
private let pageSize = 50
init(slug: String) {
@@ -213,7 +212,7 @@ struct BookDetailView: View {
.padding(.horizontal)
.padding(.vertical, 14)
if vm.chaptersLoading {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding()
} else {
ForEach(pageChapters) { ch in
@@ -315,16 +314,6 @@ private struct ChapterRow: View {
.fontWeight(isCurrent ? .semibold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
.lineLimit(1)
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
let subtitle = chapter.title.strippingTrailingDate()
if !subtitle.isEmpty && subtitle != "Chapter \(chapter.number)" {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
Spacer(minLength: 8)

View File

@@ -80,3 +80,64 @@ struct TagChip: View {
.background(Color(.systemGray5), in: Capsule())
}
}
// MARK: - Unified chip button (filter/sort chips across all screens)
//
// .filled amber background when selected (genre filter chips in Library)
// .outlined amber border + tint when selected, grey background (sort chips, browse filter chips)
enum ChipButtonStyle { case filled, outlined }
struct ChipButton: View {
let label: String
let isSelected: Bool
var style: ChipButtonStyle = .filled
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(chipFont)
.padding(.horizontal, chipHPad)
.padding(.vertical, 6)
.background(background)
.foregroundStyle(foregroundColor)
.overlay(border)
}
.buttonStyle(.plain)
}
private var chipFont: Font {
switch style {
case .filled: return .caption.weight(isSelected ? .semibold : .regular)
case .outlined: return .subheadline.weight(isSelected ? .semibold : .regular)
}
}
private var chipHPad: CGFloat { style == .outlined ? 14 : 12 }
@ViewBuilder
private var background: some View {
switch style {
case .filled:
Capsule().fill(isSelected ? Color.amber : Color(.systemGray5))
case .outlined:
Capsule()
.fill(isSelected ? Color.amber.opacity(0.15) : Color(.systemGray6))
.overlay(Capsule().stroke(isSelected ? Color.amber : .clear, lineWidth: 1.5))
}
}
private var foregroundColor: Color {
switch style {
case .filled: return isSelected ? .white : .primary
case .outlined: return isSelected ? .amber : .primary
}
}
@ViewBuilder
private var border: some View {
// outlined style already has its border baked into `background`
EmptyView()
}
}

View File

@@ -130,16 +130,18 @@ struct LibraryView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
// "All" chip
FilterChipView(
ChipButton(
label: "All",
isSelected: selectedGenre == "all"
isSelected: selectedGenre == "all",
style: .filled
) {
withAnimation { selectedGenre = "all" }
}
ForEach(availableGenres, id: \.self) { genre in
FilterChipView(
ChipButton(
label: genre.capitalized,
isSelected: selectedGenre == genre
isSelected: selectedGenre == genre,
style: .filled
) {
withAnimation {
selectedGenre = selectedGenre == genre ? "all" : genre
@@ -156,9 +158,10 @@ struct LibraryView: View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(SortOrder.allCases, id: \.self) { order in
SortChip(
ChipButton(
label: order.rawValue,
isSelected: sortOrder == order
isSelected: sortOrder == order,
style: .outlined
) {
withAnimation { sortOrder = order }
}

View File

@@ -151,6 +151,13 @@ func (s *mockStore) PresignChapter(_ context.Context, _ string, _ int, _ time.Du
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
return "", nil
}
func (s *mockStore) PresignAvatarUpload(_ context.Context, _, _ string) (string, string, error) {
return "", "", nil
}
func (s *mockStore) PresignAvatarURL(_ context.Context, _ string) (string, bool, error) {
return "", false, nil
}
func (s *mockStore) DeleteAvatar(_ context.Context, _ string) error { return nil }
func (s *mockStore) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
return "", false, nil

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)

View File

@@ -316,6 +316,18 @@ func (h *HybridStore) PresignAudio(ctx context.Context, key string, expires time
return h.minio.PresignAudio(ctx, key, expires)
}
func (h *HybridStore) PresignAvatarUpload(ctx context.Context, userID, ext string) (string, string, error) {
return h.minio.PresignAvatarUploadURL(ctx, userID, ext)
}
func (h *HybridStore) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
return h.minio.PresignAvatarURL(ctx, userID)
}
func (h *HybridStore) DeleteAvatar(ctx context.Context, userID string) error {
return h.minio.DeleteAvatar(ctx, userID)
}
// ─── Browse page snapshots ────────────────────────────────────────────────────
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {

View File

@@ -362,6 +362,22 @@ func (m *MinioClient) PutAvatar(ctx context.Context, userID, ext string, data []
return nil
}
// PresignAvatarUploadURL returns a presigned PUT URL for uploading an avatar image
// directly to MinIO from the client. Signed with the public endpoint so iOS/browser
// can PUT bytes straight to MinIO without routing through the server.
// ext should be "jpg", "png", or "webp". Expires in 15 minutes.
func (m *MinioClient) PresignAvatarUploadURL(ctx context.Context, userID, ext string) (string, string, error) {
if m.cfg.BucketAvatars == "" {
return "", "", fmt.Errorf("minio: avatars bucket not configured")
}
key := avatarKey(userID, ext)
u, err := m.pub.PresignedPutObject(ctx, m.cfg.BucketAvatars, key, 15*time.Minute)
if err != nil {
return "", "", fmt.Errorf("minio: presign avatar upload %s: %w", key, err)
}
return u.String(), key, nil
}
// PresignAvatarURL returns a presigned GET URL for a user avatar.
// Returns ("", false, nil) when no avatar exists for the given userID.
func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {

View File

@@ -160,6 +160,17 @@ type Store interface {
// PresignAudio returns a presigned GET URL for an audio object.
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
// PresignAvatarUpload returns a short-lived presigned PUT URL for uploading
// an avatar image directly to MinIO, and the object key that will be stored.
// ext should be "jpg", "png", or "webp".
PresignAvatarUpload(ctx context.Context, userID, ext string) (uploadURL, key string, err error)
// PresignAvatarURL returns a presigned GET URL for a user's avatar, or ("", false, nil) if none.
PresignAvatarURL(ctx context.Context, userID string) (string, bool, error)
// DeleteAvatar removes all avatar objects for a user (all extensions).
DeleteAvatar(ctx context.Context, userID string) error
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.

View File

@@ -9,40 +9,15 @@
import { env } from '$env/dynamic/private';
import { env as pubEnv } from '$env/dynamic/public';
import { log } from '$lib/server/logger';
import { S3Client, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const SCRAPER_URL = env.SCRAPER_API_URL ?? 'http://localhost:8080';
// Public MinIO URL — used to rewrite presigned URLs so the browser can reach MinIO directly.
// In docker-compose this would differ from the internal endpoint.
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
// MinIO direct client (for avatar uploads — not routed through Go scraper)
const MINIO_ENDPOINT = env.MINIO_ENDPOINT ?? 'localhost:9000';
const MINIO_ACCESS_KEY = env.MINIO_ACCESS_KEY ?? 'admin';
const MINIO_SECRET_KEY = env.MINIO_SECRET_KEY ?? 'changeme123';
const MINIO_USE_SSL = (env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true';
const BUCKET_AVATARS = env.MINIO_BUCKET_AVATARS ?? 'libnovel-avatars';
function makeS3Client(): S3Client {
return new S3Client({
endpoint: `${MINIO_USE_SSL ? 'https' : 'http'}://${MINIO_ENDPOINT}`,
region: 'us-east-1', // MinIO ignores region but SDK requires one
credentials: { accessKeyId: MINIO_ACCESS_KEY, secretAccessKey: MINIO_SECRET_KEY },
forcePathStyle: true // MinIO requires path-style URLs
});
}
// ─── Avatar helpers ───────────────────────────────────────────────────────────
const AVATAR_EXTS = ['jpg', 'png', 'webp', 'gif'] as const;
type AvatarExt = (typeof AVATAR_EXTS)[number];
function avatarKey(userId: string, ext: AvatarExt): string {
return `avatars/${userId}.${ext}`;
}
function extFromMime(mime: string): AvatarExt {
function extFromMime(mime: string): string {
if (mime.includes('png')) return 'png';
if (mime.includes('webp')) return 'webp';
if (mime.includes('gif')) return 'gif';
@@ -50,32 +25,19 @@ function extFromMime(mime: string): AvatarExt {
}
/**
* Upload an avatar image buffer to MinIO.
* Deletes any existing avatar for this user first, then stores the new one.
* Returns the MinIO object key (e.g. "avatars/abc123.jpg").
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
* along with the object key to record in PocketBase after upload completes.
* Routed through the Go scraper which holds MinIO credentials.
*/
export async function putAvatar(userId: string, data: Uint8Array, mimeType: string): Promise<string> {
export async function presignAvatarUploadUrl(userId: string, mimeType: string): Promise<{ uploadUrl: string; key: string }> {
const ext = extFromMime(mimeType);
const s3 = makeS3Client();
// Delete old avatars (all extensions) to avoid stale objects
await Promise.all(
AVATAR_EXTS.map((e) =>
s3.send(new DeleteObjectCommand({ Bucket: BUCKET_AVATARS, Key: avatarKey(userId, e) })).catch(() => {})
)
);
const key = avatarKey(userId, ext);
await s3.send(
new PutObjectCommand({
Bucket: BUCKET_AVATARS,
Key: key,
Body: data,
ContentType: mimeType
})
);
log.info('minio', 'avatar uploaded', { userId, key });
return key;
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
}
const data = (await res.json()) as { upload_url: string; key: string };
return { uploadUrl: data.upload_url, key: data.key };
}
/**
@@ -83,25 +45,14 @@ export async function putAvatar(userId: string, data: Uint8Array, mimeType: stri
* Returns null if no avatar exists.
*/
export async function presignAvatarUrl(userId: string): Promise<string | null> {
const s3 = makeS3Client();
for (const ext of AVATAR_EXTS) {
const key = avatarKey(userId, ext);
try {
await s3.send(new HeadObjectCommand({ Bucket: BUCKET_AVATARS, Key: key }));
// Object exists — generate presigned URL using public endpoint
const pubS3 = new S3Client({
endpoint: MINIO_PUBLIC_URL,
region: 'us-east-1',
credentials: { accessKeyId: MINIO_ACCESS_KEY, secretAccessKey: MINIO_SECRET_KEY },
forcePathStyle: true
});
const url = await getSignedUrl(pubS3, new GetObjectCommand({ Bucket: BUCKET_AVATARS, Key: key }), { expiresIn: 86400 });
return url;
} catch {
// not found or error — try next extension
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
if (res.status === 404) return null;
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`presign avatar failed: ${res.status} ${body}`);
}
}
return null;
const data = (await res.json()) as { url: string };
return data.url ?? null;
}
/**

View File

@@ -801,9 +801,13 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
*/
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
const token = await getToken();
await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
const res = await fetch(`${PB_URL}/api/collections/app_users/records/${userId}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ avatar_url: avatarUrl })
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`updateUserAvatarUrl failed: ${res.status} ${body}`);
}
}

View File

@@ -1,62 +1,76 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { putAvatar, presignAvatarUrl } from '$lib/server/minio';
import { presignAvatarUploadUrl, presignAvatarUrl } from '$lib/server/minio';
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
/**
* POST /api/profile/avatar
* Accepts multipart/form-data with a "file" field.
* Uploads to MinIO libnovel-avatars bucket, stores the key in app_users.avatar_url.
* Returns { avatar_url: <presigned-url> }.
* Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" }
*
* Returns a short-lived presigned PUT URL pointing at MinIO (public endpoint)
* so the client can upload the image bytes directly, bypassing the server.
* After the PUT completes, the client must call PATCH /api/profile/avatar
* with the returned key to record it in PocketBase.
*
* Returns: { upload_url: string, key: string }
*/
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.includes('multipart/form-data')) {
error(400, 'Expected multipart/form-data');
}
let formData: FormData;
let mimeType = 'image/jpeg';
try {
formData = await request.formData();
const body = await request.json();
if (body?.mime_type) mimeType = body.mime_type;
} catch {
error(400, 'Failed to parse form data');
// default to jpeg if body is missing/invalid
}
const file = formData.get('file');
if (!(file instanceof File)) error(400, 'Missing "file" field');
if (!ALLOWED_TYPES.includes(file.type)) {
error(400, `Unsupported image type: ${file.type}. Allowed: jpeg, png, webp, gif`);
if (!ALLOWED_TYPES.includes(mimeType)) {
error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`);
}
if (file.size > MAX_SIZE) {
error(413, 'Image too large (max 5 MB)');
const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType);
return json({ upload_url: uploadUrl, key });
};
/**
* PATCH /api/profile/avatar
* Body: JSON { key: string }
*
* Called after the client has successfully PUT the image to MinIO via the
* presigned URL. Records the object key in PocketBase and returns a fresh
* presigned GET URL for immediate display.
*
* Returns: { avatar_url: string | null }
*/
export const PATCH: RequestHandler = async ({ request, locals }) => {
if (!locals.user) error(401, 'Not authenticated');
let key: string | undefined;
try {
const body = await request.json();
if (typeof body?.key === 'string') key = body.key;
} catch {
error(400, 'Invalid JSON body');
}
const buffer = new Uint8Array(await file.arrayBuffer());
const key = await putAvatar(locals.user.id, buffer, file.type);
if (!key) error(400, 'Missing "key" field');
// Persist key in PocketBase so we can look it up later
await updateUserAvatarUrl(locals.user.id, key);
// Return a fresh presigned URL for immediate use
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};
/**
* GET /api/profile/avatar
* Returns a presigned URL for the current user's avatar, or null if none set.
* Returns a presigned GET URL for the current user's avatar, or null if none set.
*/
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) error(401, 'Not authenticated');
// First try to get from PocketBase record (the stored key acts as a flag)
const record = await getUserByUsername(locals.user.username).catch(() => null);
if (!record?.avatar_url) {
return json({ avatar_url: null });