feat: avatar upload via presigned PUT URL flow
Some checks failed
CI / Scraper / Lint (push) Successful in 11s
CI / Scraper / Lint (pull_request) Successful in 9s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Test (pull_request) Successful in 9s
CI / UI / Build (pull_request) Successful in 27s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 47s
CI / UI / Docker Push (pull_request) Has been skipped
Release / UI / Build (push) Successful in 21s
Release / UI / Docker (push) Successful in 5m1s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / UI / Docker Push (push) Failing after 9m10s
iOS CI / Build (pull_request) Failing after 4m3s
iOS CI / Test (pull_request) Has been skipped

- Go scraper: add PresignAvatarUploadURL/PresignAvatarURL/DeleteAvatar to
  Store interface, implement on HybridStore+MinioClient, register
  GET /api/presign/avatar-upload/{userId} and /api/presign/avatar/{userId}
- SvelteKit: replace direct AWS S3 SDK in minio.ts with presign calls to
  the Go scraper; rewrite avatar +server.ts (POST=presign, PATCH=record key)
- iOS: rewrite uploadAvatar() as 3-step presigned PUT flow; refactor
  chip components into shared ChipButton in CommonViews.swift
This commit is contained in:
Admin
2026-03-10 17:05:43 +05:00
parent 72eed89f59
commit 52f876d8e8
18 changed files with 302 additions and 234 deletions

View File

@@ -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 });