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:
@@ -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