From 428b57732e6ecd34669ea6bddc87e3564a42951f Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 28 Mar 2026 19:23:30 +0500 Subject: [PATCH] fix(ui): resolve avatar URL from MinIO; fall back to OAuth provider URL Add resolveAvatarUrl(userId, storedValue) helper that tries MinIO first, then falls back to the stored HTTP URL for OAuth users (Google/GitHub) who have never uploaded a custom avatar. Add getUserById() to pocketbase helpers for batch avatar resolution in comments. Update all 6 call sites to use the new helper. --- ui/src/lib/server/minio.ts | 40 ++++++++++++++++++- ui/src/lib/server/pocketbase.ts | 13 ++++++ ui/src/routes/api/auth/me/+server.ts | 4 +- ui/src/routes/api/comments/[slug]/+server.ts | 9 +++-- ui/src/routes/api/profile/avatar/+server.ts | 8 +--- ui/src/routes/api/users/[username]/+server.ts | 8 ++-- ui/src/routes/profile/+page.server.ts | 8 ++-- .../routes/users/[username]/+page.server.ts | 8 ++-- 8 files changed, 71 insertions(+), 27 deletions(-) diff --git a/ui/src/lib/server/minio.ts b/ui/src/lib/server/minio.ts index 149573e..0eb494d 100644 --- a/ui/src/lib/server/minio.ts +++ b/ui/src/lib/server/minio.ts @@ -40,8 +40,8 @@ export async function presignAvatarUploadUrl(userId: string, mimeType: string): } /** - * Returns a presigned GET URL for a user's avatar, rewritten to the public URL. - * Returns null if no avatar exists. + * Returns a presigned GET URL for a user's avatar from MinIO. + * Returns null if no avatar object exists in MinIO for this user. */ export async function presignAvatarUrl(userId: string): Promise { const res = await backendFetch(`/api/presign/avatar/${encodeURIComponent(userId)}`); @@ -54,6 +54,42 @@ export async function presignAvatarUrl(userId: string): Promise { return data.url ? rewriteHost(data.url) : null; } +/** + * Resolves the best available avatar URL for a user. + * + * Priority: + * 1. MinIO — if the user has uploaded a custom avatar it will be found here + * (presigned, short-lived GET URL). + * 2. OAuth provider URL — stored in avatar_url when the account was created + * via Google / GitHub OAuth (e.g. https://lh3.googleusercontent.com/...). + * Returned as-is; the browser fetches it directly. + * + * Pass the raw `avatar_url` field from the PocketBase record as `storedValue` + * so this function can distinguish between a MinIO key and a remote URL without + * an extra DB round-trip. + * + * Returns null when neither source yields an avatar. + */ +export async function resolveAvatarUrl( + userId: string, + storedValue: string | null | undefined +): Promise { + // 1. Try MinIO first (custom upload takes priority over OAuth picture). + try { + const minioUrl = await presignAvatarUrl(userId); + if (minioUrl) return minioUrl; + } catch { + // MinIO unavailable — fall through to OAuth fallback. + } + + // 2. Fall back to OAuth-provided picture URL if it looks like a remote URL. + if (storedValue && storedValue.startsWith('http')) { + return storedValue; + } + + return null; +} + /** * Rewrites the MinIO host in a presigned URL to the public-facing URL. * diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 407dcc7..b8061dc 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -541,6 +541,19 @@ export async function getUserByUsername(username: string): Promise return listOne('app_users', `username="${username.replace(/"/g, '\\"')}"`); } +/** + * Look up a user by their PocketBase record ID. Returns null if not found. + */ +export async function getUserById(id: string): Promise { + const token = await getToken(); + const res = await fetch(`${PB_URL}/api/collections/app_users/records/${encodeURIComponent(id)}`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.status === 404) return null; + if (!res.ok) return null; + return res.json() as Promise; +} + /** * Look up a user by email. Returns null if not found. */ diff --git a/ui/src/routes/api/auth/me/+server.ts b/ui/src/routes/api/auth/me/+server.ts index 7ac173d..7b7abb5 100644 --- a/ui/src/routes/api/auth/me/+server.ts +++ b/ui/src/routes/api/auth/me/+server.ts @@ -1,6 +1,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getUserByUsername } from '$lib/server/pocketbase'; +import { resolveAvatarUrl } from '$lib/server/minio'; /** * GET /api/auth/me @@ -13,10 +14,11 @@ export const GET: RequestHandler = async ({ locals }) => { } // Fetch full record from PocketBase to get avatar_url const record = await getUserByUsername(locals.user.username).catch(() => null); + const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null); return json({ id: locals.user.id, username: locals.user.username, role: locals.user.role, - avatar_url: record?.avatar_url ?? null + avatar_url: avatarUrl }); }; diff --git a/ui/src/routes/api/comments/[slug]/+server.ts b/ui/src/routes/api/comments/[slug]/+server.ts index b90e559..cce8cf2 100644 --- a/ui/src/routes/api/comments/[slug]/+server.ts +++ b/ui/src/routes/api/comments/[slug]/+server.ts @@ -5,9 +5,10 @@ import { listReplies, createComment, getMyVotes, + getUserById, type CommentSort } from '$lib/server/pocketbase'; -import { presignAvatarUrl } from '$lib/server/minio'; +import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; /** @@ -38,13 +39,15 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { replies: repliesPerComment[i] })); - // Batch-resolve avatar presign URLs for all unique user_ids + // Batch-resolve avatar URLs for all unique user_ids + // MinIO first (custom upload), fall back to OAuth provider picture. const allComments = [...topLevel, ...allReplies]; const uniqueUserIds = [...new Set(allComments.map((c) => c.user_id).filter(Boolean))]; const avatarEntries = await Promise.all( uniqueUserIds.map(async (userId) => { try { - const url = await presignAvatarUrl(userId); + const user = await getUserById(userId); + const url = await resolveAvatarUrl(userId, user?.avatar_url); return [userId, url] as [string, string | null]; } catch { return [userId, null] as [string, null]; diff --git a/ui/src/routes/api/profile/avatar/+server.ts b/ui/src/routes/api/profile/avatar/+server.ts index f43de11..3d72169 100644 --- a/ui/src/routes/api/profile/avatar/+server.ts +++ b/ui/src/routes/api/profile/avatar/+server.ts @@ -1,6 +1,6 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { presignAvatarUrl } from '$lib/server/minio'; +import { presignAvatarUrl, resolveAvatarUrl } from '$lib/server/minio'; import { updateUserAvatarUrl, getUserByUsername } from '$lib/server/pocketbase'; import { backendFetch } from '$lib/server/scraper'; @@ -63,10 +63,6 @@ export const GET: RequestHandler = async ({ locals }) => { if (!locals.user) error(401, 'Not authenticated'); 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); + const avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url).catch(() => null); return json({ avatar_url: avatarUrl }); }; diff --git a/ui/src/routes/api/users/[username]/+server.ts b/ui/src/routes/api/users/[username]/+server.ts index 7c8f5b5..318e25a 100644 --- a/ui/src/routes/api/users/[username]/+server.ts +++ b/ui/src/routes/api/users/[username]/+server.ts @@ -1,7 +1,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getPublicProfile, getSubscription } from '$lib/server/pocketbase'; -import { presignAvatarUrl } from '$lib/server/minio'; +import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; /** @@ -15,11 +15,9 @@ export const GET: RequestHandler = async ({ params, locals }) => { const profile = await getPublicProfile(username); if (!profile) error(404, `User "${username}" not found`); - // Resolve avatar presigned URL if set + // Resolve avatar — MinIO first, fall back to OAuth provider picture let avatarUrl: string | null = null; - if (profile.avatar_url) { - avatarUrl = await presignAvatarUrl(profile.id).catch(() => null); - } + avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null); // Is the current logged-in user subscribed? let isSubscribed = false; diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index 587337b..76ebca5 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -1,7 +1,7 @@ import { fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { changePassword, listUserSessions, getUserByUsername } from '$lib/server/pocketbase'; -import { presignAvatarUrl } from '$lib/server/minio'; +import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ locals }) => { @@ -16,13 +16,11 @@ export const load: PageServerLoad = async ({ locals }) => { log.warn('profile', 'listUserSessions failed (non-fatal)', { err: String(e) }); } - // Fetch avatar presigned URL if user has one + // Fetch avatar — MinIO first, fall back to OAuth provider picture let avatarUrl: string | null = null; try { const record = await getUserByUsername(locals.user.username); - if (record?.avatar_url) { - avatarUrl = await presignAvatarUrl(locals.user.id); - } + avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url); } catch (e) { log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) }); } diff --git a/ui/src/routes/users/[username]/+page.server.ts b/ui/src/routes/users/[username]/+page.server.ts index fa9cfe6..3efc082 100644 --- a/ui/src/routes/users/[username]/+page.server.ts +++ b/ui/src/routes/users/[username]/+page.server.ts @@ -6,7 +6,7 @@ import { getUserPublicLibrary, getUserCurrentlyReading } from '$lib/server/pocketbase'; -import { presignAvatarUrl } from '$lib/server/minio'; +import { resolveAvatarUrl } from '$lib/server/minio'; import { log } from '$lib/server/logger'; export const load: PageServerLoad = async ({ params, locals }) => { @@ -15,11 +15,9 @@ export const load: PageServerLoad = async ({ params, locals }) => { const profile = await getPublicProfile(username).catch(() => null); if (!profile) error(404, `User "${username}" not found`); - // Resolve avatar + // Resolve avatar — MinIO first, fall back to OAuth provider picture let avatarUrl: string | null = null; - if (profile.avatar_url) { - avatarUrl = await presignAvatarUrl(profile.id).catch(() => null); - } + avatarUrl = await resolveAvatarUrl(profile.id, profile.avatar_url).catch(() => null); // Subscription state for the logged-in visitor let isSubscribed = false;