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

@@ -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),

View File

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