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 */
|
/* Begin PBXFileReference section */
|
||||||
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
|
||||||
@@ -337,7 +337,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1600;
|
LastUpgradeCheck = 2630;
|
||||||
};
|
};
|
||||||
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -522,6 +522,7 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
@@ -544,6 +545,7 @@
|
|||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.10;
|
SWIFT_VERSION = 5.10;
|
||||||
@@ -616,6 +618,7 @@
|
|||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
@@ -631,6 +634,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1600"
|
LastUpgradeVersion = "2630"
|
||||||
version = "1.7">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES"
|
buildImplicitDependencies = "YES">
|
||||||
runPostActionsOnFailure = "NO">
|
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -27,8 +26,7 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
@@ -51,8 +49,6 @@
|
|||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
<CommandLineArguments>
|
|
||||||
</CommandLineArguments>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -74,8 +70,6 @@
|
|||||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
|
||||||
</CommandLineArguments>
|
|
||||||
<EnvironmentVariables>
|
<EnvironmentVariables>
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "LIBNOVEL_BASE_URL"
|
key = "LIBNOVEL_BASE_URL"
|
||||||
@@ -100,8 +94,6 @@
|
|||||||
ReferencedContainer = "container:LibNovel.xcodeproj">
|
ReferencedContainer = "container:LibNovel.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
<CommandLineArguments>
|
|
||||||
</CommandLineArguments>
|
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
|
|||||||
@@ -73,27 +73,6 @@ struct ChapterIndexBrief: Codable, Hashable {
|
|||||||
let title: String
|
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
|
// MARK: - User Settings
|
||||||
|
|
||||||
struct UserSettings: Codable {
|
struct UserSettings: Codable {
|
||||||
@@ -230,12 +209,6 @@ struct RankingItem: Codable, Identifiable {
|
|||||||
|
|
||||||
// MARK: - Home
|
// MARK: - Home
|
||||||
|
|
||||||
struct HomeData {
|
|
||||||
let continueReading: [ContinueReadingItem]
|
|
||||||
let recentlyUpdated: [Book]
|
|
||||||
let stats: HomeStats
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContinueReadingItem: Identifiable {
|
struct ContinueReadingItem: Identifiable {
|
||||||
var id: String { book.id }
|
var id: String { book.id }
|
||||||
let book: Book
|
let book: Book
|
||||||
@@ -286,10 +259,3 @@ struct BookBrief: Codable {
|
|||||||
enum NextPrefetchStatus {
|
enum NextPrefetchStatus {
|
||||||
case none, prefetching, prefetched, failed
|
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
|
var baseURL: URL
|
||||||
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
private var authCookie: String? // raw "libnovel_auth=<token>" header value
|
||||||
private var sessionId: String? // anon session id (UUID)
|
|
||||||
|
|
||||||
// URLSession with persistent cookie storage
|
// URLSession with persistent cookie storage
|
||||||
private let session: URLSession = {
|
private let session: URLSession = {
|
||||||
@@ -51,10 +50,6 @@ actor APIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionId(_ id: String) {
|
|
||||||
sessionId = id
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Low-level request builder
|
// MARK: - Low-level request builder
|
||||||
|
|
||||||
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
|
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
|
// MARK: - Auth
|
||||||
|
|
||||||
struct LoginRequest: Encodable {
|
struct LoginRequest: Encodable {
|
||||||
@@ -125,7 +113,7 @@ actor APIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logout() async throws {
|
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)
|
await setAuthCookie(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,13 +151,6 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Browse
|
// 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 {
|
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)"
|
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
|
||||||
return try await fetch("/api/browse-page\(query)")
|
return try await fetch("/api/browse-page\(query)")
|
||||||
@@ -286,40 +267,50 @@ actor APIClient {
|
|||||||
|
|
||||||
// MARK: - Avatar
|
// MARK: - Avatar
|
||||||
|
|
||||||
|
struct AvatarPresignResponse: Decodable {
|
||||||
|
let uploadURL: String
|
||||||
|
let key: String
|
||||||
|
enum CodingKeys: String, CodingKey { case uploadURL = "upload_url"; case key }
|
||||||
|
}
|
||||||
|
|
||||||
struct AvatarResponse: Decodable {
|
struct AvatarResponse: Decodable {
|
||||||
let avatarURL: String?
|
let avatarURL: String?
|
||||||
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
enum CodingKeys: String, CodingKey { case avatarURL = "avatar_url" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upload a JPEG image as the user's profile avatar.
|
/// Upload a profile avatar using a two-step presigned PUT flow:
|
||||||
/// Returns the presigned URL for the uploaded avatar.
|
/// 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? {
|
func uploadAvatar(_ imageData: Data, mimeType: String = "image/jpeg") async throws -> String? {
|
||||||
let boundary = "Boundary-\(UUID().uuidString)"
|
// Step 1: request a presigned PUT URL from the SvelteKit server
|
||||||
let urlString = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let presign: AvatarPresignResponse = try await fetch(
|
||||||
+ "/api/profile/avatar"
|
"/api/profile/avatar",
|
||||||
guard let url = URL(string: urlString) else { throw APIError.invalidResponse }
|
method: "POST",
|
||||||
|
body: ["mime_type": mimeType]
|
||||||
|
)
|
||||||
|
|
||||||
var req = URLRequest(url: url)
|
// Step 2: PUT the image bytes directly to MinIO
|
||||||
req.httpMethod = "POST"
|
guard let putURL = URL(string: presign.uploadURL) else { throw APIError.invalidResponse }
|
||||||
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
var putReq = URLRequest(url: putURL)
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
putReq.httpMethod = "PUT"
|
||||||
|
putReq.setValue(mimeType, forHTTPHeaderField: "Content-Type")
|
||||||
|
putReq.httpBody = imageData
|
||||||
|
|
||||||
var body = Data()
|
let (_, putResp) = try await session.data(for: putReq)
|
||||||
let ext = mimeType.contains("png") ? "png" : mimeType.contains("webp") ? "webp" : "jpg"
|
guard let putHttp = putResp as? HTTPURLResponse,
|
||||||
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
(200..<300).contains(putHttp.statusCode) else {
|
||||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.\(ext)\"\r\n".data(using: .utf8)!)
|
let code = (putResp as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
throw APIError.httpError(code, "MinIO PUT failed")
|
||||||
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 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
|
return result.avatarURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,11 +383,6 @@ struct BrowseResponse: Decodable {
|
|||||||
let novels: [BrowseNovel]
|
let novels: [BrowseNovel]
|
||||||
let page: Int
|
let page: Int
|
||||||
let hasNext: Bool
|
let hasNext: Bool
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
|
||||||
case novels, page
|
|
||||||
case hasNext = "hasNext"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
struct BrowseNovel: Decodable, Identifiable, Hashable {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import Combine
|
import Combine
|
||||||
|
import Kingfisher
|
||||||
|
|
||||||
// MARK: - PlaybackProgress
|
// MARK: - PlaybackProgress
|
||||||
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
// Isolated ObservableObject for high-frequency playback state (currentTime,
|
||||||
@@ -78,20 +79,6 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
default: return true
|
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
|
// MARK: - Private
|
||||||
|
|
||||||
@@ -523,14 +510,17 @@ final class AudioPlayerService: ObservableObject {
|
|||||||
|
|
||||||
private func prefetchCoverArtwork(from urlString: String) {
|
private func prefetchCoverArtwork(from urlString: String) {
|
||||||
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
guard !urlString.isEmpty, let url = URL(string: urlString) else { return }
|
||||||
URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
KingfisherManager.shared.retrieveImage(with: url) { [weak self] result in
|
||||||
guard let self, let data, let image = UIImage(data: data) else { return }
|
guard let self else { return }
|
||||||
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
if case .success(let value) = result {
|
||||||
Task { @MainActor in
|
let image = value.image
|
||||||
self.cachedCoverArtwork = artwork
|
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
||||||
self.updateNowPlaying()
|
Task { @MainActor in
|
||||||
|
self.cachedCoverArtwork = artwork
|
||||||
|
self.updateNowPlaying()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.resume()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Session
|
// MARK: - Audio Session
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ final class BookDetailViewModel: ObservableObject {
|
|||||||
@Published var saved: Bool = false
|
@Published var saved: Bool = false
|
||||||
@Published var lastChapter: Int?
|
@Published var lastChapter: Int?
|
||||||
@Published var isLoading = false
|
@Published var isLoading = false
|
||||||
@Published var chaptersLoading = false
|
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
|
|
||||||
init(slug: String) {
|
init(slug: String) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ struct BookDetailView: View {
|
|||||||
@EnvironmentObject var audioPlayer: AudioPlayerService
|
@EnvironmentObject var audioPlayer: AudioPlayerService
|
||||||
@State private var summaryExpanded = false
|
@State private var summaryExpanded = false
|
||||||
@State private var chapterPage = 0
|
@State private var chapterPage = 0
|
||||||
@State private var scrollOffset: CGFloat = 0
|
|
||||||
private let pageSize = 50
|
private let pageSize = 50
|
||||||
|
|
||||||
init(slug: String) {
|
init(slug: String) {
|
||||||
@@ -213,7 +212,7 @@ struct BookDetailView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.vertical, 14)
|
.padding(.vertical, 14)
|
||||||
|
|
||||||
if vm.chaptersLoading {
|
if vm.isLoading {
|
||||||
ProgressView().frame(maxWidth: .infinity).padding()
|
ProgressView().frame(maxWidth: .infinity).padding()
|
||||||
} else {
|
} else {
|
||||||
ForEach(pageChapters) { ch in
|
ForEach(pageChapters) { ch in
|
||||||
@@ -315,16 +314,6 @@ private struct ChapterRow: View {
|
|||||||
.fontWeight(isCurrent ? .semibold : .regular)
|
.fontWeight(isCurrent ? .semibold : .regular)
|
||||||
.foregroundStyle(isCurrent ? .amber : .primary)
|
.foregroundStyle(isCurrent ? .amber : .primary)
|
||||||
.lineLimit(1)
|
.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)
|
Spacer(minLength: 8)
|
||||||
|
|||||||
@@ -80,3 +80,64 @@ struct TagChip: View {
|
|||||||
.background(Color(.systemGray5), in: Capsule())
|
.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) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
// "All" chip
|
// "All" chip
|
||||||
FilterChipView(
|
ChipButton(
|
||||||
label: "All",
|
label: "All",
|
||||||
isSelected: selectedGenre == "all"
|
isSelected: selectedGenre == "all",
|
||||||
|
style: .filled
|
||||||
) {
|
) {
|
||||||
withAnimation { selectedGenre = "all" }
|
withAnimation { selectedGenre = "all" }
|
||||||
}
|
}
|
||||||
ForEach(availableGenres, id: \.self) { genre in
|
ForEach(availableGenres, id: \.self) { genre in
|
||||||
FilterChipView(
|
ChipButton(
|
||||||
label: genre.capitalized,
|
label: genre.capitalized,
|
||||||
isSelected: selectedGenre == genre
|
isSelected: selectedGenre == genre,
|
||||||
|
style: .filled
|
||||||
) {
|
) {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
selectedGenre = selectedGenre == genre ? "all" : genre
|
selectedGenre = selectedGenre == genre ? "all" : genre
|
||||||
@@ -156,9 +158,10 @@ struct LibraryView: View {
|
|||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: 8) {
|
||||||
ForEach(SortOrder.allCases, id: \.self) { order in
|
ForEach(SortOrder.allCases, id: \.self) { order in
|
||||||
SortChip(
|
ChipButton(
|
||||||
label: order.rawValue,
|
label: order.rawValue,
|
||||||
isSelected: sortOrder == order
|
isSelected: sortOrder == order,
|
||||||
|
style: .outlined
|
||||||
) {
|
) {
|
||||||
withAnimation { sortOrder = order }
|
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) {
|
func (s *mockStore) PresignAudio(_ context.Context, _ string, _ time.Duration) (string, error) {
|
||||||
return "", nil
|
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) SaveBrowsePage(_ context.Context, _, _ string) error { return nil }
|
||||||
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
func (s *mockStore) GetBrowsePage(_ context.Context, _ string) (string, bool, error) {
|
||||||
return "", false, nil
|
return "", false, nil
|
||||||
|
|||||||
@@ -649,3 +649,64 @@ func (s *Server) handlePresignVoiceSample(w http.ResponseWriter, r *http.Request
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"url": url})
|
_ = 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/chapter/{slug}/{n}", s.handlePresignChapter)
|
||||||
mux.HandleFunc("GET /api/presign/audio/{slug}/{n}", s.handlePresignAudio)
|
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/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)
|
// Plain-text chapter content (used server-side for audio generation)
|
||||||
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
mux.HandleFunc("GET /api/chapter-text/{slug}/{n}", s.handleChapterText)
|
||||||
// Voices list (proxied from Kokoro)
|
// 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)
|
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 ────────────────────────────────────────────────────
|
// ─── Browse page snapshots ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *HybridStore) SaveBrowsePage(ctx context.Context, key, html string) error {
|
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
|
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.
|
// PresignAvatarURL returns a presigned GET URL for a user avatar.
|
||||||
// Returns ("", false, nil) when no avatar exists for the given userID.
|
// Returns ("", false, nil) when no avatar exists for the given userID.
|
||||||
func (m *MinioClient) PresignAvatarURL(ctx context.Context, userID string) (string, bool, error) {
|
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 returns a presigned GET URL for an audio object.
|
||||||
PresignAudio(ctx context.Context, key string, expires time.Duration) (string, error)
|
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) ──────────────────────────────────────
|
// ── Browse page snapshots (MinIO) ──────────────────────────────────────
|
||||||
|
|
||||||
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
|
// SaveBrowsePage stores a SingleFile HTML snapshot for the given cache key.
|
||||||
|
|||||||
@@ -9,40 +9,15 @@
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { env as pubEnv } from '$env/dynamic/public';
|
import { env as pubEnv } from '$env/dynamic/public';
|
||||||
import { log } from '$lib/server/logger';
|
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';
|
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.
|
// 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.
|
// In docker-compose this would differ from the internal endpoint.
|
||||||
const MINIO_PUBLIC_URL = pubEnv.PUBLIC_MINIO_PUBLIC_URL ?? 'http://localhost:9000';
|
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 ───────────────────────────────────────────────────────────
|
// ─── Avatar helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const AVATAR_EXTS = ['jpg', 'png', 'webp', 'gif'] as const;
|
function extFromMime(mime: string): string {
|
||||||
type AvatarExt = (typeof AVATAR_EXTS)[number];
|
|
||||||
|
|
||||||
function avatarKey(userId: string, ext: AvatarExt): string {
|
|
||||||
return `avatars/${userId}.${ext}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extFromMime(mime: string): AvatarExt {
|
|
||||||
if (mime.includes('png')) return 'png';
|
if (mime.includes('png')) return 'png';
|
||||||
if (mime.includes('webp')) return 'webp';
|
if (mime.includes('webp')) return 'webp';
|
||||||
if (mime.includes('gif')) return 'gif';
|
if (mime.includes('gif')) return 'gif';
|
||||||
@@ -50,32 +25,19 @@ function extFromMime(mime: string): AvatarExt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload an avatar image buffer to MinIO.
|
* Returns a short-lived presigned PUT URL for uploading an avatar directly to MinIO,
|
||||||
* Deletes any existing avatar for this user first, then stores the new one.
|
* along with the object key to record in PocketBase after upload completes.
|
||||||
* Returns the MinIO object key (e.g. "avatars/abc123.jpg").
|
* 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 ext = extFromMime(mimeType);
|
||||||
const s3 = makeS3Client();
|
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar-upload/${encodeURIComponent(userId)}?ext=${ext}`);
|
||||||
|
if (!res.ok) {
|
||||||
// Delete old avatars (all extensions) to avoid stale objects
|
const body = await res.text().catch(() => '');
|
||||||
await Promise.all(
|
throw new Error(`presign avatar upload failed: ${res.status} ${body}`);
|
||||||
AVATAR_EXTS.map((e) =>
|
}
|
||||||
s3.send(new DeleteObjectCommand({ Bucket: BUCKET_AVATARS, Key: avatarKey(userId, e) })).catch(() => {})
|
const data = (await res.json()) as { upload_url: string; key: string };
|
||||||
)
|
return { uploadUrl: data.upload_url, key: data.key };
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,25 +45,14 @@ export async function putAvatar(userId: string, data: Uint8Array, mimeType: stri
|
|||||||
* Returns null if no avatar exists.
|
* Returns null if no avatar exists.
|
||||||
*/
|
*/
|
||||||
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
export async function presignAvatarUrl(userId: string): Promise<string | null> {
|
||||||
const s3 = makeS3Client();
|
const res = await fetch(`${SCRAPER_URL}/api/presign/avatar/${encodeURIComponent(userId)}`);
|
||||||
for (const ext of AVATAR_EXTS) {
|
if (res.status === 404) return null;
|
||||||
const key = avatarKey(userId, ext);
|
if (!res.ok) {
|
||||||
try {
|
const body = await res.text().catch(() => '');
|
||||||
await s3.send(new HeadObjectCommand({ Bucket: BUCKET_AVATARS, Key: key }));
|
throw new Error(`presign avatar failed: ${res.status} ${body}`);
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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> {
|
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
|
||||||
const token = await getToken();
|
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',
|
method: 'PATCH',
|
||||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ avatar_url: avatarUrl })
|
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 { json, error } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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';
|
import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase';
|
||||||
|
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/profile/avatar
|
* POST /api/profile/avatar
|
||||||
* Accepts multipart/form-data with a "file" field.
|
* Body: JSON { mime_type: "image/jpeg" | "image/png" | "image/webp" }
|
||||||
* Uploads to MinIO libnovel-avatars bucket, stores the key in app_users.avatar_url.
|
*
|
||||||
* Returns { avatar_url: <presigned-url> }.
|
* 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 }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
if (!locals.user) error(401, 'Not authenticated');
|
if (!locals.user) error(401, 'Not authenticated');
|
||||||
|
|
||||||
const contentType = request.headers.get('content-type') ?? '';
|
let mimeType = 'image/jpeg';
|
||||||
if (!contentType.includes('multipart/form-data')) {
|
|
||||||
error(400, 'Expected multipart/form-data');
|
|
||||||
}
|
|
||||||
|
|
||||||
let formData: FormData;
|
|
||||||
try {
|
try {
|
||||||
formData = await request.formData();
|
const body = await request.json();
|
||||||
|
if (body?.mime_type) mimeType = body.mime_type;
|
||||||
} catch {
|
} catch {
|
||||||
error(400, 'Failed to parse form data');
|
// default to jpeg if body is missing/invalid
|
||||||
}
|
}
|
||||||
|
|
||||||
const file = formData.get('file');
|
if (!ALLOWED_TYPES.includes(mimeType)) {
|
||||||
if (!(file instanceof File)) error(400, 'Missing "file" field');
|
error(400, `Unsupported image type: ${mimeType}. Allowed: jpeg, png, webp`);
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
|
||||||
error(400, `Unsupported image type: ${file.type}. Allowed: jpeg, png, webp, gif`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_SIZE) {
|
const { uploadUrl, key } = await presignAvatarUploadUrl(locals.user.id, mimeType);
|
||||||
error(413, 'Image too large (max 5 MB)');
|
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());
|
if (!key) error(400, 'Missing "key" field');
|
||||||
const key = await putAvatar(locals.user.id, buffer, file.type);
|
|
||||||
|
|
||||||
// Persist key in PocketBase so we can look it up later
|
|
||||||
await updateUserAvatarUrl(locals.user.id, key);
|
await updateUserAvatarUrl(locals.user.id, key);
|
||||||
|
|
||||||
// Return a fresh presigned URL for immediate use
|
|
||||||
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
const avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||||
return json({ avatar_url: avatarUrl });
|
return json({ avatar_url: avatarUrl });
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/profile/avatar
|
* 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 }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
if (!locals.user) error(401, 'Not authenticated');
|
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);
|
const record = await getUserByUsername(locals.user.username).catch(() => null);
|
||||||
if (!record?.avatar_url) {
|
if (!record?.avatar_url) {
|
||||||
return json({ avatar_url: null });
|
return json({ avatar_url: null });
|
||||||
|
|||||||
Reference in New Issue
Block a user