feat:profile pictures

This commit is contained in:
2025-10-16 18:08:03 +02:00
parent bee03a57ec
commit e54c4fb98e
14 changed files with 559 additions and 11 deletions

View File

@@ -4,6 +4,7 @@ import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase64url, encodeHexLowerCase } from '@oslojs/encoding';
import { db } from '$lib/server/db';
import * as table from '$lib/server/db/schema';
import { getSignedR2Url } from '$lib/server/r2';
const DAY_IN_MS = 1000 * 60 * 60 * 24;
@@ -31,7 +32,11 @@ export async function validateSessionToken(token: string) {
const [result] = await db
.select({
// Adjust user table here to tweak returned data
user: { id: table.user.id, username: table.user.username },
user: {
id: table.user.id,
username: table.user.username,
profilePictureUrl: table.user.profilePictureUrl
},
session: table.session
})
.from(table.session)
@@ -58,7 +63,25 @@ export async function validateSessionToken(token: string) {
.where(eq(table.session.id, session.id));
}
return { session, user };
// Generate signed URL for profile picture if it exists
let profilePictureUrl = user.profilePictureUrl;
if (profilePictureUrl && !profilePictureUrl.startsWith('http')) {
// It's a path, generate signed URL
try {
profilePictureUrl = await getSignedR2Url(profilePictureUrl, 24 * 60 * 60); // 24 hours
} catch (error) {
console.error('Failed to generate signed URL for profile picture:', error);
profilePictureUrl = null;
}
}
return {
session,
user: {
...user,
profilePictureUrl
}
};
}
export type SessionValidationResult = Awaited<ReturnType<typeof validateSessionToken>>;
@@ -95,7 +118,8 @@ export async function createUser(googleId: string, name: string) {
username: name,
googleId,
passwordHash: null,
age: null
age: null,
profilePictureUrl: null
};
await db.insert(table.user).values(user);
return user;

View File

@@ -5,7 +5,8 @@ export const user = pgTable('user', {
age: integer('age'),
username: text('username').notNull().unique(),
passwordHash: text('password_hash'),
googleId: text('google_id').unique()
googleId: text('google_id').unique(),
profilePictureUrl: text('profile_picture_url')
});
export const session = pgTable('session', {

View File

@@ -123,3 +123,85 @@ export async function processAndUploadVideo(
fallbackThumbnailUrl: thumbnailUrl
};
}
export async function processAndUploadProfilePicture(
file: File,
userId: string
): Promise<{
url: string;
thumbnailUrl: string;
fallbackUrl?: string;
fallbackThumbnailUrl?: string;
}> {
const buffer = Buffer.from(await file.arrayBuffer());
// Generate unique filename
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 15);
const filename = `users/${userId}/profile-${timestamp}-${randomId}`;
// Process full-size image in WebP format (with JPEG fallback)
const processedWebP = await sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 85, effort: 4 })
.toBuffer();
// Generate JPEG fallback for older browsers
const processedJPEG = await sharp(buffer)
.resize(400, 400, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 85, progressive: true })
.toBuffer();
// Generate smaller thumbnail in WebP format
const thumbnailWebP = await sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'centre'
})
.webp({ quality: 80, effort: 4 })
.toBuffer();
// Generate JPEG thumbnail fallback
const thumbnailJPEG = await sharp(buffer)
.resize(150, 150, {
fit: 'cover',
position: 'centre'
})
.jpeg({ quality: 80 })
.toBuffer();
// Upload all variants to R2
const webpFile = new File([new Uint8Array(processedWebP)], `${filename}.webp`, {
type: 'image/webp'
});
const jpegFile = new File([new Uint8Array(processedJPEG)], `${filename}.jpg`, {
type: 'image/jpeg'
});
const thumbWebPFile = new File([new Uint8Array(thumbnailWebP)], `${filename}-thumb.webp`, {
type: 'image/webp'
});
const thumbJPEGFile = new File([new Uint8Array(thumbnailJPEG)], `${filename}-thumb.jpg`, {
type: 'image/jpeg'
});
const [webpPath, jpegPath, thumbWebPPath, thumbJPEGPath] = await Promise.all([
uploadToR2(webpFile, `${filename}.webp`, 'image/webp'),
uploadToR2(jpegFile, `${filename}.jpg`, 'image/jpeg'),
uploadToR2(thumbWebPFile, `${filename}-thumb.webp`, 'image/webp'),
uploadToR2(thumbJPEGFile, `${filename}-thumb.jpg`, 'image/jpeg')
]);
// Return WebP URLs as primary, JPEG as fallback (client can choose based on browser support)
return {
url: webpPath,
thumbnailUrl: thumbWebPPath,
fallbackUrl: jpegPath,
fallbackThumbnailUrl: thumbJPEGPath
};
}