255 lines
17 KiB
TypeScript
255 lines
17 KiB
TypeScript
import { createSession, getSessionUser } from '$lib/server/auth/session';
|
||
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||
import { getDatabase } from '$lib/server/database';
|
||
import { fail, redirect } from '@sveltejs/kit';
|
||
import type { Actions, PageServerLoad } from './$types';
|
||
import { AVATAR_PRESETS, AvatarValidationError, processAvatarUpload, saveUploadedAvatar, selectPresetAvatar } from '$lib/server/avatars';
|
||
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||
|
||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const database = await getDatabase();
|
||
const [builds, guides, favorites, ratings, connection, emailIdentity, passkeys, authenticationMethods] = await Promise.all([
|
||
(await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id),
|
||
(await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id),
|
||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id),
|
||
(await database.prepare('SELECT builds.id, builds.title, builds.slug, COALESCE(user_profiles.display_name,users.username) AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id),
|
||
(await database.prepare(`SELECT users.discord_id,users.login_username,users.password_hash,discord_profiles.username AS discord_username,
|
||
discord_profiles.display_name,discord_profiles.avatar_hash
|
||
FROM users LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id WHERE users.id=?`)).get(user.id),
|
||
(await database.prepare('SELECT email,verified_at FROM user_emails WHERE user_id=?')).get(user.id),
|
||
(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(user.id),
|
||
getAuthenticationMethods(database, user.id)
|
||
]);
|
||
const oauthErrorCode = url.searchParams.get('oauth_error');
|
||
const oauthErrors: Record<string, { title: string; message: string }> = {
|
||
discord_in_use: {
|
||
title: 'DISCORD ACCOUNT ALREADY LINKED',
|
||
message: 'That Discord account belongs to another SHD.build agent. No accounts were changed. Sign out, then use Continue with Discord to access the existing account.'
|
||
},
|
||
link_session_changed: {
|
||
title: 'SESSION CHANGED',
|
||
message: 'Your SHD.build session changed while Discord was connecting. Sign in to the intended account and try linking again.'
|
||
},
|
||
discord_denied: {
|
||
title: 'DISCORD AUTHORIZATION CANCELLED',
|
||
message: 'Discord did not authorize the connection. Your SHD.build account was not changed.'
|
||
}
|
||
};
|
||
const oauthError = oauthErrorCode
|
||
? oauthErrors[oauthErrorCode] ?? {
|
||
title: 'DISCORD LINK FAILED',
|
||
message: 'Discord linking could not be completed. Your existing sign-in methods and account data were not changed.'
|
||
}
|
||
: null;
|
||
return {
|
||
account: { builds, guides, favorites, ratings },
|
||
discordConnection: connection?.discord_id ? {
|
||
id: String(connection.discord_id),
|
||
username: connection.discord_username ? String(connection.discord_username) : null,
|
||
displayName: connection.display_name ? String(connection.display_name) : null,
|
||
avatarHash: connection.avatar_hash ? String(connection.avatar_hash) : null,
|
||
canUnlink: canRemoveAuthenticationMethod(authenticationMethods, 'discord')
|
||
} : null,
|
||
hasPassword: typeof connection?.password_hash === 'string',
|
||
hasLocalCredentials: typeof connection?.password_hash === 'string' && typeof connection?.login_username === 'string',
|
||
emailIdentity: emailIdentity ? { email: String(emailIdentity.email), verifiedAt: String(emailIdentity.verified_at) } : null,
|
||
authenticationMethods,
|
||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||
emailVerified: url.searchParams.has('email_verified'),
|
||
passkeys: passkeys.map((passkey) => ({
|
||
id: String(passkey.id), name: String(passkey.name), deviceType: String(passkey.device_type),
|
||
backedUp: Boolean(passkey.backed_up), lastUsedAt: passkey.last_used_at ? String(passkey.last_used_at) : null,
|
||
createdAt: String(passkey.created_at)
|
||
})),
|
||
chooseDisplayName: url.searchParams.has('choose_display_name'),
|
||
discordLinked: url.searchParams.has('discord_linked'),
|
||
oauthError,
|
||
avatarPresets: AVATAR_PRESETS
|
||
};
|
||
};
|
||
|
||
export const actions: Actions = {
|
||
requestEmailVerification: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase();
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
||
return fail(400, { emailError: 'Enter a valid email address.' });
|
||
}
|
||
if (!emailDeliveryConfigured()) return fail(503, { emailError: 'Email delivery is not configured yet.' });
|
||
const database = await getDatabase();
|
||
const owner = await (await database.prepare('SELECT user_id FROM user_emails WHERE email=? COLLATE NOCASE AND user_id<>?')).get(email, user.id);
|
||
if (owner) return fail(409, { emailError: 'That email address is already linked to another agent.' });
|
||
const token = await createMagicLinkToken(database, user.id, email, 'verify_email');
|
||
await sendAuthenticationLink(email, token, 'verify_email');
|
||
return { emailVerificationSent: true };
|
||
},
|
||
removePasskey: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const id = String((await request.formData()).get('passkeyId') ?? '');
|
||
const database = await getDatabase();
|
||
const methods = await getAuthenticationMethods(database, user.id);
|
||
if (!canRemoveAuthenticationMethod(methods, 'passkey')) {
|
||
return fail(409, { passkeyError: 'Add another sign-in method before removing your only passkey.' });
|
||
}
|
||
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(id, user.id);
|
||
return { passkeyRemoved: true };
|
||
},
|
||
removeEmailIdentity: async ({ cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const database = await getDatabase();
|
||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'email')) {
|
||
return fail(409, { emailError: 'Add another sign-in method before removing your verified email.' });
|
||
}
|
||
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(user.id);
|
||
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(user.id);
|
||
return { emailRemoved: true };
|
||
},
|
||
removeLocalCredentials: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const password = String((await request.formData()).get('password') ?? '');
|
||
const database = await getDatabase();
|
||
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
|
||
if (typeof credential?.password_hash !== 'string') return fail(409, { localCredentialError: 'Password sign-in is not configured.' });
|
||
if (!(await verifyPassword(credential.password_hash, password))) return fail(400, { localCredentialError: 'Your password is incorrect.' });
|
||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'local')) {
|
||
return fail(409, { localCredentialError: 'Add another sign-in method before removing username and password sign-in.' });
|
||
}
|
||
await (await database.prepare('UPDATE users SET login_username=NULL,password_hash=NULL WHERE id=?')).run(user.id);
|
||
return { localCredentialsRemoved: true };
|
||
},
|
||
selectAvatar: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const preset = String((await request.formData()).get('preset') ?? '');
|
||
try {
|
||
await selectPresetAvatar(await getDatabase(), user.id, preset);
|
||
return { avatarChanged: true };
|
||
} catch (error) {
|
||
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
|
||
throw error;
|
||
}
|
||
},
|
||
uploadAvatar: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const file = (await request.formData()).get('avatar');
|
||
if (!(file instanceof File)) return fail(400, { avatarError: 'Choose an image to upload.' });
|
||
try {
|
||
const output = await processAvatarUpload(file);
|
||
await saveUploadedAvatar(await getDatabase(), user.id, output);
|
||
return { avatarChanged: true };
|
||
} catch (error) {
|
||
if (error instanceof AvatarValidationError) return fail(400, { avatarError: error.message });
|
||
throw error;
|
||
}
|
||
},
|
||
changeLoginUsername: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim();
|
||
const database = await getDatabase();
|
||
const credential = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
|
||
if (typeof credential?.login_username !== 'string' || typeof credential?.password_hash !== 'string') {
|
||
return fail(409, { loginUsernameError: 'Add username and password sign-in before changing its username.' });
|
||
}
|
||
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { loginUsernameError: 'Use 3–32 ASCII letters, numbers, underscores, or hyphens.' });
|
||
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { loginUsernameError: 'That login username is reserved.' });
|
||
if (await (await database.prepare('SELECT id FROM users WHERE login_username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { loginUsernameError: 'That login username is already in use.' });
|
||
await (await database.prepare('UPDATE users SET login_username = ? WHERE id = ?')).run(username, user.id);
|
||
return { loginUsernameChanged: true };
|
||
},
|
||
addLocalCredentials: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const form = await request.formData();
|
||
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
|
||
const password = String(form.get('newPassword') ?? '');
|
||
const confirm = String(form.get('confirmPassword') ?? '');
|
||
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) return fail(400, { localCredentialError: 'Use a username with 3–32 ASCII letters, numbers, underscores, or hyphens.' });
|
||
if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { localCredentialError: 'That login username is reserved.' });
|
||
if (password.length < 12 || password.length > 128) return fail(400, { localCredentialError: 'Use a password between 12 and 128 characters.' });
|
||
if (password !== confirm) return fail(400, { localCredentialError: 'The new passwords do not match.' });
|
||
const database = await getDatabase();
|
||
const current = await (await database.prepare('SELECT login_username,password_hash FROM users WHERE id=?')).get(user.id);
|
||
if (current?.login_username || current?.password_hash) return fail(409, { localCredentialError: 'Username and password sign-in is already configured.' });
|
||
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) return fail(409, { localCredentialError: 'That login username is already in use.' });
|
||
await (await database.prepare('UPDATE users SET login_username=?,password_hash=? WHERE id=?'))
|
||
.run(username, await hashPassword(password), user.id);
|
||
return { localCredentialsAdded: true };
|
||
},
|
||
changeDisplayName: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const displayName = String((await request.formData()).get('displayName') ?? '').normalize('NFKC').trim();
|
||
const length = Array.from(displayName).length;
|
||
if (length < 2 || length > 40 || /[\p{C}\r\n]/u.test(displayName)) {
|
||
return fail(400, { displayNameError: 'Use 2–40 visible characters on one line.' });
|
||
}
|
||
const now = new Date().toISOString(), database = await getDatabase();
|
||
await (await database.prepare(`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
|
||
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`))
|
||
.run(user.id, displayName, now);
|
||
return { displayNameChanged: true };
|
||
},
|
||
changePassword: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const form = await request.formData();
|
||
const current = String(form.get('currentPassword') ?? ''), next = String(form.get('newPassword') ?? ''), confirm = String(form.get('confirmPassword') ?? '');
|
||
if (next.length < 12 || next.length > 128) return fail(400, { passwordError: 'Use between 12 and 128 characters.' });
|
||
if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' });
|
||
const database = await getDatabase();
|
||
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id);
|
||
if (typeof credential?.password_hash !== 'string') return fail(409, { passwordError: 'Add username and password sign-in first.' });
|
||
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' });
|
||
await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id);
|
||
await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id);
|
||
await createSession(cookies, user.id);
|
||
return { passwordChanged: true };
|
||
},
|
||
deleteAccount: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const form = await request.formData();
|
||
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||
const password = String(form.get('password') ?? '');
|
||
if (confirmation !== 'DELETE') return fail(400, { deleteError: 'Enter DELETE exactly to confirm deletion.' });
|
||
const database = await getDatabase();
|
||
const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id=?')).get(user.id);
|
||
if (typeof credential?.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
|
||
return fail(400, { deleteError: 'Your password is incorrect.' });
|
||
}
|
||
if (!(await softDeleteAccount(database, user.id, user.id, 'Deleted by account owner'))) {
|
||
return fail(409, { deleteError: 'This account has already been deleted.' });
|
||
}
|
||
cookies.delete('shd_session', { path: '/' });
|
||
redirect(303, '/?account_deleted=1');
|
||
},
|
||
unlinkDiscord: async ({ request, cookies }) => {
|
||
const user = await getSessionUser(cookies);
|
||
if (!user) redirect(303, '/sign-in');
|
||
const password = String((await request.formData()).get('password') ?? '');
|
||
const database = await getDatabase();
|
||
const credential = await (await database.prepare('SELECT password_hash,discord_id FROM users WHERE id=?')).get(user.id);
|
||
if (!credential?.discord_id) return fail(409, { discordUnlinkError: 'No Discord account is linked.' });
|
||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, user.id), 'discord')) {
|
||
return fail(400, { discordUnlinkError: 'Add another sign-in method before unlinking Discord so you do not lose access.' });
|
||
}
|
||
if (typeof credential.password_hash === 'string' && !(await verifyPassword(credential.password_hash, password))) {
|
||
return fail(400, { discordUnlinkError: 'Your password is incorrect.' });
|
||
}
|
||
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(user.id);
|
||
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(user.id);
|
||
return { discordUnlinked: true };
|
||
}
|
||
};
|