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
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:
@@ -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";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user