diff --git a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj index c94fbcb..f4c9882 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj +++ b/ios/LibNovel/LibNovel.xcodeproj/project.pbxproj @@ -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 = ""; }; - 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 = ""; }; 39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; 3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; @@ -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"; diff --git a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme index f271d0d..575ed1b 100644 --- a/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme +++ b/ios/LibNovel/LibNovel.xcodeproj/xcshareddata/xcschemes/LibNovel.xcscheme @@ -1,11 +1,10 @@ + LastUpgradeVersion = "2630" + version = "1.3"> + buildImplicitDependencies = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES"> - - - - - - diff --git a/ios/LibNovel/LibNovel/Models/Models.swift b/ios/LibNovel/LibNovel/Models/Models.swift index e1f1e59..d925716 100644 --- a/ios/LibNovel/LibNovel/Models/Models.swift +++ b/ios/LibNovel/LibNovel/Models/Models.swift @@ -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: Codable { - let items: [T] - let totalItems: Int -} diff --git a/ios/LibNovel/LibNovel/Networking/APIClient.swift b/ios/LibNovel/LibNovel/Networking/APIClient.swift index bb4d717..42f0baf 100644 --- a/ios/LibNovel/LibNovel/Networking/APIClient.swift +++ b/ios/LibNovel/LibNovel/Networking/APIClient.swift @@ -11,7 +11,6 @@ actor APIClient { var baseURL: URL private var authCookie: String? // raw "libnovel_auth=" 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 { diff --git a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift index 7947ba8..7af1fde 100644 --- a/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift +++ b/ios/LibNovel/LibNovel/Services/AudioPlayerService.swift @@ -2,6 +2,7 @@ import Foundation import AVFoundation import MediaPlayer import Combine +import Kingfisher // MARK: - PlaybackProgress // Isolated ObservableObject for high-frequency playback state (currentTime, @@ -78,20 +79,6 @@ final class AudioPlayerService: ObservableObject { default: return true } } - - /// 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 @@ -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 } - let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image } - Task { @MainActor in - self.cachedCoverArtwork = artwork - self.updateNowPlaying() + 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 diff --git a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift index 384742c..a98dd5b 100644 --- a/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift +++ b/ios/LibNovel/LibNovel/ViewModels/BookDetailViewModel.swift @@ -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) { diff --git a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift index 56470b7..73d80e1 100644 --- a/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift +++ b/ios/LibNovel/LibNovel/Views/BookDetail/BookDetailView.swift @@ -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) diff --git a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift index 261b324..7fd4f94 100644 --- a/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift +++ b/ios/LibNovel/LibNovel/Views/Common/CommonViews.swift @@ -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() + } +} diff --git a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift index 856e8a5..77fdc04 100644 --- a/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift +++ b/ios/LibNovel/LibNovel/Views/Library/LibraryView.swift @@ -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 } } diff --git a/scraper/internal/orchestrator/orchestrator_test.go b/scraper/internal/orchestrator/orchestrator_test.go index b73d9eb..8d2324a 100644 --- a/scraper/internal/orchestrator/orchestrator_test.go +++ b/scraper/internal/orchestrator/orchestrator_test.go @@ -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 diff --git a/scraper/internal/server/handlers_audio.go b/scraper/internal/server/handlers_audio.go index 5c19111..1dda33c 100644 --- a/scraper/internal/server/handlers_audio.go +++ b/scraper/internal/server/handlers_audio.go @@ -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}) +} diff --git a/scraper/internal/server/server.go b/scraper/internal/server/server.go index a4e9ae4..24f38bb 100644 --- a/scraper/internal/server/server.go +++ b/scraper/internal/server/server.go @@ -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) diff --git a/scraper/internal/storage/hybrid.go b/scraper/internal/storage/hybrid.go index e15bf05..68cee40 100644 --- a/scraper/internal/storage/hybrid.go +++ b/scraper/internal/storage/hybrid.go @@ -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 { diff --git a/scraper/internal/storage/minio.go b/scraper/internal/storage/minio.go index a71c43a..569fbec 100644 --- a/scraper/internal/storage/minio.go +++ b/scraper/internal/storage/minio.go @@ -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) { diff --git a/scraper/internal/storage/store.go b/scraper/internal/storage/store.go index 0611ea1..6dfb286 100644 --- a/scraper/internal/storage/store.go +++ b/scraper/internal/storage/store.go @@ -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. diff --git a/ui/src/lib/server/minio.ts b/ui/src/lib/server/minio.ts index 05b02dd..2558a27 100644 --- a/ui/src/lib/server/minio.ts +++ b/ui/src/lib/server/minio.ts @@ -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 { +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 { - 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; } /** diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 9e1ac76..be74b04 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -801,9 +801,13 @@ export async function revokeAllUserSessions(userId: string): Promise { */ export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise { 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}`); + } } diff --git a/ui/src/routes/api/profile/avatar/+server.ts b/ui/src/routes/api/profile/avatar/+server.ts index d245442..4ca1afb 100644 --- a/ui/src/routes/api/profile/avatar/+server.ts +++ b/ui/src/routes/api/profile/avatar/+server.ts @@ -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: }. + * 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 });