feat: add avatar upload support (MinIO bucket, PocketBase field, SvelteKit API, iOS ProfileView)
Some checks failed
CI / Scraper / Lint (push) Successful in 15s
CI / Scraper / Test (push) Successful in 19s
CI / UI / Build (push) Successful in 21s
CI / Scraper / Lint (pull_request) Successful in 14s
iOS CI / Test (push) Has been cancelled
iOS CI / Build (push) Has been cancelled
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 1m32s
iOS CI / Test (pull_request) Has been skipped
CI / UI / Docker Push (push) Successful in 6m42s
CI / Scraper / Docker Push (push) Successful in 7m12s

This commit is contained in:
Admin
2026-03-10 16:08:38 +05:00
parent 3a2d113b1b
commit fb8f1dfe25
19 changed files with 2226 additions and 83 deletions

View File

@@ -1,5 +1,6 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getUserByUsername } from '$lib/server/pocketbase';
/**
* GET /api/auth/me
@@ -10,9 +11,12 @@ export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) {
error(401, 'Not authenticated');
}
// Fetch full record from PocketBase to get avatar_url
const record = await getUserByUsername(locals.user.username).catch(() => null);
return json({
id: locals.user.id,
username: locals.user.username,
role: locals.user.role
role: locals.user.role,
avatar_url: record?.avatar_url ?? null
});
};

View File

@@ -0,0 +1,67 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { putAvatar, 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'];
/**
* 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> }.
*/
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;
try {
formData = await request.formData();
} catch {
error(400, 'Failed to parse form data');
}
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 (file.size > MAX_SIZE) {
error(413, 'Image too large (max 5 MB)');
}
const buffer = new Uint8Array(await file.arrayBuffer());
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);
// 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.
*/
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 });
}
const avatarUrl = await presignAvatarUrl(locals.user.id);
return json({ avatar_url: avatarUrl });
};