Add user avatar management and moderation

This commit is contained in:
JB
2026-07-25 00:17:18 -04:00
parent c0834804d4
commit 3f78f0f9d8
30 changed files with 915 additions and 18 deletions
+29
View File
@@ -0,0 +1,29 @@
export const AVATAR_PRESETS = [
{ id: 'field-agent', label: 'Field Agent', url: '/avatars/default/field-agent.webp' },
{ id: 'field-medic', label: 'Field Medic', url: '/avatars/default/field-medic.webp' },
{ id: 'systems-engineer', label: 'Systems Engineer', url: '/avatars/default/systems-engineer.webp' },
{ id: 'recon-scout', label: 'Recon Scout', url: '/avatars/default/recon-scout.webp' },
{ id: 'drone-pilot', label: 'Drone Pilot', url: '/avatars/default/drone-pilot.webp' },
{ id: 'field-commander', label: 'Field Commander', url: '/avatars/default/field-commander.webp' },
{ id: 'rescue-specialist', label: 'Rescue Specialist', url: '/avatars/default/rescue-specialist.webp' },
{ id: 'equipment-engineer', label: 'Equipment Engineer', url: '/avatars/default/equipment-engineer.webp' },
{ id: 'wilderness-tracker', label: 'Wilderness Tracker', url: '/avatars/default/wilderness-tracker.webp' },
{ id: 'intelligence-analyst', label: 'Intelligence Analyst', url: '/avatars/default/intelligence-analyst.webp' },
{ id: 'combat-medic', label: 'Combat Medic', url: '/avatars/default/combat-medic.webp' },
{ id: 'comms-specialist', label: 'Comms Specialist', url: '/avatars/default/comms-specialist.webp' }
] as const;
export const DEFAULT_AVATAR_PRESET = AVATAR_PRESETS[0].id;
export function isAvatarPreset(value: string): value is (typeof AVATAR_PRESETS)[number]['id'] {
return AVATAR_PRESETS.some((preset) => preset.id === value);
}
export function avatarUrl(source: unknown, value: unknown, userId: string, updatedAt?: unknown): string {
if (source === 'upload') {
const version = updatedAt ? `?v=${encodeURIComponent(String(updatedAt))}` : '';
return `/avatars/user/${encodeURIComponent(userId)}${version}`;
}
const preset = typeof value === 'string' && isAvatarPreset(value) ? value : DEFAULT_AVATAR_PRESET;
return `/avatars/default/${preset}.webp`;
}
+2 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { Snippet } from 'svelte';
let { action, method = 'POST', class: className = '', children }: { action: string; method?: 'POST'; class?: string; children: Snippet<[boolean]> } = $props();
let { action, method = 'POST', enctype, class: className = '', children }: { action: string; method?: 'POST'; enctype?: 'multipart/form-data'; class?: string; children: Snippet<[boolean]> } = $props();
let pending = $state(false);
</script>
<form {action} {method} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
</form>
+2
View File
@@ -1,5 +1,6 @@
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
import { deleteUploadedAvatar } from '$lib/server/avatars';
export async function softDeleteAccount(
database: Database,
@@ -32,6 +33,7 @@ export async function softDeleteAccount(
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(userId);
await deleteUploadedAvatar(database, userId);
await (await database.prepare(
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'
)).run(
+7 -2
View File
@@ -2,6 +2,7 @@ import { dev } from '$app/environment';
import { getDatabase } from '$lib/server/database';
import type { Cookies } from '@sveltejs/kit';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { avatarUrl } from '$lib/avatar';
const COOKIE_NAME = 'shd_session';
const SESSION_SECONDS = 60 * 60 * 24 * 30;
@@ -12,6 +13,7 @@ export type AuthUser = {
displayName: string;
role: string;
discordLinked: boolean;
avatarUrl: string;
};
function hashToken(token: string): string {
@@ -57,12 +59,14 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
const database = await getDatabase();
const statement = await database.prepare(`
SELECT users.id, users.username, COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role, users.discord_id,
users.role, users.discord_id, user_avatars.source AS avatar_source,
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
deleted_accounts.deleted_at
FROM sessions
JOIN users ON users.id = sessions.user_id
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
LEFT JOIN user_status ON user_status.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE sessions.token_hash = ? AND sessions.expires_at > ?
@@ -91,7 +95,8 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
username: String(user.username),
displayName: String(user.display_name),
role: String(user.role),
discordLinked: typeof user.discord_id === 'string'
discordLinked: typeof user.discord_id === 'string',
avatarUrl: avatarUrl(user.avatar_source, user.avatar_value, String(user.id), user.avatar_updated_at)
};
}
+101
View File
@@ -0,0 +1,101 @@
import { AVATAR_PRESETS, avatarUrl, isAvatarPreset } from '$lib/avatar';
import type { Database } from '@tursodatabase/database';
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import sharp from 'sharp';
export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
export const MAX_AVATAR_PIXELS = 20_000_000;
export const AVATAR_SIZE = 256;
const USER_FILE_PATTERN = /^[0-9a-f-]{36}\.webp$/i;
export class AvatarValidationError extends Error {}
export function avatarStorageDirectory(): string {
return path.resolve(process.env.TDR_AVATAR_STORAGE_PATH || path.join(process.cwd(), 'data', 'avatars'));
}
function storageKey(userId: string): string {
if (!/^[0-9a-f-]{36}$/i.test(userId)) throw new Error('Invalid user identifier');
return `${userId}.webp`;
}
function storagePath(key: string): string {
if (!USER_FILE_PATTERN.test(key)) throw new Error('Invalid avatar storage key');
return path.join(avatarStorageDirectory(), key);
}
export async function processAvatarUpload(file: File): Promise<Buffer> {
if (!file.size || file.size > MAX_AVATAR_BYTES) {
throw new AvatarValidationError('Choose an image no larger than 5 MiB.');
}
const input = Buffer.from(await file.arrayBuffer());
try {
const image = sharp(input, { limitInputPixels: MAX_AVATAR_PIXELS, failOn: 'warning' });
const metadata = await image.metadata();
if (!metadata.format || !['jpeg', 'png', 'webp', 'avif'].includes(metadata.format)) {
throw new AvatarValidationError('Use a JPEG, PNG, WebP, or AVIF image.');
}
return await image
.rotate()
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: 'cover', position: 'attention' })
.webp({ quality: 82, effort: 5 })
.toBuffer();
} catch (error) {
if (error instanceof AvatarValidationError) throw error;
throw new AvatarValidationError('That image could not be processed. Try another JPEG, PNG, WebP, or AVIF file.');
}
}
export async function saveUploadedAvatar(database: Database, userId: string, output: Buffer): Promise<string> {
const key = storageKey(userId);
const directory = avatarStorageDirectory();
await mkdir(directory, { recursive: true });
await writeFile(storagePath(key), output, { flag: 'w', mode: 0o600 });
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO user_avatars(user_id,source,value,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(userId, 'upload', key, output.length, now);
return now;
}
export async function selectPresetAvatar(database: Database, userId: string, preset: string): Promise<void> {
if (!isAvatarPreset(preset)) throw new AvatarValidationError('Choose one of the available SHD avatars.');
const existing = await (await database.prepare('SELECT source,value FROM user_avatars WHERE user_id=?')).get(userId);
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO user_avatars(user_id,source,value,byte_size,updated_at)
VALUES(?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
source=excluded.source,value=excluded.value,byte_size=excluded.byte_size,updated_at=excluded.updated_at`))
.run(userId, 'preset', preset, null, now);
if (existing?.source === 'upload' && typeof existing.value === 'string') {
await unlink(storagePath(existing.value)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
}
export async function readUploadedAvatar(database: Database, userId: string): Promise<Buffer | null> {
const avatar = await (await database.prepare(
"SELECT value FROM user_avatars WHERE user_id=? AND source='upload'"
)).get(userId);
if (typeof avatar?.value !== 'string') return null;
try {
return await readFile(storagePath(avatar.value));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
}
export async function deleteUploadedAvatar(database: Database, userId: string): Promise<void> {
const avatar = await (await database.prepare('SELECT source,value FROM user_avatars WHERE user_id=?')).get(userId);
await (await database.prepare('DELETE FROM user_avatars WHERE user_id=?')).run(userId);
if (avatar?.source === 'upload' && typeof avatar.value === 'string') {
await unlink(storagePath(avatar.value)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
}
export { AVATAR_PRESETS, avatarUrl };