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
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:
1673
ui/package-lock.json
generated
1673
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
"vite": "^7.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1005.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1005.0",
|
||||
"marked": "^17.0.3",
|
||||
"pocketbase": "^0.26.8"
|
||||
}
|
||||
|
||||
@@ -9,12 +9,101 @@
|
||||
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 {
|
||||
if (mime.includes('png')) return 'png';
|
||||
if (mime.includes('webp')) return 'webp';
|
||||
if (mime.includes('gif')) return 'gif';
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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").
|
||||
*/
|
||||
export async function putAvatar(userId: string, data: Uint8Array, mimeType: string): Promise<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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a presigned GET URL for a user's avatar, rewritten to the public URL.
|
||||
* 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
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the MinIO host in a presigned URL to the public-facing URL.
|
||||
* The presigned URL is signed against the internal endpoint (e.g. minio:9000),
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface User {
|
||||
password_hash: string;
|
||||
role: string;
|
||||
created: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
// ─── Auth token cache ─────────────────────────────────────────────────────────
|
||||
@@ -794,3 +795,15 @@ export async function revokeAllUserSessions(userId: string): Promise<void> {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the avatar_url field for a user record.
|
||||
*/
|
||||
export async function updateUserAvatarUrl(userId: string, avatarUrl: string): Promise<void> {
|
||||
const token = await getToken();
|
||||
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 })
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
};
|
||||
|
||||
67
ui/src/routes/api/profile/avatar/+server.ts
Normal file
67
ui/src/routes/api/profile/avatar/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { changePassword, listUserSessions } from '$lib/server/pocketbase';
|
||||
import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase';
|
||||
import { presignAvatarUrl } from '$lib/server/minio';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -15,8 +16,20 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
// Fetch avatar presigned URL if user has one
|
||||
let avatarUrl: string | null = null;
|
||||
try {
|
||||
const record = await getUserByUsername(locals.user.username);
|
||||
if (record?.avatar_url) {
|
||||
avatarUrl = await presignAvatarUrl(locals.user.id);
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user,
|
||||
avatarUrl,
|
||||
sessions: sessions.map((s) => ({
|
||||
id: s.id,
|
||||
user_agent: s.user_agent,
|
||||
|
||||
@@ -6,6 +6,38 @@
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// ── Avatar ───────────────────────────────────────────────────────────────────
|
||||
let avatarUrl = $state<string | null>(data.avatarUrl ?? null);
|
||||
let avatarUploading = $state(false);
|
||||
let avatarError = $state('');
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
|
||||
async function handleAvatarChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
avatarUploading = true;
|
||||
avatarError = '';
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await fetch('/api/profile/avatar', { method: 'POST', body: fd });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as { message?: string };
|
||||
avatarError = body.message ?? `Upload failed (${res.status})`;
|
||||
return;
|
||||
}
|
||||
const result = await res.json() as { avatar_url: string | null };
|
||||
avatarUrl = result.avatar_url;
|
||||
} catch {
|
||||
avatarError = 'Network error during upload';
|
||||
} finally {
|
||||
avatarUploading = false;
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────────────────
|
||||
let voices = $state<string[]>([]);
|
||||
let voicesLoaded = $state(false);
|
||||
@@ -145,9 +177,57 @@
|
||||
<form id="logout-form" method="POST" action="/logout" class="hidden"></form>
|
||||
|
||||
<div class="max-w-xl mx-auto space-y-10">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">Profile</h1>
|
||||
<p class="text-zinc-400 text-sm mt-1">Signed in as <span class="text-zinc-200 font-medium">{data.user.username}</span></p>
|
||||
<div class="flex items-center gap-5">
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
onclick={() => fileInput?.click()}
|
||||
class="group relative w-20 h-20 rounded-full overflow-hidden ring-2 ring-zinc-600 hover:ring-amber-400 transition-all focus:outline-none focus:ring-amber-400"
|
||||
title="Change profile picture"
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
{#if avatarUrl}
|
||||
<img src={avatarUrl} alt="Profile" class="w-full h-full object-cover" />
|
||||
{:else}
|
||||
<div class="w-full h-full bg-zinc-700 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-zinc-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Hover overlay -->
|
||||
<div class="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{#if avatarUploading}
|
||||
<svg class="w-5 h-5 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
onchange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-zinc-100">{data.user.username}</h1>
|
||||
<p class="text-zinc-400 text-sm mt-0.5 capitalize">{data.user.role}</p>
|
||||
{#if avatarError}
|
||||
<p class="text-red-400 text-xs mt-1">{avatarError}</p>
|
||||
{:else}
|
||||
<p class="text-zinc-500 text-xs mt-1">Click avatar to change photo</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Reading settings ─────────────────────────────────────────────────── -->
|
||||
|
||||
Reference in New Issue
Block a user