Add user avatar management and moderation
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user