feat: add community publishing and news syndication
This commit is contained in:
@@ -5,11 +5,11 @@ import { notificationSummary } from '$lib/server/notifications';
|
||||
import { getSiteAccessSettings } from '$lib/server/site-access';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
return {
|
||||
user,
|
||||
isacAccess: await hasPermission(user, 'isac.access'),
|
||||
notifications: user ? await notificationSummary(user.id) : { unread: 0 },
|
||||
siteAccess: await getSiteAccessSettings()
|
||||
};
|
||||
const user = await getSessionUser(cookies);
|
||||
return {
|
||||
user,
|
||||
isacAccess: await hasPermission(user, 'isac.access'),
|
||||
notifications: user ? await notificationSummary(user.id) : { unread: 0 },
|
||||
siteAccess: await getSiteAccessSettings(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<section class="community-channels">
|
||||
<div class="section-heading"><div><p class="eyebrow"><span></span> COMMUNITY CHANNELS</p><h2>ONE NETWORK. EVERY MISSION.</h2></div><p>SHD.build grows with the community and the game.</p></div>
|
||||
<div class="community-channel-grid">
|
||||
<a href="/news"><small>01 // FIELD REPORTS</small><strong>NEWS & UPDATES</strong><span>Track game announcements, updates, and community reporting.</span><b>OPEN CHANNEL →</b></a>
|
||||
<a href="/news"><small>01 // FIELD REPORTS</small><strong>NEWS</strong><span>Track game announcements, updates, and community reporting.</span><b>OPEN CHANNEL →</b></a>
|
||||
<a href="/database"><small>02 // ISAC ARCHIVE</small><strong>GAME DATABASE</strong><span>Reference attributes, talents, brands, gear, weapons, mods, and protocols.</span><b>QUERY DATABASE →</b></a>
|
||||
<a href="/builds"><small>03 // AGENT LOADOUTS</small><strong>COMMUNITY BUILDS</strong><span>Discover, rate, copy, and discuss configurations from other agents.</span><b>EXPLORE BUILDS →</b></a>
|
||||
<a href="/guides"><small>04 // FIELD MANUAL</small><strong>GUIDES</strong><span>Read detailed play styles, rotations, strategies, and build walkthroughs.</span><b>READ GUIDES →</b></a>
|
||||
|
||||
+494
-264
@@ -4,286 +4,516 @@ 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 {
|
||||
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';
|
||||
import {
|
||||
emailDeliveryConfigured,
|
||||
sendAuthenticationLink,
|
||||
} from '$lib/server/auth/mail';
|
||||
import {
|
||||
canRemoveAuthenticationMethod,
|
||||
getAuthenticationMethods,
|
||||
} from '$lib/server/auth/methods';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
moderateCommunityImage,
|
||||
recordCommunityModerationScan
|
||||
moderateCommunityImage,
|
||||
recordCommunityModerationScan,
|
||||
} from '$lib/server/community-moderation';
|
||||
import { saveProfileBio, updateProfileVisibility } from '$lib/server/profile-settings';
|
||||
import {
|
||||
saveProfileBio,
|
||||
updateProfileVisibility,
|
||||
} from '$lib/server/profile-settings';
|
||||
import { agentProfileHref, displayNameKey } from '$lib/agents';
|
||||
import {
|
||||
createPublicProfileSlug,
|
||||
displayNameIsAvailable
|
||||
createPublicProfileSlug,
|
||||
displayNameIsAvailable,
|
||||
} from '$lib/server/agent-profiles';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const ownerProfile = await (await database.prepare(
|
||||
'SELECT public_slug FROM user_profiles WHERE user_id=?'
|
||||
)).get(user.id);
|
||||
const profileTarget = new URL(
|
||||
agentProfileHref({
|
||||
id: user.id,
|
||||
publicSlug: ownerProfile?.public_slug ? String(ownerProfile.public_slug) : null
|
||||
}),
|
||||
url.origin
|
||||
);
|
||||
const requestedPanel = url.searchParams.get('panel');
|
||||
if (requestedPanel) profileTarget.searchParams.set('control', requestedPanel);
|
||||
for (const key of ['oauth_error', 'discord_linked', 'email_verified']) {
|
||||
const value = url.searchParams.get(key);
|
||||
if (value) profileTarget.searchParams.set(key, value);
|
||||
}
|
||||
redirect(303, `${profileTarget.pathname}${profileTarget.search}`);
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const ownerProfile = await (
|
||||
await database.prepare(
|
||||
'SELECT public_slug FROM user_profiles WHERE user_id=?',
|
||||
)
|
||||
).get(user.id);
|
||||
const profileTarget = new URL(
|
||||
agentProfileHref({
|
||||
id: user.id,
|
||||
publicSlug: ownerProfile?.public_slug
|
||||
? String(ownerProfile.public_slug)
|
||||
: null,
|
||||
}),
|
||||
url.origin,
|
||||
);
|
||||
const requestedPanel = url.searchParams.get('panel');
|
||||
if (requestedPanel) profileTarget.searchParams.set('control', requestedPanel);
|
||||
for (const key of ['oauth_error', 'discord_linked', 'email_verified']) {
|
||||
const value = url.searchParams.get(key);
|
||||
if (value) profileTarget.searchParams.set(key, value);
|
||||
}
|
||||
redirect(303, `${profileTarget.pathname}${profileTarget.search}`);
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveBio: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const form = await request.formData();
|
||||
const result = await saveProfileBio(user, form.get('bio'));
|
||||
if (!result.ok) {
|
||||
return fail(400, {
|
||||
bioError: result.error,
|
||||
bioResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
return {
|
||||
bioSaved: true,
|
||||
bioReviewQueued: result.reviewQueued,
|
||||
bioMarkdown: result.bio,
|
||||
bioResultToken: randomUUID()
|
||||
};
|
||||
},
|
||||
setProfileVisibility: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const result = await updateProfileVisibility(
|
||||
user,
|
||||
(await request.formData()).get('isPublic')
|
||||
);
|
||||
if (!result.ok) {
|
||||
return fail(400, {
|
||||
profileVisibilityError: result.error,
|
||||
profileVisibilityResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
return {
|
||||
profileVisibilitySaved: true,
|
||||
profileIsPublic: result.isPublic,
|
||||
profileVisibilityResultToken: randomUUID()
|
||||
};
|
||||
},
|
||||
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);
|
||||
const moderation = await moderateCommunityImage({ contentType: 'avatar', image: output });
|
||||
const database = await getDatabase();
|
||||
const queueReview = moderation.decision !== 'approved';
|
||||
const { uploadedAt, reviewId } = await saveUploadedAvatar(database, user.id, output, { queueReview });
|
||||
await recordCommunityModerationScan({
|
||||
contentType: 'avatar',
|
||||
contentId: user.id,
|
||||
authorId: user.id,
|
||||
result: moderation
|
||||
});
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.submitted',
|
||||
entityId: user.id,
|
||||
after: {
|
||||
status: queueReview ? 'pending_review' : 'automatically_approved',
|
||||
uploadedAt,
|
||||
byteSize: output.length,
|
||||
reviewId
|
||||
}
|
||||
});
|
||||
return { avatarChanged: true, avatarReviewQueued: queueReview };
|
||||
} 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();
|
||||
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
|
||||
return fail(409, { displayNameError: 'That display name is already in use.' });
|
||||
}
|
||||
const publicSlug = await createPublicProfileSlug(database, displayName, user.id);
|
||||
await (await database.prepare(`INSERT INTO user_profiles
|
||||
saveBio: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const form = await request.formData();
|
||||
const result = await saveProfileBio(user, form.get('bio'));
|
||||
if (!result.ok) {
|
||||
return fail(400, {
|
||||
bioError: result.error,
|
||||
bioResultToken: randomUUID(),
|
||||
});
|
||||
}
|
||||
return {
|
||||
bioSaved: true,
|
||||
bioReviewQueued: result.reviewQueued,
|
||||
bioMarkdown: result.bio,
|
||||
bioResultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
setProfileVisibility: async ({ request, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const result = await updateProfileVisibility(
|
||||
user,
|
||||
(await request.formData()).get('isPublic'),
|
||||
);
|
||||
if (!result.ok) {
|
||||
return fail(400, {
|
||||
profileVisibilityError: result.error,
|
||||
profileVisibilityResultToken: randomUUID(),
|
||||
});
|
||||
}
|
||||
return {
|
||||
profileVisibilitySaved: true,
|
||||
profileIsPublic: result.isPublic,
|
||||
profileVisibilityResultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
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);
|
||||
const moderation = await moderateCommunityImage({
|
||||
contentType: 'avatar',
|
||||
image: output,
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const queueReview = moderation.decision !== 'approved';
|
||||
const { uploadedAt, reviewId } = await saveUploadedAvatar(
|
||||
database,
|
||||
user.id,
|
||||
output,
|
||||
{ queueReview },
|
||||
);
|
||||
await recordCommunityModerationScan({
|
||||
contentType: 'avatar',
|
||||
contentId: user.id,
|
||||
authorId: user.id,
|
||||
result: moderation,
|
||||
});
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.submitted',
|
||||
entityId: user.id,
|
||||
after: {
|
||||
status: queueReview ? 'pending_review' : 'automatically_approved',
|
||||
uploadedAt,
|
||||
byteSize: output.length,
|
||||
reviewId,
|
||||
},
|
||||
});
|
||||
return { avatarChanged: true, avatarReviewQueued: queueReview };
|
||||
} 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();
|
||||
if (!(await displayNameIsAvailable(database, displayName, user.id))) {
|
||||
return fail(409, {
|
||||
displayNameError: 'That display name is already in use.',
|
||||
});
|
||||
}
|
||||
const publicSlug = await createPublicProfileSlug(
|
||||
database,
|
||||
displayName,
|
||||
user.id,
|
||||
);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_profiles
|
||||
(user_id,display_name,display_name_key,public_slug,updated_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
display_name_key=excluded.display_name_key,
|
||||
public_slug=excluded.public_slug,
|
||||
updated_at=excluded.updated_at`))
|
||||
.run(user.id, displayName, displayNameKey(displayName), publicSlug, 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') ?? '');
|
||||
const purgeSecurityData = form.get('purgeSecurityData') === 'on';
|
||||
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', purgeSecurityData))) {
|
||||
return fail(409, { deleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
redirect(303, '/?account_deleted=1');
|
||||
},
|
||||
unlinkDiscord: async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const credential = await (await database.prepare('SELECT 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.' });
|
||||
}
|
||||
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 };
|
||||
}
|
||||
updated_at=excluded.updated_at`)
|
||||
).run(user.id, displayName, displayNameKey(displayName), publicSlug, 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') ?? '');
|
||||
const purgeSecurityData = form.get('purgeSecurityData') === 'on';
|
||||
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',
|
||||
purgeSecurityData,
|
||||
))
|
||||
) {
|
||||
return fail(409, {
|
||||
deleteError: 'This account has already been deleted.',
|
||||
});
|
||||
}
|
||||
cookies.delete('shd_session', { path: '/' });
|
||||
redirect(303, '/?account_deleted=1');
|
||||
},
|
||||
unlinkDiscord: async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const credential = await (
|
||||
await database.prepare('SELECT 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.',
|
||||
});
|
||||
}
|
||||
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 };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in?returnTo=%2Faccount%2Farticles');
|
||||
const canManageNews = await hasPermission(user, 'news.manage');
|
||||
const rows = await (
|
||||
await (
|
||||
await getDatabase()
|
||||
)
|
||||
.prepare(`SELECT articles.id,articles.kind,articles.slug,articles.visibility,
|
||||
articles.pending_revision_id,articles.published_revision_id,articles.updated_at,draft.title
|
||||
FROM articles JOIN article_drafts draft ON draft.article_id=articles.id
|
||||
WHERE articles.deleted_at IS NULL AND (articles.owner_id=? ${canManageNews ? "OR articles.kind='news'" : ''})
|
||||
ORDER BY articles.updated_at DESC`)
|
||||
).all(user.id);
|
||||
return { articles: rows, canManageNews };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">let{data}=$props();</script>
|
||||
<svelte:head><title>Your Articles — SHD.build</title></svelte:head>
|
||||
<main class="page account-articles"><header><div><p class="eyebrow"><span></span> AUTHOR WORKSPACE</p><h1>YOUR ARTICLES</h1><p>Return to private drafts, pending reviews, and published Guides or News transmissions.</p></div><div><a class="secondary" href="/guides/new">NEW GUIDE</a>{#if data.canManageNews}<a class="primary" href="/news/new">NEW NEWS ARTICLE</a>{/if}</div></header><section>{#each data.articles as article}<article><div><span>{article.kind.toUpperCase()} · {article.visibility.toUpperCase()}</span><h2>{article.title}</h2><small>{article.pending_revision_id?'REVIEW PENDING':article.published_revision_id?'PUBLISHED REVISION AVAILABLE':'PRIVATE DRAFT'} · UPDATED {new Date(article.updated_at).toLocaleDateString()}</small></div><a class="secondary" href={`/${article.kind==='guide'?'guides':'news'}/${article.slug}/edit`}>CONTINUE EDITING →</a></article>{:else}<div class="catalogue-empty"><span>◇</span><strong>NO ARTICLE DRAFTS</strong><p>Start a Guide when you are ready to share field knowledge.</p></div>{/each}</section></main>
|
||||
<style>.account-articles{padding-top:28px}.account-articles>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:20px;padding:18px 0 20px;border-bottom:1px solid var(--shd-border);background:transparent}.account-articles>header h1,.account-articles>header p{margin:0}.account-articles>header>div:first-child>p:last-child{margin-top:8px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial)}.account-articles>header>div:last-child{display:flex;gap:7px}.account-articles>section{display:grid;margin-top:10px}.account-articles>section>article{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:16px;padding:14px;border:1px solid var(--shd-border);border-top:0;background:var(--shd-surface)}.account-articles>section>article:first-child{border-top:1px solid var(--shd-border)}.account-articles article>div{display:grid;gap:3px}.account-articles article span,.account-articles article small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.account-articles article h2{margin:0}@media(max-width:650px){.account-articles>header{align-items:start;flex-direction:column}.account-articles>header>div:last-child{width:100%;display:grid;grid-template-columns:1fr}.account-articles>section>article{grid-template-columns:1fr}.account-articles>section>article>a{width:100%}}</style>
|
||||
@@ -4,12 +4,19 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in to view this contact message.');
|
||||
const database = await getDatabase();
|
||||
const message = await (await database.prepare(`SELECT contact_messages.* FROM contact_messages
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in to view this contact message.');
|
||||
const database = await getDatabase();
|
||||
const message = await (
|
||||
await database.prepare(`SELECT contact_messages.* FROM contact_messages
|
||||
JOIN contact_message_recipients ON contact_message_recipients.message_id=contact_messages.id
|
||||
WHERE contact_messages.id=? AND contact_message_recipients.user_id=?`)).get(params.id, user.id);
|
||||
if (!message) error(404, 'Contact message not found.');
|
||||
return { message: { ...message, dmca: message.dmca_json ? JSON.parse(String(message.dmca_json)) : null } };
|
||||
WHERE contact_messages.id=? AND contact_message_recipients.user_id=?`)
|
||||
).get(params.id, user.id);
|
||||
if (!message) error(404, 'Contact message not found.');
|
||||
return {
|
||||
message: {
|
||||
...message,
|
||||
dmca: message.dmca_json ? JSON.parse(String(message.dmca_json)) : null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,121 +4,219 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES } from '$lib/server/oversight-notifications';
|
||||
import { notificationOrderBy, parseNotificationSort, parseNotificationState } from '$lib/server/notification-sort';
|
||||
import {
|
||||
OVERSIGHT_NOTIFICATION_CATEGORIES,
|
||||
OVERSIGHT_NOTIFICATION_FREQUENCIES,
|
||||
} from '$lib/server/oversight-notifications';
|
||||
import {
|
||||
notificationOrderBy,
|
||||
parseNotificationSort,
|
||||
parseNotificationState,
|
||||
} from '$lib/server/notification-sort';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const state = parseNotificationState(url.searchParams.get('state'));
|
||||
const sort = parseNotificationSort(url.searchParams.get('sort'));
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = ['n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)'];
|
||||
const args: unknown[] = [user.id];
|
||||
if (state === 'unread') conditions.push('n.read_at IS NULL');
|
||||
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
|
||||
if (search) {
|
||||
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const database = await getDatabase();
|
||||
const state = parseNotificationState(url.searchParams.get('state'));
|
||||
const sort = parseNotificationSort(url.searchParams.get('sort'));
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const conditions = [
|
||||
'n.user_id=?',
|
||||
'(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)',
|
||||
];
|
||||
const args: unknown[] = [user.id];
|
||||
if (state === 'unread') conditions.push('n.read_at IS NULL');
|
||||
if (state === 'read') conditions.push('n.read_at IS NOT NULL');
|
||||
if (search) {
|
||||
conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR n.body LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR n.source LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
||||
args.push(pattern, pattern, pattern);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const orderBy = notificationOrderBy(sort);
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const notifications = await (await database.prepare(`SELECT n.* FROM notifications n
|
||||
args.push(pattern, pattern, pattern);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const orderBy = notificationOrderBy(sort);
|
||||
const total = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`,
|
||||
)
|
||||
).get(...args)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const pagination = getPagination(url, total);
|
||||
const notifications = await (
|
||||
await database.prepare(`SELECT n.* FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const preferenceRows = await (await database.prepare('SELECT * FROM notification_preferences WHERE user_id=?')).all(user.id);
|
||||
const permissions = await getPermissions(user);
|
||||
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
||||
(category) => permissions.includes('*') || permissions.includes(category.permission)
|
||||
);
|
||||
const oversightRows = oversightCategories.length
|
||||
? await (await database.prepare('SELECT * FROM oversight_notification_preferences WHERE user_id=?')).all(user.id)
|
||||
: [];
|
||||
const oversightPreferences = oversightCategories.map((category) => ({
|
||||
...category,
|
||||
frequency: String(oversightRows.find((row) => row.oversight_category === category.id)?.frequency ?? 'immediate')
|
||||
}));
|
||||
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
|
||||
const stored = preferenceRows.find((row) => row.category === name);
|
||||
return {
|
||||
category: name,
|
||||
inApp: name === 'isac' ? true : stored?.in_app_enabled === undefined ? true : Boolean(stored.in_app_enabled),
|
||||
browser: Boolean(stored?.browser_enabled),
|
||||
emailFrequency: String(stored?.email_frequency ?? 'none')
|
||||
};
|
||||
});
|
||||
const unread = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`)
|
||||
).all(...args, pagination.perPage, pagination.offset);
|
||||
const preferenceRows = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM notification_preferences WHERE user_id=?',
|
||||
)
|
||||
).all(user.id);
|
||||
const permissions = await getPermissions(user);
|
||||
const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
||||
(category) =>
|
||||
permissions.includes('*') || permissions.includes(category.permission),
|
||||
);
|
||||
const oversightRows = oversightCategories.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM oversight_notification_preferences WHERE user_id=?',
|
||||
)
|
||||
).all(user.id)
|
||||
: [];
|
||||
const oversightPreferences = oversightCategories.map((category) => ({
|
||||
...category,
|
||||
frequency: String(
|
||||
oversightRows.find((row) => row.oversight_category === category.id)
|
||||
?.frequency ?? 'immediate',
|
||||
),
|
||||
}));
|
||||
const preferences = NOTIFICATION_CATEGORIES.map((name) => {
|
||||
const stored = preferenceRows.find((row) => row.category === name);
|
||||
return {
|
||||
category: name,
|
||||
inApp:
|
||||
name === 'isac'
|
||||
? true
|
||||
: stored?.in_app_enabled === undefined
|
||||
? true
|
||||
: Boolean(stored.in_app_enabled),
|
||||
browser: Boolean(stored?.browser_enabled),
|
||||
emailFrequency: String(stored?.email_frequency ?? 'none'),
|
||||
};
|
||||
});
|
||||
const unread = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
|
||||
return { notifications, preferences, oversightPreferences, pagination, filters: { state, sort, search }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) };
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)
|
||||
).get(user.id)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
return {
|
||||
notifications,
|
||||
preferences,
|
||||
oversightPreferences,
|
||||
pagination,
|
||||
filters: { state, sort, search },
|
||||
unread,
|
||||
hasVerifiedEmail: Boolean(
|
||||
await (
|
||||
await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')
|
||||
).get(user.id),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
markRead: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?')).run(new Date().toISOString(), id, user.id);
|
||||
return { notificationUpdated: true };
|
||||
},
|
||||
markAllRead: async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
await (await (await getDatabase()).prepare('UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL')).run(new Date().toISOString(), user.id);
|
||||
return { notificationsCleared: true };
|
||||
},
|
||||
savePreferences: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const hasEmail = Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id));
|
||||
for (const category of NOTIFICATION_CATEGORIES) {
|
||||
const inApp = category === 'isac' || form.get(`inApp:${category}`) === 'on';
|
||||
const browser = form.get(`browser:${category}`) === 'on';
|
||||
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
|
||||
if (!['none', 'immediate', 'daily'].includes(emailFrequency)) return fail(400, { notificationPreferenceError: 'Choose a valid email frequency.' });
|
||||
if (emailFrequency !== 'none' && !hasEmail) return fail(400, { notificationPreferenceError: 'Add and verify a magic-link email before enabling email notifications.' });
|
||||
await (await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
|
||||
markRead: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare(
|
||||
'UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), id, user.id);
|
||||
return { notificationUpdated: true };
|
||||
},
|
||||
markAllRead: async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare(
|
||||
'UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL',
|
||||
)
|
||||
).run(new Date().toISOString(), user.id);
|
||||
return { notificationsCleared: true };
|
||||
},
|
||||
savePreferences: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const hasEmail = Boolean(
|
||||
await (
|
||||
await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')
|
||||
).get(user.id),
|
||||
);
|
||||
for (const category of NOTIFICATION_CATEGORIES) {
|
||||
const inApp =
|
||||
category === 'isac' || form.get(`inApp:${category}`) === 'on';
|
||||
const browser = form.get(`browser:${category}`) === 'on';
|
||||
const emailFrequency = String(form.get(`email:${category}`) ?? 'none');
|
||||
if (!['none', 'immediate', 'daily'].includes(emailFrequency))
|
||||
return fail(400, {
|
||||
notificationPreferenceError: 'Choose a valid email frequency.',
|
||||
});
|
||||
if (emailFrequency !== 'none' && !hasEmail)
|
||||
return fail(400, {
|
||||
notificationPreferenceError:
|
||||
'Add and verify a magic-link email before enabling email notifications.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET
|
||||
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`))
|
||||
.run(user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now);
|
||||
}
|
||||
return {
|
||||
notificationPreferencesSaved: true,
|
||||
notificationPreferenceSaveToken: crypto.randomUUID()
|
||||
};
|
||||
},
|
||||
saveOversightPreferences: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const available = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
||||
(category) => permissions.includes('*') || permissions.includes(category.permission)
|
||||
);
|
||||
if (!available.length) return fail(403, { oversightPreferenceError: 'Oversight permission is required.' });
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
for (const category of available) {
|
||||
const frequency = String(form.get(`oversight:${category.id}`) ?? 'immediate');
|
||||
if (!OVERSIGHT_NOTIFICATION_FREQUENCIES.includes(frequency as any)) {
|
||||
return fail(400, { oversightPreferenceError: 'Choose a valid Oversight notification frequency.' });
|
||||
}
|
||||
await (await database.prepare(`INSERT INTO oversight_notification_preferences(user_id,oversight_category,frequency,updated_at)
|
||||
in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`)
|
||||
).run(
|
||||
user.id,
|
||||
category,
|
||||
inApp ? 1 : 0,
|
||||
browser ? 1 : 0,
|
||||
emailFrequency,
|
||||
now,
|
||||
);
|
||||
}
|
||||
return {
|
||||
notificationPreferencesSaved: true,
|
||||
notificationPreferenceSaveToken: crypto.randomUUID(),
|
||||
};
|
||||
},
|
||||
saveOversightPreferences: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const available = OVERSIGHT_NOTIFICATION_CATEGORIES.filter(
|
||||
(category) =>
|
||||
permissions.includes('*') || permissions.includes(category.permission),
|
||||
);
|
||||
if (!available.length)
|
||||
return fail(403, {
|
||||
oversightPreferenceError: 'Oversight permission is required.',
|
||||
});
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
for (const category of available) {
|
||||
const frequency = String(
|
||||
form.get(`oversight:${category.id}`) ?? 'immediate',
|
||||
);
|
||||
if (!OVERSIGHT_NOTIFICATION_FREQUENCIES.includes(frequency as any)) {
|
||||
return fail(400, {
|
||||
oversightPreferenceError:
|
||||
'Choose a valid Oversight notification frequency.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare(`INSERT INTO oversight_notification_preferences(user_id,oversight_category,frequency,updated_at)
|
||||
VALUES(?,?,?,?) ON CONFLICT(user_id,oversight_category) DO UPDATE SET
|
||||
frequency=excluded.frequency,updated_at=excluded.updated_at`)).run(user.id, category.id, frequency, now);
|
||||
}
|
||||
return {
|
||||
oversightPreferencesSaved: true,
|
||||
notificationPreferenceSaveToken: crypto.randomUUID()
|
||||
};
|
||||
}
|
||||
frequency=excluded.frequency,updated_at=excluded.updated_at`)
|
||||
).run(user.id, category.id, frequency, now);
|
||||
}
|
||||
return {
|
||||
oversightPreferencesSaved: true,
|
||||
notificationPreferenceSaveToken: crypto.randomUUID(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,6 @@
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
type AuthoredComment = {
|
||||
@@ -45,6 +44,7 @@
|
||||
parentId: string | null;
|
||||
status: 'visible' | 'removed';
|
||||
bodyHtml: string;
|
||||
removalReason: string | null;
|
||||
createdAt: string;
|
||||
author: { id: string; displayName: string; publicSlug: string | null; avatarUrl: string; roles: string[] };
|
||||
canDelete: boolean;
|
||||
@@ -53,6 +53,7 @@
|
||||
hasReplies: boolean;
|
||||
rootId: string;
|
||||
isFollowing: boolean;
|
||||
isPinned: boolean;
|
||||
};
|
||||
type PageData = {
|
||||
agent: PublicAgent;
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.agent.displayName} — Agent Profile — SHD.build</title>
|
||||
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, guides, and community activity on SHD.build.`}/>
|
||||
<meta name="description" content={`View ${data.agent.displayName}'s public Agent profile, builds, Guides, and community activity on SHD.build.`}/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="page public-agent-page">
|
||||
@@ -172,7 +173,7 @@
|
||||
<input type="hidden" name="isPublic" value={data.agent.isPublic ? '0' : '1'}/>
|
||||
<button class="visibility-toggle" class:enabled={data.agent.isPublic} role="switch" aria-checked={data.agent.isPublic} aria-label={`Public profile ${data.agent.isPublic ? 'enabled' : 'hidden'}. Toggle visibility.`}><span aria-hidden="true"></span><b>{data.agent.isPublic ? 'PUBLIC' : 'HIDDEN'}</b></button>
|
||||
</PendingForm>
|
||||
<InfoPopover label="About profile visibility" title="PROFILE VISIBILITY" text="A hidden profile leaves your name and avatar on public builds, guides, and comments, but removes the profile from discovery and blocks new profile messages."/>
|
||||
<InfoPopover label="About profile visibility" title="PROFILE VISIBILITY" text="A hidden profile leaves your name and avatar on public Builds, Guides, and comments, but removes the profile from discovery and blocks new profile messages."/>
|
||||
<a class="mobile-account-jump" href="#account-control">ACCOUNT CONTROL ↓</a>
|
||||
{:else if data.viewer.canReportProfile}
|
||||
<ContentReport contentType="avatar" contentId={data.agent.id} label="REPORT AVATAR" result={form}/>
|
||||
@@ -229,16 +230,15 @@
|
||||
<header><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>PUBLIC BUILDS</h2></div><span>{data.counts.builds} TOTAL</span></header>
|
||||
<div class="public-content-grid">
|
||||
{#each data.builds as build}
|
||||
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>{build.summary || 'Open this loadout to inspect its equipment, Attributes, and calculated performance.'}</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
|
||||
<a class="public-content-card" href={`/builds/${encodeURIComponent(build.slug)}`}><span>LOADOUT</span><h3>{build.title}</h3><p>Open this loadout to inspect its equipment, Attributes, and calculated performance.</p><footer><small>UPDATED {dateLabel(build.updatedAt)}</small><b>VIEW BUILD →</b></footer></a>
|
||||
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLIC BUILDS</strong><p>This Agent has not shared a public loadout.</p></div>{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section class="profile-section">
|
||||
<header><div><p class="eyebrow">FIELD GUIDANCE</p><h2>PUBLISHED GUIDES</h2></div><span>{data.counts.guides} TOTAL</span></header>
|
||||
<div class="public-content-grid">
|
||||
{#each data.guides as guide}
|
||||
<a class="public-content-card guide-card" href={`/builds/${encodeURIComponent(guide.slug)}`}><span>GUIDE</span><h3>{guide.title}</h3><p>{guide.summary || 'Open this guide for the complete strategy and field notes.'}</p><footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer></a>
|
||||
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLISHED GUIDES</strong><p>This Agent has not published a guide.</p></div>{/each}
|
||||
{#each data.guides as guide}<a class="public-content-card" href={`/guides/${encodeURIComponent(guide.slug)}`}><span>GUIDE</span><h3>{guide.title}</h3><p>Open this Guide for the complete strategy, embedded Builds, and field notes.</p><footer><small>UPDATED {dateLabel(guide.updatedAt)}</small><b>READ GUIDE →</b></footer></a>
|
||||
{:else}<div class="public-empty"><span>◇</span><strong>NO PUBLISHED GUIDES</strong><p>This Agent has not published a Guide.</p></div>{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -281,6 +281,7 @@
|
||||
{#if comment.status === 'visible'}
|
||||
<header>
|
||||
<AgentIdentityLink agent={comment.author} roles={comment.author.roles} meta={dateLabel(comment.createdAt)} />
|
||||
{#if comment.isPinned}<b class="pinned-comment-label">PINNED COMMENT</b>{/if}
|
||||
</header>
|
||||
<MarkdownContent html={comment.bodyHtml} compact />
|
||||
<div class="comment-actions">
|
||||
@@ -301,7 +302,14 @@
|
||||
<input type="hidden" name="intent" value={comment.isFollowing ? 'unfollow' : 'follow'}/>
|
||||
<button>{comment.isFollowing ? 'UNFOLLOW THREAD' : 'FOLLOW THREAD'}</button>
|
||||
</PendingForm>
|
||||
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain menuItem/>{/if}
|
||||
{#if comment.canReport}<ContentReport contentType="comment" contentId={comment.id} result={form} plain menuItem/>{/if}
|
||||
{#if data.viewer.isSelf && comment.depth === 0}
|
||||
<PendingForm action="?/setPinnedProfileComment">
|
||||
<input type="hidden" name="commentId" value={comment.id}/>
|
||||
<input type="hidden" name="intent" value={comment.isPinned ? 'unpin' : 'pin'}/>
|
||||
<button>{comment.isPinned ? 'UNPIN COMMENT' : 'PIN COMMENT'}</button>
|
||||
</PendingForm>
|
||||
{/if}
|
||||
{#if comment.canDelete}
|
||||
<button type="button" class="comment-remove danger" onclick={() => { deletingCommentId = comment.id; close(); }}>REMOVE MESSAGE</button>
|
||||
{/if}
|
||||
@@ -311,7 +319,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="removed-message">
|
||||
<strong>MESSAGE REMOVED</strong>
|
||||
<strong>{comment.removalReason === 'Removed by Community Oversight' ? 'MESSAGE REMOVED BY COMMUNITY OVERSIGHT' : 'MESSAGE REMOVED BY POSTER'}</strong>
|
||||
<span>Replies remain visible to preserve the conversation.</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -322,7 +330,7 @@
|
||||
{/if}
|
||||
{#if deletingCommentId === comment.id}
|
||||
<div class="comment-delete-confirm" role="alert">
|
||||
<p>Remove this profile message?</p>
|
||||
<p>Remove this message? It remains as a labeled placeholder only when replies depend on it.</p>
|
||||
<div>
|
||||
<button type="button" class="secondary" onclick={() => deletingCommentId = ''}>CANCEL</button>
|
||||
<PendingForm action="?/deleteProfileComment">
|
||||
@@ -366,7 +374,7 @@
|
||||
{:else}
|
||||
<div class="public-empty">
|
||||
<span>◇</span><strong>NO PUBLIC DISCUSSION</strong>
|
||||
<p>Comments this Agent leaves on builds and guides will appear here.</p>
|
||||
<p>Comments this Agent leaves on builds will appear here.</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -383,12 +391,13 @@
|
||||
.agent-profile-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(360px,.42fr);align-items:start;gap:10px;margin-top:10px}.agent-main-column,.agent-primary,.agent-activity{display:grid;gap:10px;min-width:0}.agent-main-column{grid-column:1}.agent-activity{position:sticky;top:88px;grid-column:2}.agent-activity .public-empty{min-height:110px}#account-control{scroll-margin-top:88px}.agent-published-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.agent-published-grid .public-content-grid{grid-template-columns:1fr}
|
||||
.profile-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}.profile-section>header{min-height:54px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 12px;border-bottom:1px solid var(--shd-border);background:var(--shd-surface-raised)}.profile-section>header>div{display:grid;gap:2px}.profile-section>header :is(h1,h2,p){margin:0}.profile-section>header h1,.profile-section>header h2{font-size:var(--shd-type-body);letter-spacing:.4px}.profile-section>header>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.bio-content{min-height:110px;padding:18px}:global(form.profile-bio-form){display:grid;gap:10px;padding:16px}.profile-bio-actions{display:flex;justify-content:flex-end;gap:7px}.profile-bio-actions button{min-height:40px}.profile-owner-notice{display:grid;gap:4px;margin:12px 16px 0;padding:10px;border:1px solid #745b2b;background:#1c170e;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.profile-owner-notice strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}.profile-owner-notice.error{border-color:#70423b;background:#211414}.profile-owner-notice.error strong{color:var(--shd-danger)}.owner-profile-result{margin:10px 16px 0}.public-content-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;padding:7px}
|
||||
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card.guide-card{border-left-color:var(--shd-info)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.guide-card>span{color:var(--shd-info)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
|
||||
.public-content-card{min-width:0;display:grid;grid-template-rows:auto auto minmax(48px,1fr) auto;gap:6px;padding:12px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:#0d1011;color:var(--shd-text)}.public-content-card>span,.public-content-card footer{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.public-content-card>span{color:var(--shd-orange)}.public-content-card h3,.public-content-card p{margin:0}.public-content-card h3{font-size:1.05rem}.public-content-card p{color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting);line-height:1.45}.public-content-card footer{display:flex;justify-content:space-between;gap:8px;padding-top:8px;border-top:1px solid var(--shd-border-soft)}.public-content-card small{color:var(--shd-text-dim)}.public-content-card b{color:var(--shd-orange)}.public-content-card:hover,.public-content-card:focus-visible{border-color:var(--shd-orange);background:var(--shd-surface-active)}
|
||||
.public-empty{grid-column:1/-1;min-height:150px;display:grid;place-content:center;gap:6px;padding:20px;text-align:center}.public-empty span{color:var(--shd-orange);font-size:28px}.public-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:1px}.public-empty p{margin:0;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-supporting)}.public-empty.compact{min-height:110px}
|
||||
.authored-comment-list{display:grid;gap:7px;padding:7px}.authored-comment-list>article{display:grid;gap:10px;padding:11px;border:1px solid var(--shd-border);background:#0d1011}.authored-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:10px;padding:0;border:0;background:transparent}.authored-comment-list article>header>div{min-width:0;display:grid;align-content:center;gap:2px}.authored-comment-list article header span,.authored-comment-list article header small,.authored-comment-list article>a{font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.authored-comment-list article header span,.authored-comment-list article>a{color:var(--shd-orange)}.authored-comment-list article header small{align-self:center;color:var(--shd-text-muted);text-align:right;white-space:nowrap}.authored-comment-list article>a{justify-self:end}
|
||||
.profile-comms{margin-top:16px;padding:0;background:transparent}.profile-comms-heading{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:16px;padding:0 0 12px;border:0;border-bottom:1px solid var(--shd-border);background:transparent}.profile-comms-heading>div{display:grid;gap:3px}.profile-comms-heading :is(h2,p){margin:0}.profile-comms-heading h2{font-size:1.25rem}.profile-comms-heading>span{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.profile-comment-start{width:min(920px,100%);padding-top:16px}.profile-comment-trigger{width:100%;min-height:48px;padding:0 14px;border:1px solid var(--shd-border-strong);background:transparent;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:var(--shd-type-body);text-align:left;cursor:pointer}.profile-comment-trigger:hover,.profile-comment-trigger:focus-visible,.profile-comment-trigger[aria-expanded=true]{border-color:var(--shd-orange);color:var(--shd-text);background:var(--shd-surface-active)}:global(form.profile-comment-form){width:min(920px,100%);display:grid;gap:9px;padding:14px 0 0;border:0;background:transparent}.profile-comment-form-actions{display:flex;justify-content:flex-end;gap:7px}.profile-comment-form-actions button{min-height:38px}.profile-comment-list{width:min(980px,100%);display:grid;margin-top:26px;overflow:visible}.profile-comment-list>article{--thread-depth:0;position:relative;display:grid;gap:8px;margin:18px 0 0 calc(var(--thread-depth) * 22px);padding:0;scroll-margin-top:96px;background:transparent}.profile-comment-list>article:target{outline:1px solid var(--shd-orange);outline-offset:8px}.profile-comment-list>article.reply{padding-left:14px;border-left:1px solid var(--shd-border-strong)}.profile-comment-list article>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:center;justify-content:flex-start;gap:12px;padding:0;border:0;background:transparent}.profile-comment-list article>header :global(.agent-identity){flex:0 1 auto}.profile-comment-list article :global(.markdown-content){max-width:75ch;margin-left:47px}.comment-actions{display:flex;align-items:center;gap:14px;min-height:32px;margin-left:47px}.comment-reply{min-height:32px;padding:0;border:0;background:transparent;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px;cursor:pointer}.comment-reply:hover,.comment-reply:focus-visible,.comment-reply[aria-expanded=true]{color:var(--shd-orange);text-decoration:underline;text-underline-offset:3px}.reply-composer{margin:6px 0 0 47px}.reply-composer :global(form.profile-comment-form){padding-top:0}.removed-message{display:grid;gap:3px;padding:4px 0}.removed-message strong{color:var(--shd-text-dim);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.removed-message span{color:var(--shd-text-dim);font-size:var(--shd-type-supporting);font-style:italic}.comment-delete-confirm{width:min(520px,calc(100% - 47px));display:grid;gap:8px;margin-left:47px;padding:10px;border:1px solid var(--shd-danger);background:#1b1111}.comment-delete-confirm p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-delete-confirm>div{display:flex;justify-content:flex-end;gap:7px}.comment-delete-confirm button{min-height:38px;padding:0 10px}.comment-access-note{width:min(920px,100%);display:grid;gap:7px;padding:16px 0 0}.comment-access-note strong{color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.comment-access-note p{margin:0;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.comment-access-note a{justify-self:start}.profile-comms .public-empty{min-height:100px;place-content:start;padding:24px 0;text-align:left}.profile-comms .public-empty span{display:none}
|
||||
.profile-header-actions{display:flex;align-items:center;gap:7px}.profile-header-actions>span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.bio-edit-trigger{min-height:32px;padding:0 9px;border:1px solid var(--shd-border);background:transparent;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px;cursor:pointer}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-orange);background:var(--shd-surface-active)}.visibility-toggle{min-height:32px;display:flex;align-items:center;gap:7px;padding:0 8px;border:1px solid var(--shd-border-strong);background:#0d1011;color:var(--shd-text-muted);cursor:pointer}.visibility-toggle>span{position:relative;width:26px;height:14px;border:1px solid var(--shd-border-strong);background:#070909}.visibility-toggle>span::after{content:"";position:absolute;top:2px;left:2px;width:8px;height:8px;background:var(--shd-text-dim)}.visibility-toggle.enabled{border-color:#6b5128;color:var(--shd-orange)}.visibility-toggle.enabled>span::after{left:14px;background:var(--shd-orange)}.visibility-toggle b{font-size:var(--shd-type-telemetry);letter-spacing:.7px}.owner-notifications>header>a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.owner-notification-list{display:grid}.owner-notification-list>a{min-width:0;display:grid;gap:5px;padding:9px 10px;border-top:1px solid var(--shd-border);background:#0d1011;color:var(--shd-text-muted)}.owner-notification-list>a:first-child{border-top:0}.owner-notification-list>a.unread{box-shadow:inset 2px 0 var(--shd-orange)}.owner-notification-list>a.isac{box-shadow:inset 2px 0 var(--shd-info)}.owner-notification-list span{min-width:0;display:flex;justify-content:space-between;gap:8px}.owner-notification-list b,.owner-notification-list small{font-size:var(--shd-type-telemetry);letter-spacing:.5px}.owner-notification-list b{overflow:hidden;color:var(--shd-orange);text-overflow:ellipsis;white-space:nowrap}.owner-notification-list small{color:var(--shd-text-dim);white-space:nowrap}.owner-notification-list strong{overflow:hidden;font-size:var(--shd-type-supporting);text-overflow:ellipsis;white-space:nowrap}
|
||||
.profile-comment-list article :global(.markdown-content),.comment-actions{margin-left:53px}.reply-composer{margin-left:53px}.comment-delete-confirm{width:min(520px,calc(100% - 53px));margin-left:53px}
|
||||
.pinned-comment-label{margin-left:auto;color:var(--shd-orange);font-size:var(--shd-type-telemetry);letter-spacing:.7px}
|
||||
@media(max-width:1000px){.agent-profile-grid{grid-template-columns:1fr}.agent-main-column{grid-column:1}.agent-activity{position:static;grid-column:1;grid-template-columns:repeat(2,minmax(0,1fr))}.agent-activity .profile-section:last-child{grid-column:1/-1}.agent-telemetry{grid-template-columns:repeat(2,minmax(0,1fr))}.agent-telemetry>div:nth-child(3){border-left:0}.agent-telemetry>div:nth-child(n+3){border-top:1px solid var(--shd-border)}}
|
||||
@media(max-width:650px){.agent-command{grid-template-columns:1fr;align-items:stretch}.agent-command-actions{width:100%;display:grid;grid-template-columns:minmax(0,1fr) auto}.agent-command-actions :global(.profile-visibility-form),.agent-command-actions .visibility-toggle{width:100%}.agent-command-actions .visibility-toggle{min-height:44px;justify-content:center}.agent-command-actions :global(.content-report){grid-column:1/-1}.agent-command-actions :global(.content-report summary){width:100%;justify-content:center}.mobile-account-jump{grid-column:1/-1;min-height:44px;display:flex;align-items:center;justify-content:center;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.mobile-account-jump:hover,.mobile-account-jump:focus-visible{border-color:var(--shd-orange);color:var(--shd-orange);background:var(--shd-surface-active)}.agent-main-column{display:contents}.agent-primary{grid-column:1;grid-row:1}.agent-activity{grid-column:1;grid-row:2;grid-template-columns:1fr}.profile-comms{grid-column:1;grid-row:3;margin-top:16px}.agent-activity .profile-section:last-child{grid-column:auto}.agent-published-grid{grid-template-columns:1fr}.public-content-grid{grid-template-columns:1fr}.profile-section>header,.profile-comms-heading{align-items:flex-start;flex-direction:column}.agent-bio>header{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.agent-bio .profile-header-actions{width:auto}.bio-edit-trigger{min-height:44px;padding:0 5px;border-color:transparent}.bio-edit-trigger:hover,.bio-edit-trigger:focus-visible,.bio-edit-trigger[aria-expanded=true]{border-color:var(--shd-border-strong)}.profile-bio-actions{display:grid;grid-template-columns:1fr 1fr}.profile-bio-actions button{min-height:44px}.profile-comment-trigger,.profile-comment-form-actions button,.comment-reply,.comment-delete-confirm button{min-height:44px}.profile-comment-form-actions{display:grid;grid-template-columns:1fr 1fr}.profile-comment-list>article{margin-left:calc(var(--thread-depth) * 12px)}.comment-delete-confirm>div{display:grid;grid-template-columns:1fr 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.public-content-card,.profile-comment-trigger,.comment-reply{transition:none}}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import { saveArticleDraft } from '$lib/server/articles';
|
||||
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
if (
|
||||
!isAllowedShdBuildRequestOrigin(request.headers.get('origin'), url.origin)
|
||||
) {
|
||||
return json({ message: 'Request origin is not allowed.' }, { status: 403 });
|
||||
}
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
return json({ message: 'Sign in to save this article.' }, { status: 401 });
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return json({ message: 'Draft request is invalid.' }, { status: 400 });
|
||||
}
|
||||
const result = await saveArticleDraft({
|
||||
articleId: String(body.articleId ?? ''),
|
||||
actorId: user.id,
|
||||
canManageNews: await hasPermission(user, 'news.manage'),
|
||||
title: body.title,
|
||||
deck: body.deck,
|
||||
document: body.document,
|
||||
tagIds: Array.isArray(body.tagIds) ? body.tagIds : [],
|
||||
channel: body.channel,
|
||||
coverImageId: body.coverImageId,
|
||||
coverAlt: body.coverAlt,
|
||||
coverCaption: body.coverCaption,
|
||||
version: Number(body.version),
|
||||
});
|
||||
return json(result.ok ? result : { message: result.message }, {
|
||||
status: result.ok ? 200 : result.status,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import {
|
||||
ManagedImageValidationError,
|
||||
processManagedImage,
|
||||
saveManagedImageFile,
|
||||
} from '$lib/server/managed-images';
|
||||
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
if (
|
||||
!isAllowedShdBuildRequestOrigin(request.headers.get('origin'), url.origin)
|
||||
) {
|
||||
return json({ message: 'Request origin is not allowed.' }, { status: 403 });
|
||||
}
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
return json(
|
||||
{ message: 'Sign in to upload article images.' },
|
||||
{ status: 401 },
|
||||
);
|
||||
const form = await request.formData();
|
||||
const articleId = String(form.get('articleId') ?? '').trim();
|
||||
const file = form.get('image');
|
||||
if (!(file instanceof File) || !file.size)
|
||||
return json({ message: 'Choose an image to upload.' }, { status: 400 });
|
||||
const database = await getDatabase();
|
||||
const article = await (
|
||||
await database.prepare(
|
||||
'SELECT id,kind,owner_id FROM articles WHERE id=? AND deleted_at IS NULL',
|
||||
)
|
||||
).get(articleId);
|
||||
if (
|
||||
!article ||
|
||||
(article.kind === 'guide'
|
||||
? article.owner_id !== user.id
|
||||
: !(await hasPermission(user, 'news.manage')))
|
||||
) {
|
||||
return json({ message: 'Article not found.' }, { status: 404 });
|
||||
}
|
||||
try {
|
||||
const processed = await processManagedImage(file, false, false);
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO managed_images(
|
||||
id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,
|
||||
created_by,created_at,updated_at
|
||||
) VALUES(?,?,'General','["article"]',0,0,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
`Article image · ${id.slice(0, 8)}`,
|
||||
storageKey,
|
||||
file.name,
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
user.id,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO article_media(article_id,image_id,uploaded_by,created_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(articleId, id, user.id, now);
|
||||
return json({ id, width: processed.width, height: processed.height });
|
||||
} catch (cause) {
|
||||
const message =
|
||||
cause instanceof ManagedImageValidationError
|
||||
? cause.message
|
||||
: 'The image could not be uploaded.';
|
||||
return json({ message }, { status: 400 });
|
||||
}
|
||||
};
|
||||
@@ -1,115 +1,115 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
row: { healthy: 1 } as { healthy?: number },
|
||||
err: null as Error | null
|
||||
row: { healthy: 1 } as { healthy?: number },
|
||||
err: null as Error | null,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => {
|
||||
if (state.err) {
|
||||
throw state.err;
|
||||
}
|
||||
getDatabase: vi.fn(async () => {
|
||||
if (state.err) {
|
||||
throw state.err;
|
||||
}
|
||||
|
||||
return {
|
||||
prepare: async () => ({
|
||||
get: async () => state.row
|
||||
})
|
||||
};
|
||||
})
|
||||
return {
|
||||
prepare: async () => ({
|
||||
get: async () => state.row,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
import { GET } from './health/+server';
|
||||
|
||||
describe('GET /api/health', () => {
|
||||
let originalBuildNumber: string | undefined;
|
||||
let originalCommitSha: string | undefined;
|
||||
let originalImage: string | undefined;
|
||||
let originalBuildNumber: string | undefined;
|
||||
let originalCommitSha: string | undefined;
|
||||
let originalImage: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
state.row = { healthy: 1 };
|
||||
state.err = null;
|
||||
originalBuildNumber = process.env.SHD_BUILD_NUMBER;
|
||||
originalCommitSha = process.env.SHD_COMMIT_SHA;
|
||||
originalImage = process.env.SHD_IMAGE;
|
||||
delete process.env.SHD_BUILD_NUMBER;
|
||||
delete process.env.SHD_COMMIT_SHA;
|
||||
delete process.env.SHD_IMAGE;
|
||||
});
|
||||
beforeEach(() => {
|
||||
state.row = { healthy: 1 };
|
||||
state.err = null;
|
||||
originalBuildNumber = process.env.SHD_BUILD_NUMBER;
|
||||
originalCommitSha = process.env.SHD_COMMIT_SHA;
|
||||
originalImage = process.env.SHD_IMAGE;
|
||||
delete process.env.SHD_BUILD_NUMBER;
|
||||
delete process.env.SHD_COMMIT_SHA;
|
||||
delete process.env.SHD_IMAGE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBuildNumber === undefined) {
|
||||
delete process.env.SHD_BUILD_NUMBER;
|
||||
} else {
|
||||
process.env.SHD_BUILD_NUMBER = originalBuildNumber;
|
||||
}
|
||||
afterEach(() => {
|
||||
if (originalBuildNumber === undefined) {
|
||||
delete process.env.SHD_BUILD_NUMBER;
|
||||
} else {
|
||||
process.env.SHD_BUILD_NUMBER = originalBuildNumber;
|
||||
}
|
||||
|
||||
if (originalCommitSha === undefined) {
|
||||
delete process.env.SHD_COMMIT_SHA;
|
||||
} else {
|
||||
process.env.SHD_COMMIT_SHA = originalCommitSha;
|
||||
}
|
||||
if (originalCommitSha === undefined) {
|
||||
delete process.env.SHD_COMMIT_SHA;
|
||||
} else {
|
||||
process.env.SHD_COMMIT_SHA = originalCommitSha;
|
||||
}
|
||||
|
||||
if (originalImage === undefined) {
|
||||
delete process.env.SHD_IMAGE;
|
||||
} else {
|
||||
process.env.SHD_IMAGE = originalImage;
|
||||
}
|
||||
});
|
||||
if (originalImage === undefined) {
|
||||
delete process.env.SHD_IMAGE;
|
||||
} else {
|
||||
process.env.SHD_IMAGE = originalImage;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns healthy status and metadata when checks pass', async () => {
|
||||
process.env.SHD_BUILD_NUMBER = '123';
|
||||
process.env.SHD_COMMIT_SHA = 'abc1234';
|
||||
it('returns healthy status and metadata when checks pass', async () => {
|
||||
process.env.SHD_BUILD_NUMBER = '123';
|
||||
process.env.SHD_COMMIT_SHA = 'abc1234';
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'ok',
|
||||
status_code: 200,
|
||||
ready: true,
|
||||
service: 'app',
|
||||
version: expect.any(String),
|
||||
build_number: '123',
|
||||
build_revision: 'abc1234',
|
||||
checks: {
|
||||
database: {
|
||||
status: 'ok',
|
||||
error: null
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'ok',
|
||||
status_code: 200,
|
||||
ready: true,
|
||||
service: 'app',
|
||||
version: expect.any(String),
|
||||
build_number: '123',
|
||||
build_revision: 'abc1234',
|
||||
checks: {
|
||||
database: {
|
||||
status: 'ok',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns degraded status and 503 when DB check fails', async () => {
|
||||
state.err = new Error('database unavailable');
|
||||
it('returns degraded status and 503 when DB check fails', async () => {
|
||||
state.err = new Error('database unavailable');
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'degraded',
|
||||
status_code: 503,
|
||||
ready: false,
|
||||
checks: {
|
||||
database: {
|
||||
status: 'unhealthy',
|
||||
error: 'database unavailable'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
expect(response.status).toBe(503);
|
||||
expect(payload).toMatchObject({
|
||||
status: 'degraded',
|
||||
status_code: 503,
|
||||
ready: false,
|
||||
checks: {
|
||||
database: {
|
||||
status: 'unhealthy',
|
||||
error: 'database unavailable',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses image tag as revision when commit sha is absent', async () => {
|
||||
process.env.SHD_IMAGE = 'shd-build-production:7';
|
||||
it('uses image tag as revision when commit sha is absent', async () => {
|
||||
process.env.SHD_IMAGE = 'shd-build-production:7';
|
||||
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
const response = await GET();
|
||||
const payload = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.build_revision).toBe('7');
|
||||
expect(payload.build_number).toBe('0');
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(payload.build_revision).toBe('7');
|
||||
expect(payload.build_number).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,63 +3,63 @@ import { json } from '@sveltejs/kit';
|
||||
import { version as appVersion } from '../../../../package.json';
|
||||
|
||||
function configuredBuildNumber() {
|
||||
const raw = process.env.SHD_BUILD_NUMBER?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return '0';
|
||||
return raw;
|
||||
const raw = process.env.SHD_BUILD_NUMBER?.trim();
|
||||
if (!raw || !/^\d+$/.test(raw)) return '0';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function inferredRevision() {
|
||||
const direct = process.env.SHD_COMMIT_SHA?.trim();
|
||||
if (direct) return direct;
|
||||
const direct = process.env.SHD_COMMIT_SHA?.trim();
|
||||
if (direct) return direct;
|
||||
|
||||
const image = process.env.SHD_IMAGE?.trim();
|
||||
if (!image) return null;
|
||||
const image = process.env.SHD_IMAGE?.trim();
|
||||
if (!image) return null;
|
||||
|
||||
const tag = image.includes(':') ? image.split(':').slice(-1)[0] : null;
|
||||
return tag ? tag : null;
|
||||
const tag = image.includes(':') ? image.split(':').slice(-1)[0] : null;
|
||||
return tag ? tag : null;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const startedAt = new Date().toISOString();
|
||||
let databaseStatus: 'ok' | 'unhealthy' = 'ok';
|
||||
let databaseError: string | null = null;
|
||||
const startedAt = new Date().toISOString();
|
||||
let databaseStatus: 'ok' | 'unhealthy' = 'ok';
|
||||
let databaseError: string | null = null;
|
||||
|
||||
try {
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare('SELECT 1 AS healthy');
|
||||
const row = await statement.get();
|
||||
if (row?.healthy !== 1) {
|
||||
databaseStatus = 'unhealthy';
|
||||
databaseError = 'health-check query returned unexpected value';
|
||||
}
|
||||
} catch (err) {
|
||||
databaseStatus = 'unhealthy';
|
||||
databaseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
try {
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare('SELECT 1 AS healthy');
|
||||
const row = await statement.get();
|
||||
if (row?.healthy !== 1) {
|
||||
databaseStatus = 'unhealthy';
|
||||
databaseError = 'health-check query returned unexpected value';
|
||||
}
|
||||
} catch (err) {
|
||||
databaseStatus = 'unhealthy';
|
||||
databaseError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
const ready = databaseStatus === 'ok';
|
||||
const responsePayload = {
|
||||
status: 'ok',
|
||||
service: 'app',
|
||||
status_code: 200,
|
||||
version: appVersion,
|
||||
build_revision: inferredRevision(),
|
||||
build_number: configuredBuildNumber(),
|
||||
ready,
|
||||
uptime_ms: process.uptime() * 1000,
|
||||
timestamp: startedAt,
|
||||
checks: {
|
||||
database: {
|
||||
status: databaseStatus,
|
||||
error: databaseError
|
||||
}
|
||||
}
|
||||
};
|
||||
const ready = databaseStatus === 'ok';
|
||||
const responsePayload = {
|
||||
status: 'ok',
|
||||
service: 'app',
|
||||
status_code: 200,
|
||||
version: appVersion,
|
||||
build_revision: inferredRevision(),
|
||||
build_number: configuredBuildNumber(),
|
||||
ready,
|
||||
uptime_ms: process.uptime() * 1000,
|
||||
timestamp: startedAt,
|
||||
checks: {
|
||||
database: {
|
||||
status: databaseStatus,
|
||||
error: databaseError,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
responsePayload.status = 'degraded';
|
||||
responsePayload.status_code = 503;
|
||||
}
|
||||
if (!ready) {
|
||||
responsePayload.status = 'degraded';
|
||||
responsePayload.status_code = 503;
|
||||
}
|
||||
|
||||
return json(responsePayload, { status: ready ? 200 : 503 });
|
||||
return json(responsePayload, { status: ready ? 200 : 503 });
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
export async function GET() {
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare('SELECT 1 AS healthy');
|
||||
const result = await statement.get();
|
||||
const database = await getDatabase();
|
||||
const statement = await database.prepare('SELECT 1 AS healthy');
|
||||
const result = await statement.get();
|
||||
|
||||
return json({
|
||||
status: result?.healthy === 1 ? 'ok' : 'error',
|
||||
engine: '@tursodatabase/database'
|
||||
});
|
||||
return json({
|
||||
status: result?.healthy === 1 ? 'ok' : 'error',
|
||||
engine: '@tursodatabase/database',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { runScheduledNotificationTasks } from '$lib/server/scheduled-notifications';
|
||||
import { runScheduledTasks } from '$lib/server/scheduled-tasks';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
function tokenMatches(header: string | null) {
|
||||
const configured = env.SCHEDULER_TOKEN;
|
||||
const provided = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
if (!configured || !provided) return false;
|
||||
const expectedHash = createHash('sha256').update(configured).digest();
|
||||
const providedHash = createHash('sha256').update(provided).digest();
|
||||
return timingSafeEqual(expectedHash, providedHash);
|
||||
const configured = env.SCHEDULER_TOKEN;
|
||||
const provided = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
||||
if (!configured || !provided) return false;
|
||||
const expectedHash = createHash('sha256').update(configured).digest();
|
||||
const providedHash = createHash('sha256').update(provided).digest();
|
||||
return timingSafeEqual(expectedHash, providedHash);
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
if (!tokenMatches(request.headers.get('authorization'))) {
|
||||
return json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
return json({ status: 'ok', ...(await runScheduledNotificationTasks()) });
|
||||
if (!tokenMatches(request.headers.get('authorization'))) {
|
||||
return json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
return json({ status: 'ok', ...(await runScheduledTasks()) });
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
AiMechanicConfigurationError,
|
||||
AiMechanicGenerationError
|
||||
AiMechanicConfigurationError,
|
||||
AiMechanicGenerationError,
|
||||
} from '$lib/server/ai/mechanic-authoring-service';
|
||||
import {
|
||||
AiMechanicRateLimitError,
|
||||
reserveAiMechanicGeneration
|
||||
AiMechanicRateLimitError,
|
||||
reserveAiMechanicGeneration,
|
||||
} from '$lib/server/ai/mechanic-authoring-rate-limit';
|
||||
import {
|
||||
AiCalculationRequestError,
|
||||
parseAiCalculationAuthoringRequest
|
||||
AiCalculationRequestError,
|
||||
parseAiCalculationAuthoringRequest,
|
||||
} from '$lib/server/ai/calculation-authoring-schema';
|
||||
import { generateAiCalculationDraft } from '$lib/server/ai/calculation-authoring-service';
|
||||
import { AI_CALCULATION_PROMPT_VERSION } from '$lib/server/ai/calculation-authoring-prompt';
|
||||
@@ -22,91 +22,108 @@ import type { RequestHandler } from './$types';
|
||||
const MAX_REQUEST_BYTES = 350_000;
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'ai.calculation.generate'
|
||||
);
|
||||
const origin = request.headers.get('origin');
|
||||
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
|
||||
return json(
|
||||
{ message: 'Cross-origin calculation authoring requests are not allowed.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
const contentLength = Number(request.headers.get('content-length') ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
|
||||
return json({ message: 'The calculation authoring request is too large.' }, { status: 413 });
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return json({ message: 'The calculation authoring request is too large.' }, { status: 413 });
|
||||
}
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
return json({ message: 'The calculation authoring request must be valid JSON.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiCalculationDraft(authoringRequest, operator.id);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'ai_assistance',
|
||||
action: 'calculation_ai.generated',
|
||||
entityId: authoringRequest.baseRevisionId
|
||||
? `calculation_revision:${authoringRequest.baseRevisionId}`
|
||||
: `calculation_revision_draft:${response.meta.requestId}`,
|
||||
after: {
|
||||
promptVersion: AI_CALCULATION_PROMPT_VERSION,
|
||||
baseRevisionId: authoringRequest.baseRevisionId,
|
||||
responseStatus: response.status,
|
||||
model: response.meta.model,
|
||||
requestId: response.meta.requestId,
|
||||
usage: {
|
||||
inputTokens: response.meta.inputTokens,
|
||||
outputTokens: response.meta.outputTokens,
|
||||
totalTokens: response.meta.totalTokens
|
||||
},
|
||||
clarificationCount: response.status === 'clarification_required'
|
||||
? response.questions.length
|
||||
: 0,
|
||||
capabilityGapCount: response.status === 'capability_gap'
|
||||
? response.gaps.length
|
||||
: 0,
|
||||
draftValidated: response.status === 'draft_ready'
|
||||
}
|
||||
});
|
||||
return json(response);
|
||||
} catch (caught) {
|
||||
if (caught instanceof AiCalculationRequestError) {
|
||||
return json({ message: caught.message }, { status: caught.status });
|
||||
}
|
||||
if (caught instanceof AiMechanicConfigurationError) {
|
||||
return json({ message: caught.message }, { status: 503 });
|
||||
}
|
||||
if (caught instanceof AiMechanicRateLimitError) {
|
||||
return json(
|
||||
{ message: caught.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'retry-after': String(caught.retryAfterSeconds) }
|
||||
}
|
||||
);
|
||||
}
|
||||
if (caught instanceof AiMechanicGenerationError) {
|
||||
return json({ message: caught.message }, { status: 422 });
|
||||
}
|
||||
console.error('Calculation authoring assistant failed.', caught);
|
||||
return json(
|
||||
{ message: 'The AI calculation authoring service is temporarily unavailable.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'ai.calculation.generate',
|
||||
);
|
||||
const origin = request.headers.get('origin');
|
||||
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
|
||||
return json(
|
||||
{
|
||||
message: 'Cross-origin calculation authoring requests are not allowed.',
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const contentLength = Number(request.headers.get('content-length') ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
|
||||
return json(
|
||||
{ message: 'The calculation authoring request is too large.' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return json(
|
||||
{ message: 'The calculation authoring request is too large.' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
return json(
|
||||
{ message: 'The calculation authoring request must be valid JSON.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiCalculationAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiCalculationDraft(
|
||||
authoringRequest,
|
||||
operator.id,
|
||||
);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'ai_assistance',
|
||||
action: 'calculation_ai.generated',
|
||||
entityId: authoringRequest.baseRevisionId
|
||||
? `calculation_revision:${authoringRequest.baseRevisionId}`
|
||||
: `calculation_revision_draft:${response.meta.requestId}`,
|
||||
after: {
|
||||
promptVersion: AI_CALCULATION_PROMPT_VERSION,
|
||||
baseRevisionId: authoringRequest.baseRevisionId,
|
||||
responseStatus: response.status,
|
||||
model: response.meta.model,
|
||||
requestId: response.meta.requestId,
|
||||
usage: {
|
||||
inputTokens: response.meta.inputTokens,
|
||||
outputTokens: response.meta.outputTokens,
|
||||
totalTokens: response.meta.totalTokens,
|
||||
},
|
||||
clarificationCount:
|
||||
response.status === 'clarification_required'
|
||||
? response.questions.length
|
||||
: 0,
|
||||
capabilityGapCount:
|
||||
response.status === 'capability_gap' ? response.gaps.length : 0,
|
||||
draftValidated: response.status === 'draft_ready',
|
||||
},
|
||||
});
|
||||
return json(response);
|
||||
} catch (caught) {
|
||||
if (caught instanceof AiCalculationRequestError) {
|
||||
return json({ message: caught.message }, { status: caught.status });
|
||||
}
|
||||
if (caught instanceof AiMechanicConfigurationError) {
|
||||
return json({ message: caught.message }, { status: 503 });
|
||||
}
|
||||
if (caught instanceof AiMechanicRateLimitError) {
|
||||
return json(
|
||||
{ message: caught.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'retry-after': String(caught.retryAfterSeconds) },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (caught instanceof AiMechanicGenerationError) {
|
||||
return json({ message: caught.message }, { status: 422 });
|
||||
}
|
||||
console.error('Calculation authoring assistant failed.', caught);
|
||||
return json(
|
||||
{
|
||||
message:
|
||||
'The AI calculation authoring service is temporarily unavailable.',
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,18 +2,18 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
AiMechanicConfigurationError,
|
||||
AiMechanicGenerationError,
|
||||
generateAiMechanicDraft
|
||||
AiMechanicConfigurationError,
|
||||
AiMechanicGenerationError,
|
||||
generateAiMechanicDraft,
|
||||
} from '$lib/server/ai/mechanic-authoring-service';
|
||||
import { AI_MECHANIC_PROMPT_VERSION } from '$lib/server/ai/mechanic-authoring-prompt';
|
||||
import {
|
||||
AiMechanicRequestError,
|
||||
parseAiMechanicAuthoringRequest
|
||||
AiMechanicRequestError,
|
||||
parseAiMechanicAuthoringRequest,
|
||||
} from '$lib/server/ai/mechanic-authoring-schema';
|
||||
import {
|
||||
AiMechanicRateLimitError,
|
||||
reserveAiMechanicGeneration
|
||||
AiMechanicRateLimitError,
|
||||
reserveAiMechanicGeneration,
|
||||
} from '$lib/server/ai/mechanic-authoring-rate-limit';
|
||||
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
|
||||
import { json } from '@sveltejs/kit';
|
||||
@@ -22,90 +22,104 @@ import type { RequestHandler } from './$types';
|
||||
const MAX_REQUEST_BYTES = 100_000;
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'ai.mechanic.generate'
|
||||
);
|
||||
const origin = request.headers.get('origin');
|
||||
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
|
||||
return json(
|
||||
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
const contentLength = Number(request.headers.get('content-length') ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
|
||||
return json({ message: 'The mechanic authoring request is too large.' }, { status: 413 });
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return json({ message: 'The mechanic authoring request is too large.' }, { status: 413 });
|
||||
}
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
return json({ message: 'The mechanic authoring request must be valid JSON.' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiMechanicDraft(authoringRequest, operator.id);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'ai_assistance',
|
||||
action: 'mechanic_ai.generated',
|
||||
entityId: `${authoringRequest.context.sourceKind}:${response.meta.requestId}`,
|
||||
after: {
|
||||
sourceKind: authoringRequest.context.sourceKind,
|
||||
promptVersion: AI_MECHANIC_PROMPT_VERSION,
|
||||
responseStatus: response.status,
|
||||
model: response.meta.model,
|
||||
requestId: response.meta.requestId,
|
||||
usage: {
|
||||
inputTokens: response.meta.inputTokens,
|
||||
outputTokens: response.meta.outputTokens,
|
||||
totalTokens: response.meta.totalTokens
|
||||
},
|
||||
variantKeys: authoringRequest.valueColumns.map((column) => column.variantKey),
|
||||
clarificationCount: response.status === 'clarification_required'
|
||||
? response.questions.length
|
||||
: 0,
|
||||
capabilityGapCount: response.status === 'capability_gap'
|
||||
? response.gaps.length
|
||||
: 0,
|
||||
draftValidated: response.status === 'draft_ready'
|
||||
}
|
||||
});
|
||||
return json(response);
|
||||
} catch (caught) {
|
||||
if (caught instanceof AiMechanicRequestError) {
|
||||
return json({ message: caught.message }, { status: caught.status });
|
||||
}
|
||||
if (caught instanceof AiMechanicConfigurationError) {
|
||||
return json({ message: caught.message }, { status: 503 });
|
||||
}
|
||||
if (caught instanceof AiMechanicRateLimitError) {
|
||||
return json(
|
||||
{ message: caught.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'retry-after': String(caught.retryAfterSeconds) }
|
||||
}
|
||||
);
|
||||
}
|
||||
if (caught instanceof AiMechanicGenerationError) {
|
||||
return json({ message: caught.message }, { status: 422 });
|
||||
}
|
||||
console.error('Mechanic authoring assistant failed.', caught);
|
||||
return json(
|
||||
{ message: 'The AI mechanic authoring service is temporarily unavailable.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'ai.mechanic.generate',
|
||||
);
|
||||
const origin = request.headers.get('origin');
|
||||
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
|
||||
return json(
|
||||
{ message: 'Cross-origin mechanic authoring requests are not allowed.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const contentLength = Number(request.headers.get('content-length') ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
|
||||
return json(
|
||||
{ message: 'The mechanic authoring request is too large.' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return json(
|
||||
{ message: 'The mechanic authoring request is too large.' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
return json(
|
||||
{ message: 'The mechanic authoring request must be valid JSON.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const authoringRequest = parseAiMechanicAuthoringRequest(payload);
|
||||
const release = reserveAiMechanicGeneration(operator.id);
|
||||
let response;
|
||||
try {
|
||||
response = await generateAiMechanicDraft(authoringRequest, operator.id);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'ai_assistance',
|
||||
action: 'mechanic_ai.generated',
|
||||
entityId: `${authoringRequest.context.sourceKind}:${response.meta.requestId}`,
|
||||
after: {
|
||||
sourceKind: authoringRequest.context.sourceKind,
|
||||
promptVersion: AI_MECHANIC_PROMPT_VERSION,
|
||||
responseStatus: response.status,
|
||||
model: response.meta.model,
|
||||
requestId: response.meta.requestId,
|
||||
usage: {
|
||||
inputTokens: response.meta.inputTokens,
|
||||
outputTokens: response.meta.outputTokens,
|
||||
totalTokens: response.meta.totalTokens,
|
||||
},
|
||||
variantKeys: authoringRequest.valueColumns.map(
|
||||
(column) => column.variantKey,
|
||||
),
|
||||
clarificationCount:
|
||||
response.status === 'clarification_required'
|
||||
? response.questions.length
|
||||
: 0,
|
||||
capabilityGapCount:
|
||||
response.status === 'capability_gap' ? response.gaps.length : 0,
|
||||
draftValidated: response.status === 'draft_ready',
|
||||
},
|
||||
});
|
||||
return json(response);
|
||||
} catch (caught) {
|
||||
if (caught instanceof AiMechanicRequestError) {
|
||||
return json({ message: caught.message }, { status: caught.status });
|
||||
}
|
||||
if (caught instanceof AiMechanicConfigurationError) {
|
||||
return json({ message: caught.message }, { status: 503 });
|
||||
}
|
||||
if (caught instanceof AiMechanicRateLimitError) {
|
||||
return json(
|
||||
{ message: caught.message },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'retry-after': String(caught.retryAfterSeconds) },
|
||||
},
|
||||
);
|
||||
}
|
||||
if (caught instanceof AiMechanicGenerationError) {
|
||||
return json({ message: caught.message }, { status: 422 });
|
||||
}
|
||||
console.error('Mechanic authoring assistant failed.', caught);
|
||||
return json(
|
||||
{
|
||||
message:
|
||||
'The AI mechanic authoring service is temporarily unavailable.',
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
MechanicGapReportError,
|
||||
parseMechanicGapReport,
|
||||
submitMechanicGapReport
|
||||
MechanicGapReportError,
|
||||
parseMechanicGapReport,
|
||||
submitMechanicGapReport,
|
||||
} from '$lib/server/ai/mechanic-gap-report';
|
||||
import { isAllowedShdBuildRequestOrigin } from '$lib/server/request-origin';
|
||||
import { json } from '@sveltejs/kit';
|
||||
@@ -12,36 +12,48 @@ import type { RequestHandler } from './$types';
|
||||
const MAX_REQUEST_BYTES = 100_000;
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request, url }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'ai.mechanic.generate'
|
||||
);
|
||||
const origin = request.headers.get('origin');
|
||||
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
|
||||
return json({ message: 'Cross-origin mechanic gap reports are not allowed.' }, { status: 403 });
|
||||
}
|
||||
const contentLength = Number(request.headers.get('content-length') ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
|
||||
return json({ message: 'The mechanic gap report is too large.' }, { status: 413 });
|
||||
}
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return json({ message: 'The mechanic gap report is too large.' }, { status: 413 });
|
||||
}
|
||||
const report = parseMechanicGapReport(JSON.parse(body));
|
||||
return json(await submitMechanicGapReport(operator.id, report));
|
||||
} catch (caught) {
|
||||
if (caught instanceof SyntaxError) {
|
||||
return json({ message: 'The mechanic gap report must be valid JSON.' }, { status: 400 });
|
||||
}
|
||||
if (caught instanceof MechanicGapReportError) {
|
||||
return json({ message: caught.message }, { status: caught.status });
|
||||
}
|
||||
console.error('Mechanic gap report submission failed.', caught);
|
||||
return json(
|
||||
{ message: 'The mechanic gap could not be submitted to the admin team.' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'ai.mechanic.generate',
|
||||
);
|
||||
const origin = request.headers.get('origin');
|
||||
if (!isAllowedShdBuildRequestOrigin(origin, url.origin)) {
|
||||
return json(
|
||||
{ message: 'Cross-origin mechanic gap reports are not allowed.' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const contentLength = Number(request.headers.get('content-length') ?? 0);
|
||||
if (Number.isFinite(contentLength) && contentLength > MAX_REQUEST_BYTES) {
|
||||
return json(
|
||||
{ message: 'The mechanic gap report is too large.' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const body = await request.text();
|
||||
if (Buffer.byteLength(body, 'utf8') > MAX_REQUEST_BYTES) {
|
||||
return json(
|
||||
{ message: 'The mechanic gap report is too large.' },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
const report = parseMechanicGapReport(JSON.parse(body));
|
||||
return json(await submitMechanicGapReport(operator.id, report));
|
||||
} catch (caught) {
|
||||
if (caught instanceof SyntaxError) {
|
||||
return json(
|
||||
{ message: 'The mechanic gap report must be valid JSON.' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (caught instanceof MechanicGapReportError) {
|
||||
return json({ message: caught.message }, { status: caught.status });
|
||||
}
|
||||
console.error('Mechanic gap report submission failed.', caught);
|
||||
return json(
|
||||
{ message: 'The mechanic gap could not be submitted to the admin team.' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,15 +4,23 @@ import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const database = await getDatabase();
|
||||
const rows = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const database = await getDatabase();
|
||||
const rows = await (
|
||||
await database.prepare(`SELECT n.id,n.title,n.body,n.link,n.created_at
|
||||
FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND COALESCE(p.browser_enabled,0)=1
|
||||
ORDER BY n.created_at DESC LIMIT 20`)).all(user.id);
|
||||
const count = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
ORDER BY n.created_at DESC LIMIT 20`)
|
||||
).all(user.id);
|
||||
const count = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM notifications n
|
||||
LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)).get(user.id))?.count ?? 0);
|
||||
return json({ count, notifications: rows });
|
||||
WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`)
|
||||
).get(user.id)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
return json({ count, notifications: rows });
|
||||
};
|
||||
|
||||
@@ -9,50 +9,53 @@ import type { RequestHandler } from './$types';
|
||||
const STATE_COOKIE = 'shd_discord_oauth_state';
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies, url }) => {
|
||||
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
|
||||
error(503, 'Discord sign-in has not been configured yet.');
|
||||
}
|
||||
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
|
||||
error(503, 'Discord sign-in has not been configured yet.');
|
||||
}
|
||||
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
|
||||
const mode = url.searchParams.get('mode') === 'link' ? 'link' : 'signin';
|
||||
const user = mode === 'link' ? await getSessionUser(cookies) : null;
|
||||
const state = randomBytes(32).toString('base64url');
|
||||
const redirectUri =
|
||||
env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
|
||||
const mode = url.searchParams.get('mode') === 'link' ? 'link' : 'signin';
|
||||
const user = mode === 'link' ? await getSessionUser(cookies) : null;
|
||||
|
||||
if (mode === 'link' && !user) {
|
||||
redirect(303, '/sign-in?oauth_error=link_requires_login');
|
||||
}
|
||||
if (mode === 'link' && !user) {
|
||||
redirect(303, '/sign-in?oauth_error=link_requires_login');
|
||||
}
|
||||
|
||||
const database = await getDatabase();
|
||||
const removeExpired = await database.prepare('DELETE FROM oauth_states WHERE expires_at <= ?');
|
||||
await removeExpired.run(new Date().toISOString());
|
||||
const insertState = await database.prepare(
|
||||
'INSERT INTO oauth_states (state_hash, provider, mode, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const now = new Date();
|
||||
await insertState.run(
|
||||
createHash('sha256').update(state).digest('hex'),
|
||||
'discord',
|
||||
mode,
|
||||
user?.id ?? null,
|
||||
new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
|
||||
now.toISOString()
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const removeExpired = await database.prepare(
|
||||
'DELETE FROM oauth_states WHERE expires_at <= ?',
|
||||
);
|
||||
await removeExpired.run(new Date().toISOString());
|
||||
const insertState = await database.prepare(
|
||||
'INSERT INTO oauth_states (state_hash, provider, mode, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
const now = new Date();
|
||||
await insertState.run(
|
||||
createHash('sha256').update(state).digest('hex'),
|
||||
'discord',
|
||||
mode,
|
||||
user?.id ?? null,
|
||||
new Date(now.getTime() + 10 * 60 * 1000).toISOString(),
|
||||
now.toISOString(),
|
||||
);
|
||||
|
||||
cookies.set(STATE_COOKIE, state, {
|
||||
path: '/auth/discord',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: 60 * 10
|
||||
});
|
||||
cookies.set(STATE_COOKIE, state, {
|
||||
path: '/auth/discord',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: !dev,
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
|
||||
const authorizeUrl = new URL('https://discord.com/oauth2/authorize');
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', env.DISCORD_CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authorizeUrl.searchParams.set('scope', 'identify');
|
||||
authorizeUrl.searchParams.set('state', state);
|
||||
authorizeUrl.searchParams.set('prompt', 'consent');
|
||||
const authorizeUrl = new URL('https://discord.com/oauth2/authorize');
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('client_id', env.DISCORD_CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
authorizeUrl.searchParams.set('scope', 'identify');
|
||||
authorizeUrl.searchParams.set('state', state);
|
||||
authorizeUrl.searchParams.set('prompt', 'consent');
|
||||
|
||||
redirect(302, authorizeUrl.toString());
|
||||
redirect(302, authorizeUrl.toString());
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createSession, getSessionUser, recordLogin } from '$lib/server/auth/session';
|
||||
import {
|
||||
createSession,
|
||||
getSessionUser,
|
||||
recordLogin,
|
||||
} from '$lib/server/auth/session';
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
@@ -13,139 +17,204 @@ import { insertUserProfile } from '$lib/server/agent-profiles';
|
||||
const STATE_COOKIE = 'shd_discord_oauth_state';
|
||||
|
||||
type DiscordToken = { access_token: string; token_type: string };
|
||||
type DiscordUser = { id: string; username: string; global_name: string | null; avatar: string | null };
|
||||
type DiscordUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
async function saveDiscordProfile(userId: string, discordUser: DiscordUser): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO discord_profiles(user_id,discord_id,username,display_name,avatar_hash,updated_at)
|
||||
async function saveDiscordProfile(
|
||||
userId: string,
|
||||
discordUser: DiscordUser,
|
||||
): Promise<void> {
|
||||
const database = await getDatabase();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO discord_profiles(user_id,discord_id,username,display_name,avatar_hash,updated_at)
|
||||
VALUES(?,?,?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET
|
||||
discord_id=excluded.discord_id,username=excluded.username,display_name=excluded.display_name,
|
||||
avatar_hash=excluded.avatar_hash,updated_at=excluded.updated_at`))
|
||||
.run(userId, discordUser.id, discordUser.username, discordUser.global_name, discordUser.avatar, new Date().toISOString());
|
||||
avatar_hash=excluded.avatar_hash,updated_at=excluded.updated_at`)
|
||||
).run(
|
||||
userId,
|
||||
discordUser.id,
|
||||
discordUser.username,
|
||||
discordUser.global_name,
|
||||
discordUser.avatar,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
function stateMatches(expected: string | undefined, received: string | null): boolean {
|
||||
if (!expected || !received) return false;
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
const receivedBytes = Buffer.from(received);
|
||||
return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes);
|
||||
function stateMatches(
|
||||
expected: string | undefined,
|
||||
received: string | null,
|
||||
): boolean {
|
||||
if (!expected || !received) return false;
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
const receivedBytes = Buffer.from(received);
|
||||
return (
|
||||
expectedBytes.length === receivedBytes.length &&
|
||||
timingSafeEqual(expectedBytes, receivedBytes)
|
||||
);
|
||||
}
|
||||
|
||||
async function uniqueUsername(): Promise<string> {
|
||||
const database = await getDatabase();
|
||||
const base = `agent_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
|
||||
const find = await database.prepare('SELECT 1 FROM users WHERE username = ? COLLATE NOCASE');
|
||||
const database = await getDatabase();
|
||||
const base = `agent_${randomUUID().replaceAll('-', '').slice(0, 12)}`;
|
||||
const find = await database.prepare(
|
||||
'SELECT 1 FROM users WHERE username = ? COLLATE NOCASE',
|
||||
);
|
||||
|
||||
if (!(await find.get(base))) return base;
|
||||
if (!(await find.get(base))) return base;
|
||||
|
||||
return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`;
|
||||
return `agent_${randomUUID().replaceAll('-', '').slice(0, 20)}`;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ cookies, url, fetch, request, getClientAddress }) => {
|
||||
const expectedState = cookies.get(STATE_COOKIE);
|
||||
cookies.delete(STATE_COOKIE, { path: '/auth/discord' });
|
||||
export const GET: RequestHandler = async ({
|
||||
cookies,
|
||||
url,
|
||||
fetch,
|
||||
request,
|
||||
getClientAddress,
|
||||
}) => {
|
||||
const expectedState = cookies.get(STATE_COOKIE);
|
||||
cookies.delete(STATE_COOKIE, { path: '/auth/discord' });
|
||||
|
||||
if (url.searchParams.has('error')) {
|
||||
redirect(303, '/sign-in?oauth_error=discord_denied');
|
||||
}
|
||||
if (url.searchParams.has('error')) {
|
||||
redirect(303, '/sign-in?oauth_error=discord_denied');
|
||||
}
|
||||
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code || !stateMatches(expectedState, url.searchParams.get('state'))) {
|
||||
redirect(303, '/sign-in?oauth_error=invalid_state');
|
||||
}
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code || !stateMatches(expectedState, url.searchParams.get('state'))) {
|
||||
redirect(303, '/sign-in?oauth_error=invalid_state');
|
||||
}
|
||||
|
||||
const database = await getDatabase();
|
||||
const findState = await database.prepare(`
|
||||
const database = await getDatabase();
|
||||
const findState = await database.prepare(`
|
||||
SELECT mode, user_id FROM oauth_states
|
||||
WHERE state_hash = ? AND provider = 'discord' AND expires_at > ?
|
||||
`);
|
||||
const stateHash = createHash('sha256').update(String(url.searchParams.get('state'))).digest('hex');
|
||||
const oauthState = await findState.get(stateHash, new Date().toISOString());
|
||||
const deleteState = await database.prepare('DELETE FROM oauth_states WHERE state_hash = ?');
|
||||
await deleteState.run(stateHash);
|
||||
if (!oauthState) redirect(303, '/sign-in?oauth_error=invalid_state');
|
||||
const stateHash = createHash('sha256')
|
||||
.update(String(url.searchParams.get('state')))
|
||||
.digest('hex');
|
||||
const oauthState = await findState.get(stateHash, new Date().toISOString());
|
||||
const deleteState = await database.prepare(
|
||||
'DELETE FROM oauth_states WHERE state_hash = ?',
|
||||
);
|
||||
await deleteState.run(stateHash);
|
||||
if (!oauthState) redirect(303, '/sign-in?oauth_error=invalid_state');
|
||||
|
||||
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
|
||||
redirect(303, '/sign-in?oauth_error=not_configured');
|
||||
}
|
||||
if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) {
|
||||
redirect(303, '/sign-in?oauth_error=not_configured');
|
||||
}
|
||||
|
||||
const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
|
||||
const tokenResponse = await fetch('https://discord.com/api/v10/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: env.DISCORD_CLIENT_ID,
|
||||
client_secret: env.DISCORD_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri
|
||||
})
|
||||
});
|
||||
const redirectUri =
|
||||
env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`;
|
||||
const tokenResponse = await fetch(
|
||||
'https://discord.com/api/v10/oauth2/token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: env.DISCORD_CLIENT_ID,
|
||||
client_secret: env.DISCORD_CLIENT_SECRET,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) redirect(303, '/sign-in?oauth_error=token_exchange');
|
||||
const token = (await tokenResponse.json()) as DiscordToken;
|
||||
if (!tokenResponse.ok) redirect(303, '/sign-in?oauth_error=token_exchange');
|
||||
const token = (await tokenResponse.json()) as DiscordToken;
|
||||
|
||||
const userResponse = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
headers: { authorization: `${token.token_type} ${token.access_token}` }
|
||||
});
|
||||
if (!userResponse.ok) redirect(303, '/sign-in?oauth_error=identity');
|
||||
const discordUser = (await userResponse.json()) as DiscordUser;
|
||||
const userResponse = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
headers: { authorization: `${token.token_type} ${token.access_token}` },
|
||||
});
|
||||
if (!userResponse.ok) redirect(303, '/sign-in?oauth_error=identity');
|
||||
const discordUser = (await userResponse.json()) as DiscordUser;
|
||||
|
||||
if (oauthState.mode === 'link') {
|
||||
const sessionUser = await getSessionUser(cookies);
|
||||
if (!sessionUser || sessionUser.id !== String(oauthState.user_id)) {
|
||||
redirect(303, '/account?oauth_error=link_session_changed');
|
||||
}
|
||||
if (oauthState.mode === 'link') {
|
||||
const sessionUser = await getSessionUser(cookies);
|
||||
if (!sessionUser || sessionUser.id !== String(oauthState.user_id)) {
|
||||
redirect(303, '/account?oauth_error=link_session_changed');
|
||||
}
|
||||
|
||||
const ownerStatement = await database.prepare('SELECT id FROM users WHERE discord_id = ?');
|
||||
const owner = await ownerStatement.get(discordUser.id);
|
||||
if (owner && String(owner.id) !== sessionUser.id) {
|
||||
redirect(303, '/account?oauth_error=discord_in_use');
|
||||
}
|
||||
const ownerStatement = await database.prepare(
|
||||
'SELECT id FROM users WHERE discord_id = ?',
|
||||
);
|
||||
const owner = await ownerStatement.get(discordUser.id);
|
||||
if (owner && String(owner.id) !== sessionUser.id) {
|
||||
redirect(303, '/account?oauth_error=discord_in_use');
|
||||
}
|
||||
|
||||
const link = await database.prepare('UPDATE users SET discord_id = ? WHERE id = ?');
|
||||
await link.run(discordUser.id, sessionUser.id);
|
||||
await saveDiscordProfile(sessionUser.id, discordUser);
|
||||
redirect(303, '/account?discord_linked=1');
|
||||
}
|
||||
const link = await database.prepare(
|
||||
'UPDATE users SET discord_id = ? WHERE id = ?',
|
||||
);
|
||||
await link.run(discordUser.id, sessionUser.id);
|
||||
await saveDiscordProfile(sessionUser.id, discordUser);
|
||||
redirect(303, '/account?discord_linked=1');
|
||||
}
|
||||
|
||||
const findUser = await database.prepare('SELECT id FROM users WHERE discord_id = ?');
|
||||
let user = await findUser.get(discordUser.id);
|
||||
let created = false;
|
||||
const findUser = await database.prepare(
|
||||
'SELECT id FROM users WHERE discord_id = ?',
|
||||
);
|
||||
let user = await findUser.get(discordUser.id);
|
||||
let created = false;
|
||||
|
||||
if (!user) {
|
||||
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
|
||||
redirect(303, '/sign-in?oauth_error=registration_closed');
|
||||
}
|
||||
const username = await uniqueUsername();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const insertUser = await database.prepare(
|
||||
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
await insertUser.run(id, username, discordUser.id, 'user', now);
|
||||
await insertUserProfile(
|
||||
database,
|
||||
id,
|
||||
discordUser.global_name || discordUser.username,
|
||||
now,
|
||||
{ resolveCollision: true }
|
||||
);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
user = { id };
|
||||
created = true;
|
||||
}
|
||||
if (!user) {
|
||||
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
|
||||
redirect(303, '/sign-in?oauth_error=registration_closed');
|
||||
}
|
||||
const username = await uniqueUsername();
|
||||
const id = randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const insertUser = await database.prepare(
|
||||
'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
);
|
||||
await insertUser.run(id, username, discordUser.id, 'user', now);
|
||||
await insertUserProfile(
|
||||
database,
|
||||
id,
|
||||
discordUser.global_name || discordUser.username,
|
||||
now,
|
||||
{ resolveCollision: true },
|
||||
);
|
||||
await triggerAutomatedSystemMessages({
|
||||
eventKey: 'account.created',
|
||||
userId: id,
|
||||
eventId: id,
|
||||
}).catch((error) =>
|
||||
console.error('Unable to deliver new-agent welcome message', error),
|
||||
);
|
||||
user = { id };
|
||||
created = true;
|
||||
}
|
||||
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
const status = await (await database.prepare('SELECT blocked_at,banned_until FROM user_status WHERE user_id=?')).get(String(user.id));
|
||||
const banned = typeof status?.banned_until === 'string' && new Date(status.banned_until) > new Date();
|
||||
if (status?.blocked_at || banned) {
|
||||
await recordLogin(String(user.id), 'discord', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: discordUser.id });
|
||||
redirect(303, '/sign-in?oauth_error=account_blocked');
|
||||
}
|
||||
await saveDiscordProfile(String(user.id), discordUser);
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'discord', true, { request, getClientAddress, credentialId: discordUser.id, sessionId });
|
||||
redirect(303, created ? '/account?choose_display_name=1' : '/account');
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
const status = await (
|
||||
await database.prepare(
|
||||
'SELECT blocked_at,banned_until FROM user_status WHERE user_id=?',
|
||||
)
|
||||
).get(String(user.id));
|
||||
const banned =
|
||||
typeof status?.banned_until === 'string' &&
|
||||
new Date(status.banned_until) > new Date();
|
||||
if (status?.blocked_at || banned) {
|
||||
await recordLogin(String(user.id), 'discord', false, {
|
||||
request,
|
||||
getClientAddress,
|
||||
failureReason: 'account_restricted',
|
||||
credentialId: discordUser.id,
|
||||
});
|
||||
redirect(303, '/sign-in?oauth_error=account_blocked');
|
||||
}
|
||||
await saveDiscordProfile(String(user.id), discordUser);
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'discord', true, {
|
||||
request,
|
||||
getClientAddress,
|
||||
credentialId: discordUser.id,
|
||||
sessionId,
|
||||
});
|
||||
redirect(303, created ? '/account?choose_display_name=1' : '/account');
|
||||
};
|
||||
|
||||
@@ -5,30 +5,53 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ url, cookies, request, getClientAddress }) => {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const database = await getDatabase();
|
||||
const link = token ? await consumeMagicLinkToken(database, token) : null;
|
||||
if (!link) redirect(303, '/sign-in?magic_error=invalid');
|
||||
export const GET: RequestHandler = async ({
|
||||
url,
|
||||
cookies,
|
||||
request,
|
||||
getClientAddress,
|
||||
}) => {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
const database = await getDatabase();
|
||||
const link = token ? await consumeMagicLinkToken(database, token) : null;
|
||||
if (!link) redirect(303, '/sign-in?magic_error=invalid');
|
||||
|
||||
const user = await (await database.prepare(`SELECT users.id,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||
const user = await (
|
||||
await database.prepare(`SELECT users.id,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(link.userId);
|
||||
const unavailable = !user || user.deleted_at || user.blocked_at ||
|
||||
(typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date());
|
||||
if (unavailable) {
|
||||
await recordLogin(link.userId, 'magic_link', false, { request, getClientAddress, failureReason: 'account_restricted' });
|
||||
redirect(303, '/sign-in?magic_error=unavailable');
|
||||
}
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)
|
||||
).get(link.userId);
|
||||
const unavailable =
|
||||
!user ||
|
||||
user.deleted_at ||
|
||||
user.blocked_at ||
|
||||
(typeof user.banned_until === 'string' &&
|
||||
new Date(user.banned_until) > new Date());
|
||||
if (unavailable) {
|
||||
await recordLogin(link.userId, 'magic_link', false, {
|
||||
request,
|
||||
getClientAddress,
|
||||
failureReason: 'account_restricted',
|
||||
});
|
||||
redirect(303, '/sign-in?magic_error=unavailable');
|
||||
}
|
||||
|
||||
if (link.purpose === 'verify_email') {
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO user_emails(user_id,email,verified_at,updated_at) VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`))
|
||||
.run(link.userId, link.email, now, now);
|
||||
}
|
||||
await ensureBootstrapAdmin(database, link.userId);
|
||||
const sessionId = await createSession(cookies, link.userId);
|
||||
await recordLogin(link.userId, 'magic_link', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account');
|
||||
if (link.purpose === 'verify_email') {
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_emails(user_id,email,verified_at,updated_at) VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET email=excluded.email,verified_at=excluded.verified_at,updated_at=excluded.updated_at`)
|
||||
).run(link.userId, link.email, now, now);
|
||||
}
|
||||
await ensureBootstrapAdmin(database, link.userId);
|
||||
const sessionId = await createSession(cookies, link.userId);
|
||||
await recordLogin(link.userId, 'magic_link', true, {
|
||||
request,
|
||||
getClientAddress,
|
||||
sessionId,
|
||||
});
|
||||
redirect(
|
||||
303,
|
||||
link.purpose === 'verify_email' ? '/account?email_verified=1' : '/account',
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,13 +5,18 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ url }) => {
|
||||
const database = await getDatabase();
|
||||
const context = passkeyContext(url);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: context.rpID,
|
||||
userVerification: 'required',
|
||||
allowCredentials: []
|
||||
});
|
||||
const challengeId = await storeChallenge(database, 'authentication', options.challenge, context);
|
||||
return json({ options, challengeId });
|
||||
const database = await getDatabase();
|
||||
const context = passkeyContext(url);
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: context.rpID,
|
||||
userVerification: 'required',
|
||||
allowCredentials: [],
|
||||
});
|
||||
const challengeId = await storeChallenge(
|
||||
database,
|
||||
'authentication',
|
||||
options.challenge,
|
||||
context,
|
||||
);
|
||||
return json({ options, challengeId });
|
||||
};
|
||||
|
||||
@@ -2,56 +2,102 @@ import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { consumeChallenge } from '$lib/server/auth/passkeys';
|
||||
import { createSession, recordLogin } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { verifyAuthenticationResponse, type AuthenticationResponseJSON, type AuthenticatorTransportFuture } from '@simplewebauthn/server';
|
||||
import {
|
||||
verifyAuthenticationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type AuthenticatorTransportFuture,
|
||||
} from '@simplewebauthn/server';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
function bytes(value: unknown): Uint8Array<ArrayBuffer> {
|
||||
if (value instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
|
||||
if (Array.isArray(value)) return new Uint8Array(value);
|
||||
throw new Error('Invalid passkey public key.');
|
||||
if (value instanceof Uint8Array) {
|
||||
const copy = new Uint8Array(value.byteLength);
|
||||
copy.set(value);
|
||||
return copy;
|
||||
}
|
||||
if (value instanceof ArrayBuffer) return new Uint8Array(value.slice(0));
|
||||
if (Array.isArray(value)) return new Uint8Array(value);
|
||||
throw new Error('Invalid passkey public key.');
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request, getClientAddress }) => {
|
||||
const body = await request.json() as { challengeId?: string; response?: AuthenticationResponseJSON };
|
||||
if (!body.challengeId || !body.response) error(400, 'Incomplete passkey authentication.');
|
||||
const database = await getDatabase();
|
||||
const challenge = await consumeChallenge(database, body.challengeId, 'authentication');
|
||||
if (!challenge) error(400, 'The passkey request expired. Try again.');
|
||||
const passkey = await (await database.prepare(`SELECT passkeys.*,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||
export const POST: RequestHandler = async ({
|
||||
cookies,
|
||||
request,
|
||||
getClientAddress,
|
||||
}) => {
|
||||
const body = (await request.json()) as {
|
||||
challengeId?: string;
|
||||
response?: AuthenticationResponseJSON;
|
||||
};
|
||||
if (!body.challengeId || !body.response)
|
||||
error(400, 'Incomplete passkey authentication.');
|
||||
const database = await getDatabase();
|
||||
const challenge = await consumeChallenge(
|
||||
database,
|
||||
body.challengeId,
|
||||
'authentication',
|
||||
);
|
||||
if (!challenge) error(400, 'The passkey request expired. Try again.');
|
||||
const passkey = await (
|
||||
await database.prepare(`SELECT passkeys.*,user_status.blocked_at,user_status.banned_until,deleted_accounts.deleted_at
|
||||
FROM passkeys JOIN users ON users.id=passkeys.user_id
|
||||
LEFT JOIN user_status ON user_status.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE passkeys.id=?`)).get(body.response.id);
|
||||
if (!passkey) error(400, 'This passkey is not registered with SHD.build.');
|
||||
const unavailable = passkey.deleted_at || passkey.blocked_at ||
|
||||
(typeof passkey.banned_until === 'string' && new Date(passkey.banned_until) > new Date());
|
||||
if (unavailable) {
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'account_restricted', credentialId: String(passkey.id) });
|
||||
error(403, 'This account is not permitted to sign in.');
|
||||
}
|
||||
const transports = passkey.transports ? JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[] : undefined;
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: String(challenge.challenge),
|
||||
expectedOrigin: String(challenge.origin),
|
||||
expectedRPID: String(challenge.rp_id),
|
||||
credential: { id: String(passkey.id), publicKey: bytes(passkey.public_key), counter: Number(passkey.counter), transports },
|
||||
requireUserVerification: true
|
||||
});
|
||||
if (!verification.verified) {
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false, { request, getClientAddress, failureReason: 'verification_failed', credentialId: String(passkey.id) });
|
||||
error(400, 'Passkey authentication could not be verified.');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare('UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?'))
|
||||
.run(verification.authenticationInfo.newCounter, now, passkey.id);
|
||||
await ensureBootstrapAdmin(database, String(passkey.user_id));
|
||||
const sessionId = await createSession(cookies, String(passkey.user_id));
|
||||
await recordLogin(String(passkey.user_id), 'passkey', true, { request, getClientAddress, credentialId: String(passkey.id), sessionId });
|
||||
return json({ verified: true, redirectTo: '/account' });
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE passkeys.id=?`)
|
||||
).get(body.response.id);
|
||||
if (!passkey) error(400, 'This passkey is not registered with SHD.build.');
|
||||
const unavailable =
|
||||
passkey.deleted_at ||
|
||||
passkey.blocked_at ||
|
||||
(typeof passkey.banned_until === 'string' &&
|
||||
new Date(passkey.banned_until) > new Date());
|
||||
if (unavailable) {
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false, {
|
||||
request,
|
||||
getClientAddress,
|
||||
failureReason: 'account_restricted',
|
||||
credentialId: String(passkey.id),
|
||||
});
|
||||
error(403, 'This account is not permitted to sign in.');
|
||||
}
|
||||
const transports = passkey.transports
|
||||
? (JSON.parse(String(passkey.transports)) as AuthenticatorTransportFuture[])
|
||||
: undefined;
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: String(challenge.challenge),
|
||||
expectedOrigin: String(challenge.origin),
|
||||
expectedRPID: String(challenge.rp_id),
|
||||
credential: {
|
||||
id: String(passkey.id),
|
||||
publicKey: bytes(passkey.public_key),
|
||||
counter: Number(passkey.counter),
|
||||
transports,
|
||||
},
|
||||
requireUserVerification: true,
|
||||
});
|
||||
if (!verification.verified) {
|
||||
await recordLogin(String(passkey.user_id), 'passkey', false, {
|
||||
request,
|
||||
getClientAddress,
|
||||
failureReason: 'verification_failed',
|
||||
credentialId: String(passkey.id),
|
||||
});
|
||||
error(400, 'Passkey authentication could not be verified.');
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE passkeys SET counter=?,last_used_at=? WHERE id=?',
|
||||
)
|
||||
).run(verification.authenticationInfo.newCounter, now, passkey.id);
|
||||
await ensureBootstrapAdmin(database, String(passkey.user_id));
|
||||
const sessionId = await createSession(cookies, String(passkey.user_id));
|
||||
await recordLogin(String(passkey.user_id), 'passkey', true, {
|
||||
request,
|
||||
getClientAddress,
|
||||
credentialId: String(passkey.id),
|
||||
sessionId,
|
||||
});
|
||||
return json({ verified: true, redirectTo: '/account' });
|
||||
};
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { PASSKEY_RP_NAME, passkeyContext, storeChallenge } from '$lib/server/auth/passkeys';
|
||||
import {
|
||||
PASSKEY_RP_NAME,
|
||||
passkeyContext,
|
||||
storeChallenge,
|
||||
} from '$lib/server/auth/passkeys';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { generateRegistrationOptions } from '@simplewebauthn/server';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in before registering a passkey.');
|
||||
const database = await getDatabase();
|
||||
const passkeys = await (await database.prepare('SELECT id,transports FROM passkeys WHERE user_id=?')).all(user.id);
|
||||
const context = passkeyContext(url);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: PASSKEY_RP_NAME,
|
||||
rpID: context.rpID,
|
||||
userID: new TextEncoder().encode(user.id),
|
||||
userName: user.loginUsername ?? user.displayName,
|
||||
userDisplayName: user.displayName,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: passkeys.map((passkey) => ({
|
||||
id: String(passkey.id),
|
||||
transports: passkey.transports ? JSON.parse(String(passkey.transports)) : undefined
|
||||
})),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'required' }
|
||||
});
|
||||
const challengeId = await storeChallenge(database, 'registration', options.challenge, context, user.id);
|
||||
return json({ options, challengeId });
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in before registering a passkey.');
|
||||
const database = await getDatabase();
|
||||
const passkeys = await (
|
||||
await database.prepare('SELECT id,transports FROM passkeys WHERE user_id=?')
|
||||
).all(user.id);
|
||||
const context = passkeyContext(url);
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: PASSKEY_RP_NAME,
|
||||
rpID: context.rpID,
|
||||
userID: new TextEncoder().encode(user.id),
|
||||
userName: user.loginUsername ?? user.displayName,
|
||||
userDisplayName: user.displayName,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: passkeys.map((passkey) => ({
|
||||
id: String(passkey.id),
|
||||
transports: passkey.transports
|
||||
? JSON.parse(String(passkey.transports))
|
||||
: undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'required',
|
||||
userVerification: 'required',
|
||||
},
|
||||
});
|
||||
const challengeId = await storeChallenge(
|
||||
database,
|
||||
'registration',
|
||||
options.challenge,
|
||||
context,
|
||||
user.id,
|
||||
);
|
||||
return json({ options, challengeId });
|
||||
};
|
||||
|
||||
@@ -1,34 +1,61 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { consumeChallenge } from '$lib/server/auth/passkeys';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { verifyRegistrationResponse, type RegistrationResponseJSON } from '@simplewebauthn/server';
|
||||
import {
|
||||
verifyRegistrationResponse,
|
||||
type RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/server';
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in before registering a passkey.');
|
||||
const body = await request.json() as { challengeId?: string; name?: string; response?: RegistrationResponseJSON };
|
||||
if (!body.challengeId || !body.response) error(400, 'Incomplete passkey registration.');
|
||||
const database = await getDatabase();
|
||||
const challenge = await consumeChallenge(database, body.challengeId, 'registration', user.id);
|
||||
if (!challenge) error(400, 'The passkey registration expired. Try again.');
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: String(challenge.challenge),
|
||||
expectedOrigin: String(challenge.origin),
|
||||
expectedRPID: String(challenge.rp_id),
|
||||
requireUserVerification: true
|
||||
});
|
||||
if (!verification.verified || !verification.registrationInfo) error(400, 'Passkey registration could not be verified.');
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
||||
const name = String(body.name || 'My passkey').trim().slice(0, 60) || 'My passkey';
|
||||
await (await database.prepare(`INSERT INTO passkeys(id,user_id,webauthn_user_id,public_key,counter,device_type,backed_up,transports,name,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
credential.id, user.id, Buffer.from(user.id).toString('base64url'),
|
||||
credential.publicKey, credential.counter, credentialDeviceType, credentialBackedUp ? 1 : 0,
|
||||
credential.transports ? JSON.stringify(credential.transports) : null, name, new Date().toISOString()
|
||||
);
|
||||
return json({ verified: true });
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in before registering a passkey.');
|
||||
const body = (await request.json()) as {
|
||||
challengeId?: string;
|
||||
name?: string;
|
||||
response?: RegistrationResponseJSON;
|
||||
};
|
||||
if (!body.challengeId || !body.response)
|
||||
error(400, 'Incomplete passkey registration.');
|
||||
const database = await getDatabase();
|
||||
const challenge = await consumeChallenge(
|
||||
database,
|
||||
body.challengeId,
|
||||
'registration',
|
||||
user.id,
|
||||
);
|
||||
if (!challenge) error(400, 'The passkey registration expired. Try again.');
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: body.response,
|
||||
expectedChallenge: String(challenge.challenge),
|
||||
expectedOrigin: String(challenge.origin),
|
||||
expectedRPID: String(challenge.rp_id),
|
||||
requireUserVerification: true,
|
||||
});
|
||||
if (!verification.verified || !verification.registrationInfo)
|
||||
error(400, 'Passkey registration could not be verified.');
|
||||
const { credential, credentialDeviceType, credentialBackedUp } =
|
||||
verification.registrationInfo;
|
||||
const name =
|
||||
String(body.name || 'My passkey')
|
||||
.trim()
|
||||
.slice(0, 60) || 'My passkey';
|
||||
await (
|
||||
await database.prepare(`INSERT INTO passkeys(id,user_id,webauthn_user_id,public_key,counter,device_type,backed_up,transports,name,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
credential.id,
|
||||
user.id,
|
||||
Buffer.from(user.id).toString('base64url'),
|
||||
credential.publicKey,
|
||||
credential.counter,
|
||||
credentialDeviceType,
|
||||
credentialBackedUp ? 1 : 0,
|
||||
credential.transports ? JSON.stringify(credential.transports) : null,
|
||||
name,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return json({ verified: true });
|
||||
};
|
||||
|
||||
@@ -6,24 +6,31 @@ import { error, redirect } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
|
||||
const database = await getDatabase();
|
||||
const held = await (await database.prepare(`SELECT review.id
|
||||
if (!/^[0-9a-f-]{36}$/i.test(params.id)) error(404, 'Avatar not found');
|
||||
const database = await getDatabase();
|
||||
const held = await (
|
||||
await database.prepare(`SELECT review.id
|
||||
FROM avatar_reviews review
|
||||
JOIN user_avatars avatar
|
||||
ON avatar.user_id=review.user_id AND avatar.updated_at=review.avatar_updated_at
|
||||
WHERE review.user_id=? AND review.status='pending' AND review.review_origin='automated'
|
||||
LIMIT 1`)).get(params.id);
|
||||
if (held && !await hasPermission(await getSessionUser(cookies), 'avatars.review')) {
|
||||
redirect(307, '/avatars/default/field-agent.webp');
|
||||
}
|
||||
const image = await readUploadedAvatar(database, params.id);
|
||||
if (!image) error(404, 'Avatar not found');
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': held ? 'private, no-store' : 'public, max-age=31536000, immutable',
|
||||
'x-content-type-options': 'nosniff'
|
||||
}
|
||||
});
|
||||
LIMIT 1`)
|
||||
).get(params.id);
|
||||
if (
|
||||
held &&
|
||||
!(await hasPermission(await getSessionUser(cookies), 'avatars.review'))
|
||||
) {
|
||||
redirect(307, '/avatars/default/field-agent.webp');
|
||||
}
|
||||
const image = await readUploadedAvatar(database, params.id);
|
||||
if (!image) error(404, 'Avatar not found');
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': held
|
||||
? 'private, no-store'
|
||||
: 'public, max-age=31536000, immutable',
|
||||
'x-content-type-options': 'nosniff',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+608
-260
@@ -4,280 +4,628 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { parseLoadoutDraft } from '$lib/builder/loadout';
|
||||
import { normalizeUserMarkdown } from '$lib/markdown';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { attachWeaponCategoryBonuses } from '$lib/weapons/category-bonuses';
|
||||
|
||||
const validVisibility = new Set(['private', 'unlisted', 'public']);
|
||||
const slugify = (value: string) => value.toLowerCase().normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-').replace(/-+/g, '-').slice(0, 60) || 'loadout';
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.slice(0, 60) || 'loadout';
|
||||
|
||||
function parseLoadout(raw: FormDataEntryValue | null) {
|
||||
if (typeof raw !== 'string' || raw.length > 250_000) return null;
|
||||
try {
|
||||
return parseLoadoutDraft(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof raw !== 'string' || raw.length > 250_000) return null;
|
||||
try {
|
||||
return parseLoadoutDraft(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const requestedSlug = url.searchParams.get('build');
|
||||
let initialBuild = null;
|
||||
if (requestedSlug && viewer) {
|
||||
const row = await (await database.prepare(
|
||||
'SELECT id,title,slug,visibility,summary,guide_body,loadout_json FROM builds WHERE slug=? AND owner_id=?'
|
||||
)).get(requestedSlug, viewer.id);
|
||||
const loadout = typeof row?.loadout_json === 'string' ? parseLoadout(row.loadout_json) : null;
|
||||
if (row && loadout) initialBuild = {
|
||||
id: String(row.id),
|
||||
slug: String(row.slug),
|
||||
visibility: String(row.visibility),
|
||||
summary: String(row.summary ?? ''),
|
||||
guideBody: String(row.guide_body ?? ''),
|
||||
loadout
|
||||
};
|
||||
}
|
||||
const [
|
||||
attributes, gear, gearBrands, gearTalents, gearAugments, gearAugmentCosts, brands, brandEffects,
|
||||
weapons, weaponRanges, weaponTalentOptions, weaponBonuses,
|
||||
talents, talentTiers, talentEffects, talentTemplates, conditionalRules, conditionalPools, conditionalClauses, conditionalOutcomes,
|
||||
chipSets, chipTwoPiece, chipPrimary, chipSecondary, chipTalents, chipTalentEffects,
|
||||
protocols, protocolPrimary, protocolProfiles, protocolSecondary, protocolTalents, protocolTalentEffects, coreRanges,
|
||||
icons, specializations, specializationFocuses, specializationAbilities, specializationAbilityAttacks, focusAbilities,
|
||||
specializationNodes, specializationRanks, specializationRankEffects, specializationRankCosts,
|
||||
specializationCombatEffects, specializationRankCombatEffects, specializationCombatModifiers,
|
||||
progression, rankProfiles, activeCalculationRevision, calculationStages, calculationProfiles, skillCalculationProfiles
|
||||
] = await Promise.all([
|
||||
(await database.prepare('SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_talent_options ORDER BY talent_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_description_templates')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_rules WHERE configured=1')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_secondary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all(),
|
||||
(await database.prepare("SELECT * FROM game_data_icons")).all(),
|
||||
(await database.prepare('SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_ability_attacks')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order')).all(),
|
||||
(await database.prepare('SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order')).all(),
|
||||
(await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
|
||||
(await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all(),
|
||||
(await database.prepare("SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1")).get(),
|
||||
(await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const requestedSlug = url.searchParams.get('build');
|
||||
let initialBuild = null;
|
||||
if (requestedSlug && viewer) {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
'SELECT id,title,slug,visibility,loadout_json FROM builds WHERE slug=? AND owner_id=?',
|
||||
)
|
||||
).get(requestedSlug, viewer.id);
|
||||
const loadout =
|
||||
typeof row?.loadout_json === 'string'
|
||||
? parseLoadout(row.loadout_json)
|
||||
: null;
|
||||
if (row && loadout)
|
||||
initialBuild = {
|
||||
id: String(row.id),
|
||||
slug: String(row.slug),
|
||||
visibility: String(row.visibility),
|
||||
loadout,
|
||||
};
|
||||
}
|
||||
const [
|
||||
attributes,
|
||||
gear,
|
||||
gearBrands,
|
||||
gearTalents,
|
||||
gearAugments,
|
||||
gearAugmentCosts,
|
||||
brands,
|
||||
brandEffects,
|
||||
weapons,
|
||||
weaponRanges,
|
||||
weaponTalentOptions,
|
||||
weaponBonuses,
|
||||
talents,
|
||||
talentTiers,
|
||||
talentEffects,
|
||||
talentTemplates,
|
||||
conditionalRules,
|
||||
conditionalPools,
|
||||
conditionalClauses,
|
||||
conditionalOutcomes,
|
||||
chipSets,
|
||||
chipTwoPiece,
|
||||
chipPrimary,
|
||||
chipSecondary,
|
||||
chipTalents,
|
||||
chipTalentEffects,
|
||||
protocols,
|
||||
protocolPrimary,
|
||||
protocolProfiles,
|
||||
protocolSecondary,
|
||||
protocolTalents,
|
||||
protocolTalentEffects,
|
||||
coreRanges,
|
||||
icons,
|
||||
specializations,
|
||||
specializationFocuses,
|
||||
specializationAbilities,
|
||||
specializationAbilityAttacks,
|
||||
focusAbilities,
|
||||
specializationNodes,
|
||||
specializationRanks,
|
||||
specializationRankEffects,
|
||||
specializationRankCosts,
|
||||
specializationCombatEffects,
|
||||
specializationRankCombatEffects,
|
||||
specializationCombatModifiers,
|
||||
progression,
|
||||
rankProfiles,
|
||||
activeCalculationRevision,
|
||||
calculationStages,
|
||||
calculationProfiles,
|
||||
skillCalculationProfiles,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear WHERE active=1 ORDER BY slot,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_set_effects ORDER BY pieces_required',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapons WHERE active=1 ORDER BY category,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM weapon_tier_ranges ORDER BY tier')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_talent_options ORDER BY talent_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM weapon_category_attribute_bonuses ORDER BY weapon_category,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM talents WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tiers ORDER BY tier')).all(),
|
||||
(await database.prepare('SELECT * FROM talent_tier_effects')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM talent_description_templates')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_rules WHERE configured=1',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_state_pools ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_clauses WHERE active=1 ORDER BY rule_id,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM conditional_effect_outcomes ORDER BY rule_id,variant_key,outcome_key,sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_two_piece_bonuses')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_primary_options')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM skill_chip_secondary_options')
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM skill_chip_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_talent_effects ORDER BY sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocols WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM os_protocol_secondary_profiles')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM os_protocol_talent_effects ORDER BY sort_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
|
||||
)
|
||||
).all(),
|
||||
(await database.prepare('SELECT * FROM game_data_icons')).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specializations WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focuses WHERE active=1 ORDER BY specialization_id,display_order,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_abilities WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM specialization_ability_attacks')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focus_abilities ORDER BY focus_id,ability_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_nodes WHERE active=1 ORDER BY focus_id,row_number,column_number',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_node_ranks ORDER BY node_id,rank',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_costs ORDER BY node_id,rank,resource_id',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_combat_effects WHERE active=1 ORDER BY specialization_id,name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_effects ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_modifiers ORDER BY rank_combat_effect_id,modifier_order',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT * FROM calculation_revisions WHERE status='active' ORDER BY version DESC LIMIT 1",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT stage.* FROM calculation_stages stage
|
||||
JOIN calculation_revisions revision ON revision.id=stage.revision_id
|
||||
WHERE revision.status='active' ORDER BY stage.display_order`)).all(),
|
||||
(await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
|
||||
WHERE revision.status='active' ORDER BY stage.display_order`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all(),
|
||||
(await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT profile.* FROM skill_attribute_calculation_profiles profile
|
||||
JOIN calculation_revisions revision ON revision.id=profile.revision_id
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)).all()
|
||||
]);
|
||||
const iconUrl = (type: 'talent' | 'brand_set' | 'os_protocol' | 'skill_chip' | 'specialization' | 'specialization_ability' | 'specialization_node', id: unknown) => {
|
||||
const icon = icons.find((item) => item.entity_type === type && item.entity_id === id);
|
||||
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
|
||||
};
|
||||
const hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find((item) => item.source_type === sourceType && item.source_id === sourceId);
|
||||
return rule ? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter((pool) => pool.rule_id === rule.id),
|
||||
clauses: conditionalClauses.filter((clause) => clause.rule_id === rule.id),
|
||||
outcomes: conditionalOutcomes.filter((outcome) => outcome.rule_id === rule.id),
|
||||
triggers: []
|
||||
} : null;
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
iconUrl: iconUrl('talent', talent.id),
|
||||
template: talentTemplates.find((item) => item.talent_id === talent.id)?.template ?? '',
|
||||
tiers: talentTiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter((effect) => effect.talent_tier_id === tier.id)
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.id)
|
||||
}));
|
||||
return {
|
||||
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||
initialBuild,
|
||||
attributes,
|
||||
coreRanges,
|
||||
gear: gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: gearBrands.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: gearTalents.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id))
|
||||
})),
|
||||
gearAugments: gearAugments.map((augment) => ({
|
||||
...augment,
|
||||
costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id)
|
||||
})),
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: iconUrl('brand_set', brand.id),
|
||||
effects: brandEffects.filter((effect) => effect.brand_set_id === brand.id)
|
||||
})),
|
||||
weapons: attachWeaponCategoryBonuses(weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
|
||||
talent1Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 1).map((link) => String(link.talent_id)),
|
||||
talent2Ids: weaponTalentOptions.filter((link) => link.weapon_id === weapon.id && Number(link.talent_slot) === 2).map((link) => String(link.talent_id))
|
||||
})), weaponBonuses),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find((item) => item.skill_chip_set_id === set.id);
|
||||
return {
|
||||
...set,
|
||||
iconUrl: iconUrl('skill_chip', set.id),
|
||||
twoPiece: chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ?? null,
|
||||
primaryStatIds: chipPrimary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
|
||||
secondaryStatIds: chipSecondary.filter((item) => item.skill_chip_set_id === set.id).map((item) => String(item.stat_attribute_id)),
|
||||
talent: talent ? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter((effect) => effect.talent_id === talent.id),
|
||||
rule: hydrateRule('skill_chip_talent', talent.id)
|
||||
} : null
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find((item) => item.quality === protocol.quality && item.primary_attribute_type === protocol.primary_attribute_type);
|
||||
const talent = protocolTalents.find((item) => item.protocol_id === protocol.id);
|
||||
return {
|
||||
...protocol,
|
||||
iconUrl: iconUrl('os_protocol', protocol.id),
|
||||
primaryEffect: protocolPrimary.find((item) => item.protocol_id === protocol.id) ?? null,
|
||||
secondaryEffects: profile ? protocolSecondary.filter((effect) => effect.profile_id === profile.id) : [],
|
||||
talent: talent ? {
|
||||
...talent,
|
||||
effects: protocolTalentEffects.filter((effect) => effect.talent_id === talent.id),
|
||||
rule: hydrateRule('os_protocol_talent', talent.id)
|
||||
} : null
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
iconUrl: iconUrl('specialization', specialization.id),
|
||||
focuses: specializationFocuses.filter((focus) => focus.specialization_id === specialization.id).map((focus) => {
|
||||
const assignedAbilities = focusAbilities.filter((link) => link.focus_id === focus.id)
|
||||
.sort((left, right) => Number(left.ability_slot) - Number(right.ability_slot))
|
||||
.map((link) => {
|
||||
const ability = specializationAbilities.find((item) => item.id === link.ability_id);
|
||||
return ability ? {
|
||||
...ability,
|
||||
slot: Number(link.ability_slot),
|
||||
iconUrl: iconUrl('specialization_ability', ability.id),
|
||||
attack: specializationAbilityAttacks.find((attack) => attack.ability_id === ability.id) ?? null
|
||||
} : null;
|
||||
}).filter(Boolean);
|
||||
const nodes = specializationNodes.filter((node) => node.focus_id === focus.id).map((node) => ({
|
||||
...node,
|
||||
iconUrl: iconUrl('specialization_node', node.id),
|
||||
ranks: specializationRanks.filter((rank) => rank.node_id === node.id).map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank)),
|
||||
costs: specializationRankCosts.filter((cost) => cost.node_id === node.id && Number(cost.rank) === Number(rank.rank)),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter((effect) => effect.node_id === node.id && Number(effect.rank) === Number(rank.rank))
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find((definition) => definition.id === effect.combat_effect_id),
|
||||
modifiers: specializationCombatModifiers.filter((modifier) => modifier.rank_combat_effect_id === effect.id),
|
||||
rule: hydrateRule('specialization_tree_rank', effect.id)
|
||||
}))
|
||||
}))
|
||||
}));
|
||||
return { ...focus, abilities: assignedAbilities, nodes };
|
||||
})
|
||||
})),
|
||||
specializationProgression: {
|
||||
...progression,
|
||||
availablePoints: Number(progression?.xp_points ?? 30) + Number(progression?.shd_cache_points ?? 19),
|
||||
rankProfiles
|
||||
},
|
||||
calculation: {
|
||||
revision: activeCalculationRevision,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles,
|
||||
skillProfiles: skillCalculationProfiles
|
||||
}
|
||||
};
|
||||
WHERE revision.status='active' ORDER BY profile.stage_key,profile.stat_attribute_id`)
|
||||
).all(),
|
||||
]);
|
||||
const iconUrl = (
|
||||
type:
|
||||
| 'talent'
|
||||
| 'brand_set'
|
||||
| 'os_protocol'
|
||||
| 'skill_chip'
|
||||
| 'specialization'
|
||||
| 'specialization_ability'
|
||||
| 'specialization_node',
|
||||
id: unknown,
|
||||
) => {
|
||||
const icon = icons.find(
|
||||
(item) => item.entity_type === type && item.entity_id === id,
|
||||
);
|
||||
return icon ? gameDataIconUrl(type, String(id), icon.updated_at) : '';
|
||||
};
|
||||
const hydrateRule = (sourceType: string, sourceId: unknown) => {
|
||||
const rule = conditionalRules.find(
|
||||
(item) => item.source_type === sourceType && item.source_id === sourceId,
|
||||
);
|
||||
return rule
|
||||
? {
|
||||
rule,
|
||||
statePools: conditionalPools.filter(
|
||||
(pool) => pool.rule_id === rule.id,
|
||||
),
|
||||
clauses: conditionalClauses.filter(
|
||||
(clause) => clause.rule_id === rule.id,
|
||||
),
|
||||
outcomes: conditionalOutcomes.filter(
|
||||
(outcome) => outcome.rule_id === rule.id,
|
||||
),
|
||||
triggers: [],
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const configuredTalents = talents.map((talent) => ({
|
||||
...talent,
|
||||
iconUrl: iconUrl('talent', talent.id),
|
||||
template:
|
||||
talentTemplates.find((item) => item.talent_id === talent.id)?.template ??
|
||||
'',
|
||||
tiers: talentTiers
|
||||
.filter((tier) => tier.talent_id === talent.id)
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
effects: talentEffects.filter(
|
||||
(effect) => effect.talent_tier_id === tier.id,
|
||||
),
|
||||
})),
|
||||
rule: hydrateRule('talent', talent.id),
|
||||
}));
|
||||
return {
|
||||
viewer: viewer ? { id: viewer.id, displayName: viewer.displayName } : null,
|
||||
initialBuild,
|
||||
attributes,
|
||||
coreRanges,
|
||||
gear: gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: gearBrands
|
||||
.filter((link) => link.gear_id === item.id)
|
||||
.map((link) => String(link.brand_set_id)),
|
||||
talentIds: gearTalents
|
||||
.filter((link) => link.gear_id === item.id)
|
||||
.map((link) => String(link.talent_id)),
|
||||
})),
|
||||
gearAugments: gearAugments.map((augment) => ({
|
||||
...augment,
|
||||
costs: gearAugmentCosts.filter((cost) => cost.augment_id === augment.id),
|
||||
})),
|
||||
brands: brands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: iconUrl('brand_set', brand.id),
|
||||
effects: brandEffects.filter(
|
||||
(effect) => effect.brand_set_id === brand.id,
|
||||
),
|
||||
})),
|
||||
weapons: attachWeaponCategoryBonuses(
|
||||
weapons.map((weapon) => ({
|
||||
...weapon,
|
||||
ranges: weaponRanges.filter((range) => range.weapon_id === weapon.id),
|
||||
talent1Ids: weaponTalentOptions
|
||||
.filter(
|
||||
(link) =>
|
||||
link.weapon_id === weapon.id && Number(link.talent_slot) === 1,
|
||||
)
|
||||
.map((link) => String(link.talent_id)),
|
||||
talent2Ids: weaponTalentOptions
|
||||
.filter(
|
||||
(link) =>
|
||||
link.weapon_id === weapon.id && Number(link.talent_slot) === 2,
|
||||
)
|
||||
.map((link) => String(link.talent_id)),
|
||||
})),
|
||||
weaponBonuses,
|
||||
),
|
||||
talents: configuredTalents,
|
||||
chipSets: chipSets.map((set) => {
|
||||
const talent = chipTalents.find(
|
||||
(item) => item.skill_chip_set_id === set.id,
|
||||
);
|
||||
return {
|
||||
...set,
|
||||
iconUrl: iconUrl('skill_chip', set.id),
|
||||
twoPiece:
|
||||
chipTwoPiece.find((item) => item.skill_chip_set_id === set.id) ??
|
||||
null,
|
||||
primaryStatIds: chipPrimary
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
secondaryStatIds: chipSecondary
|
||||
.filter((item) => item.skill_chip_set_id === set.id)
|
||||
.map((item) => String(item.stat_attribute_id)),
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: chipTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
rule: hydrateRule('skill_chip_talent', talent.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
protocols: protocols.map((protocol) => {
|
||||
const profile = protocolProfiles.find(
|
||||
(item) =>
|
||||
item.quality === protocol.quality &&
|
||||
item.primary_attribute_type === protocol.primary_attribute_type,
|
||||
);
|
||||
const talent = protocolTalents.find(
|
||||
(item) => item.protocol_id === protocol.id,
|
||||
);
|
||||
return {
|
||||
...protocol,
|
||||
iconUrl: iconUrl('os_protocol', protocol.id),
|
||||
primaryEffect:
|
||||
protocolPrimary.find((item) => item.protocol_id === protocol.id) ??
|
||||
null,
|
||||
secondaryEffects: profile
|
||||
? protocolSecondary.filter(
|
||||
(effect) => effect.profile_id === profile.id,
|
||||
)
|
||||
: [],
|
||||
talent: talent
|
||||
? {
|
||||
...talent,
|
||||
effects: protocolTalentEffects.filter(
|
||||
(effect) => effect.talent_id === talent.id,
|
||||
),
|
||||
rule: hydrateRule('os_protocol_talent', talent.id),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
specializations: specializations.map((specialization) => ({
|
||||
...specialization,
|
||||
iconUrl: iconUrl('specialization', specialization.id),
|
||||
focuses: specializationFocuses
|
||||
.filter((focus) => focus.specialization_id === specialization.id)
|
||||
.map((focus) => {
|
||||
const assignedAbilities = focusAbilities
|
||||
.filter((link) => link.focus_id === focus.id)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
Number(left.ability_slot) - Number(right.ability_slot),
|
||||
)
|
||||
.map((link) => {
|
||||
const ability = specializationAbilities.find(
|
||||
(item) => item.id === link.ability_id,
|
||||
);
|
||||
return ability
|
||||
? {
|
||||
...ability,
|
||||
slot: Number(link.ability_slot),
|
||||
iconUrl: iconUrl('specialization_ability', ability.id),
|
||||
attack:
|
||||
specializationAbilityAttacks.find(
|
||||
(attack) => attack.ability_id === ability.id,
|
||||
) ?? null,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const nodes = specializationNodes
|
||||
.filter((node) => node.focus_id === focus.id)
|
||||
.map((node) => ({
|
||||
...node,
|
||||
iconUrl: iconUrl('specialization_node', node.id),
|
||||
ranks: specializationRanks
|
||||
.filter((rank) => rank.node_id === node.id)
|
||||
.map((rank) => ({
|
||||
...rank,
|
||||
effects: specializationRankEffects.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
),
|
||||
costs: specializationRankCosts.filter(
|
||||
(cost) =>
|
||||
cost.node_id === node.id &&
|
||||
Number(cost.rank) === Number(rank.rank),
|
||||
),
|
||||
combatEffects: specializationRankCombatEffects
|
||||
.filter(
|
||||
(effect) =>
|
||||
effect.node_id === node.id &&
|
||||
Number(effect.rank) === Number(rank.rank),
|
||||
)
|
||||
.map((effect) => ({
|
||||
...effect,
|
||||
definition: specializationCombatEffects.find(
|
||||
(definition) =>
|
||||
definition.id === effect.combat_effect_id,
|
||||
),
|
||||
modifiers: specializationCombatModifiers.filter(
|
||||
(modifier) =>
|
||||
modifier.rank_combat_effect_id === effect.id,
|
||||
),
|
||||
rule: hydrateRule('specialization_tree_rank', effect.id),
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
return { ...focus, abilities: assignedAbilities, nodes };
|
||||
}),
|
||||
})),
|
||||
specializationProgression: {
|
||||
...progression,
|
||||
availablePoints:
|
||||
Number(progression?.xp_points ?? 30) +
|
||||
Number(progression?.shd_cache_points ?? 19),
|
||||
rankProfiles,
|
||||
},
|
||||
calculation: {
|
||||
revision: activeCalculationRevision,
|
||||
stages: calculationStages,
|
||||
profiles: calculationProfiles,
|
||||
skillProfiles: skillCalculationProfiles,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { buildError: 'Sign in to save and share this loadout.' });
|
||||
const formData = await request.formData();
|
||||
const loadout = parseLoadout(formData.get('loadout'));
|
||||
const visibility = String(formData.get('visibility') ?? 'private');
|
||||
const id = String(formData.get('id') ?? '').trim();
|
||||
const summary = normalizeUserMarkdown(formData.get('summary'), 500);
|
||||
const guideBody = normalizeUserMarkdown(formData.get('guideBody'), 12_000);
|
||||
if (!loadout) return fail(400, { buildError: 'The loadout data is invalid. Refresh the builder and try again.' });
|
||||
if (summary === null || guideBody === null) return fail(400, { buildError: 'Build notes contain unsupported characters or exceed their length limit.' });
|
||||
const title = loadout.buildName.trim();
|
||||
if (title.length < 2 || title.length > 100) return fail(400, { buildError: 'Build names must be between 2 and 100 characters.' });
|
||||
if (!validVisibility.has(visibility)) return fail(400, { buildError: 'Choose a valid build visibility.' });
|
||||
save: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
return fail(401, {
|
||||
buildError: 'Sign in to save and share this loadout.',
|
||||
});
|
||||
const formData = await request.formData();
|
||||
const loadout = parseLoadout(formData.get('loadout'));
|
||||
const visibility = String(formData.get('visibility') ?? 'private');
|
||||
const id = String(formData.get('id') ?? '').trim();
|
||||
if (!loadout)
|
||||
return fail(400, {
|
||||
buildError:
|
||||
'The loadout data is invalid. Refresh the builder and try again.',
|
||||
});
|
||||
const title = loadout.buildName.trim();
|
||||
if (title.length < 2 || title.length > 100)
|
||||
return fail(400, {
|
||||
buildError: 'Build names must be between 2 and 100 characters.',
|
||||
});
|
||||
if (!validVisibility.has(visibility))
|
||||
return fail(400, { buildError: 'Choose a valid build visibility.' });
|
||||
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
let buildId = id;
|
||||
let slug = '';
|
||||
if (buildId) {
|
||||
const existing = await (await database.prepare('SELECT id,slug FROM builds WHERE id=? AND owner_id=?')).get(buildId, user.id);
|
||||
if (!existing) return fail(404, { buildError: 'This saved build could not be found.' });
|
||||
slug = String(existing.slug);
|
||||
await (await database.prepare(
|
||||
'UPDATE builds SET title=?,visibility=?,summary=?,guide_body=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?'
|
||||
)).run(title, visibility, summary, guideBody, JSON.stringify(loadout), now, buildId, user.id);
|
||||
} else {
|
||||
buildId = randomUUID();
|
||||
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
|
||||
await (await database.prepare(
|
||||
`INSERT INTO builds(id,owner_id,title,slug,visibility,summary,guide_body,loadout_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`
|
||||
)).run(buildId, user.id, title, slug, visibility, summary, guideBody, JSON.stringify(loadout), now, now);
|
||||
}
|
||||
return {
|
||||
buildSaved: true,
|
||||
buildToken: randomUUID(),
|
||||
savedBuild: { id: buildId, slug, visibility, title }
|
||||
};
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
let buildId = id;
|
||||
let slug = '';
|
||||
if (buildId) {
|
||||
const existing = await (
|
||||
await database.prepare(
|
||||
'SELECT id,slug FROM builds WHERE id=? AND owner_id=?',
|
||||
)
|
||||
).get(buildId, user.id);
|
||||
if (!existing)
|
||||
return fail(404, {
|
||||
buildError: 'This saved build could not be found.',
|
||||
});
|
||||
slug = String(existing.slug);
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE builds SET title=?,visibility=?,loadout_json=?,updated_at=? WHERE id=? AND owner_id=?',
|
||||
)
|
||||
).run(title, visibility, JSON.stringify(loadout), now, buildId, user.id);
|
||||
} else {
|
||||
buildId = randomUUID();
|
||||
slug = `${slugify(title)}-${buildId.slice(0, 8)}`;
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO builds(id,owner_id,title,slug,visibility,loadout_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
buildId,
|
||||
user.id,
|
||||
title,
|
||||
slug,
|
||||
visibility,
|
||||
JSON.stringify(loadout),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
return {
|
||||
buildSaved: true,
|
||||
buildToken: randomUUID(),
|
||||
savedBuild: { id: buildId, slug, visibility, title },
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import MarkdownEditor from '$lib/components/MarkdownEditor.svelte';
|
||||
import CombatAssumptions from '$lib/components/builder/CombatAssumptions.svelte';
|
||||
import ConditionalEffectCard from '$lib/components/builder/ConditionalEffectCard.svelte';
|
||||
import LoadoutMatrix, { type MatrixCell } from '$lib/components/builder/LoadoutMatrix.svelte';
|
||||
@@ -27,9 +26,6 @@
|
||||
let active = $state('specialization:focus');
|
||||
let saved = $state(false);
|
||||
let message = $state('');
|
||||
let buildSummary = $state(untrack(() => data.initialBuild?.summary ?? ''));
|
||||
let buildGuide = $state(untrack(() => data.initialBuild?.guideBody ?? ''));
|
||||
let buildNotesOpen = $state(untrack(() => Boolean(data.initialBuild?.summary || data.initialBuild?.guideBody)));
|
||||
let persistedBuildId = $state(untrack(() => data.initialBuild?.id ?? ''));
|
||||
let persistedSlug = $state(untrack(() => data.initialBuild?.slug ?? ''));
|
||||
let persistedVisibility = $state(untrack(() => data.initialBuild?.visibility ?? 'private'));
|
||||
@@ -501,9 +497,6 @@
|
||||
onMount(() => {
|
||||
if (data.initialBuild) return;
|
||||
localStorage.removeItem('shd-build-loadout-draft-v3');
|
||||
buildSummary = localStorage.getItem('shd-build-summary-v1') ?? '';
|
||||
buildGuide = localStorage.getItem('shd-build-guide-v1') ?? '';
|
||||
buildNotesOpen = Boolean(buildSummary || buildGuide);
|
||||
const stored = localStorage.getItem('shd-build-loadout-draft-v4');
|
||||
if (!stored) return;
|
||||
try {
|
||||
@@ -531,8 +524,6 @@
|
||||
});
|
||||
function saveDraft() {
|
||||
localStorage.setItem('shd-build-loadout-draft-v4', JSON.stringify(draft));
|
||||
localStorage.setItem('shd-build-summary-v1', buildSummary);
|
||||
localStorage.setItem('shd-build-guide-v1', buildGuide);
|
||||
saved = true;
|
||||
toast.success('Draft saved locally', 'This loadout is available on this device.');
|
||||
}
|
||||
@@ -545,15 +536,12 @@
|
||||
<div><p class="eyebrow"><span></span> COMMAND GRID / LOADOUT 01</p><input id="builder-build-name" name="builder-build-name" bind:value={draft.buildName} aria-label="Build name" maxlength="100"/></div>
|
||||
<div class="actions">
|
||||
<span class:visible={saved}>SAVED LOCALLY</span>
|
||||
<button class="secondary" type="button" aria-expanded={buildNotesOpen} onclick={() => buildNotesOpen = !buildNotesOpen}>BUILD NOTES</button>
|
||||
<button class="secondary" onclick={saveDraft}>SAVE LOCALLY</button>
|
||||
{#if data.viewer}
|
||||
<PendingForm action="?/save" class="builder-save-form">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="id" value={persistedBuildId}/>
|
||||
<input type="hidden" name="loadout" value={JSON.stringify(draft)}/>
|
||||
<input type="hidden" name="summary" value={buildSummary}/>
|
||||
<input type="hidden" name="guideBody" value={buildGuide}/>
|
||||
<button class="primary" type="submit" name="visibility" value={persistedVisibility} aria-label="Save build to account">{pending ? 'SAVING…' : 'SAVE TO ACCOUNT'}</button>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
@@ -573,12 +561,6 @@
|
||||
{#if persistedSlug && persistedVisibility !== 'private'}<a class="builder-share-link" href={`/builds/${persistedSlug}`}>VIEW SHARE PAGE →</a>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if buildNotesOpen}
|
||||
<section class="builder-notes" aria-label="Build sharing notes">
|
||||
<label for="builder-build-summary"><span>STRENGTHS / SUMMARY <small>OPTIONAL · SHOWN WHEN SHARED</small></span><textarea id="builder-build-summary" name="builder-build-summary" bind:value={buildSummary} maxlength="500" rows="3" placeholder="What this build does well and where it fits."></textarea></label>
|
||||
<MarkdownEditor bind:value={buildGuide} name="builder-guide-editor" label="HOW TO USE THIS BUILD" help="Add an operating sequence, positioning advice, and important tradeoffs for players who open the shared build." maxLength={12000} rows={7}/>
|
||||
</section>
|
||||
{/if}
|
||||
<div class="builder-status"><span><i></i> LIVE CALCULATION</span><span>{draft.calculationMode === 'maximum' ? 'MAXIMUM POTENTIAL' : 'CUSTOM SCENARIO'}</span><span>12 LOADOUT CELLS + 9 CHIPS</span><span>{data.gear.length + data.weapons.length + data.chipSets.length + data.protocols.length} CATALOG ITEMS</span></div>
|
||||
</div>
|
||||
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { PageServerLoad } from './$types';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const database = await getDatabase();
|
||||
const builds = await (await database.prepare(`
|
||||
const database = await getDatabase();
|
||||
const builds = await (
|
||||
await database.prepare(`
|
||||
SELECT builds.id,builds.owner_id,builds.title,builds.slug,builds.updated_at,
|
||||
COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
||||
user_profiles.public_slug,
|
||||
@@ -22,19 +23,21 @@ export const load: PageServerLoad = async () => {
|
||||
GROUP BY builds.id
|
||||
ORDER BY builds.updated_at DESC
|
||||
LIMIT 60
|
||||
`)).all();
|
||||
return {
|
||||
builds: builds.map((build) => ({
|
||||
...build,
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
build.avatar_value,
|
||||
String(build.owner_id),
|
||||
build.avatar_updated_at
|
||||
)
|
||||
}))
|
||||
};
|
||||
`)
|
||||
).all();
|
||||
return {
|
||||
builds: builds.map((build) => ({
|
||||
...build,
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable:
|
||||
Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
build.avatar_value,
|
||||
String(build.owner_id),
|
||||
build.avatar_updated_at,
|
||||
),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,15 +2,19 @@ import { error } from '@sveltejs/kit';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { parseLoadoutDraft, type LoadoutDraft } from '$lib/builder/loadout';
|
||||
import { evaluateLoadoutCombat, summarizeLoadout } from '$lib/builder/loadout-combat';
|
||||
import {
|
||||
evaluateLoadoutCombat,
|
||||
summarizeLoadout,
|
||||
} from '$lib/builder/loadout-combat';
|
||||
import { loadSharedBuildCombatCatalog } from '$lib/server/shared-build-combat';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const build = await (await database.prepare(`
|
||||
const database = await getDatabase();
|
||||
const viewer = await getSessionUser(cookies);
|
||||
const build = await (
|
||||
await database.prepare(`
|
||||
SELECT builds.*,COALESCE(user_profiles.display_name,users.username,'Deleted Agent') AS author,
|
||||
user_profiles.public_slug,
|
||||
COALESCE(user_profiles.is_public,1) AS profile_is_public,deleted_accounts.user_id AS deleted_user_id,
|
||||
@@ -22,43 +26,65 @@ export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
|
||||
WHERE builds.slug=?
|
||||
`)).get(params.slug);
|
||||
if (!build || (build.visibility === 'private' && build.owner_id !== viewer?.id)) error(404, 'Build not found');
|
||||
let loadout: LoadoutDraft;
|
||||
try {
|
||||
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
|
||||
if (!parsed) error(404, 'Build data is unavailable');
|
||||
loadout = parsed;
|
||||
} catch {
|
||||
error(404, 'Build data is unavailable');
|
||||
}
|
||||
const [gear, weapons, chips, protocols, brands, combatCatalog] = await Promise.all([
|
||||
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
||||
(await database.prepare('SELECT id,name,category,is_exotic FROM weapons')).all(),
|
||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||
(await database.prepare('SELECT id,name,quality FROM os_protocols')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
||||
loadSharedBuildCombatCatalog(database)
|
||||
]);
|
||||
const combatResults = evaluateLoadoutCombat(loadout, combatCatalog, summarizeLoadout(loadout, combatCatalog));
|
||||
return {
|
||||
build: {
|
||||
id: String(build.id), title: String(build.title), slug: String(build.slug),
|
||||
visibility: String(build.visibility), author: String(build.author),
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable: Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
build.avatar_value,
|
||||
String(build.owner_id),
|
||||
build.avatar_updated_at
|
||||
),
|
||||
owner: build.owner_id === viewer?.id,
|
||||
updatedAt: String(build.updated_at),
|
||||
summary: String(build.summary ?? ''),
|
||||
guideBody: String(build.guide_body ?? '')
|
||||
},
|
||||
loadout, gear, weapons, chips, protocols, brands, combatResults
|
||||
};
|
||||
`)
|
||||
).get(params.slug);
|
||||
if (
|
||||
!build ||
|
||||
(build.visibility === 'private' && build.owner_id !== viewer?.id)
|
||||
)
|
||||
error(404, 'Build not found');
|
||||
let loadout: LoadoutDraft;
|
||||
try {
|
||||
const parsed = parseLoadoutDraft(JSON.parse(String(build.loadout_json)));
|
||||
if (!parsed) error(404, 'Build data is unavailable');
|
||||
loadout = parsed;
|
||||
} catch {
|
||||
error(404, 'Build data is unavailable');
|
||||
}
|
||||
const [gear, weapons, chips, protocols, brands, combatCatalog] =
|
||||
await Promise.all([
|
||||
(await database.prepare('SELECT id,name,slot,tier FROM gear')).all(),
|
||||
(
|
||||
await database.prepare('SELECT id,name,category,is_exotic FROM weapons')
|
||||
).all(),
|
||||
(await database.prepare('SELECT id,name FROM skill_chip_sets')).all(),
|
||||
(
|
||||
await database.prepare('SELECT id,name,quality FROM os_protocols')
|
||||
).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets')).all(),
|
||||
loadSharedBuildCombatCatalog(database),
|
||||
]);
|
||||
const combatResults = evaluateLoadoutCombat(
|
||||
loadout,
|
||||
combatCatalog,
|
||||
summarizeLoadout(loadout, combatCatalog),
|
||||
);
|
||||
return {
|
||||
build: {
|
||||
id: String(build.id),
|
||||
title: String(build.title),
|
||||
slug: String(build.slug),
|
||||
visibility: String(build.visibility),
|
||||
author: String(build.author),
|
||||
ownerId: String(build.owner_id),
|
||||
authorPublicSlug: build.public_slug ? String(build.public_slug) : null,
|
||||
profileAvailable:
|
||||
Boolean(build.profile_is_public) && !build.deleted_user_id,
|
||||
authorAvatarUrl: avatarUrl(
|
||||
build.avatar_source,
|
||||
build.avatar_value,
|
||||
String(build.owner_id),
|
||||
build.avatar_updated_at,
|
||||
),
|
||||
owner: build.owner_id === viewer?.id,
|
||||
updatedAt: String(build.updated_at),
|
||||
},
|
||||
loadout,
|
||||
gear,
|
||||
weapons,
|
||||
chips,
|
||||
protocols,
|
||||
brands,
|
||||
combatResults,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,31 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { gearSlots } from '$lib/builder/loadout';
|
||||
import AgentIdentityLink from '$lib/components/AgentIdentityLink.svelte';
|
||||
import MarkdownContent from '$lib/components/MarkdownContent.svelte';
|
||||
import WeaponCalculationResult from '$lib/components/builder/WeaponCalculationResult.svelte';
|
||||
import { renderUserMarkdown } from '$lib/markdown';
|
||||
let { data } = $props();
|
||||
const gear = (id: string) => data.gear.find((item) => item.id === id);
|
||||
const weapon = (id: string) => data.weapons.find((item) => item.id === id);
|
||||
const chip = (id: string) => data.chips.find((item) => item.id === id);
|
||||
const protocol = (id: string) => data.protocols.find((item) => item.id === id);
|
||||
const brand = (id: string) => data.brands.find((item) => item.id === id);
|
||||
const guideHtml = $derived(renderUserMarkdown(data.build.guideBody));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{data.build.title} — SHD.build</title></svelte:head>
|
||||
<main class="app-page shared-build-page">
|
||||
<header class="shared-build-command">
|
||||
<div><p class="eyebrow"><span></span> SHD LOADOUT ARCHIVE / {data.build.visibility.toUpperCase()}</p><h1>{data.build.title}</h1><AgentIdentityLink size="compact" linked={data.build.profileAvailable} agent={{ id: data.build.ownerId, displayName: data.build.author, publicSlug: data.build.authorPublicSlug, avatarUrl: data.build.authorAvatarUrl }} meta={`UPDATED ${new Date(data.build.updatedAt).toLocaleDateString()}`}/></div>
|
||||
<div>{#if data.build.owner}<a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||
<div>{#if data.build.owner}<a class="secondary" href={`/guides/new?build=${encodeURIComponent(data.build.id)}`}>CREATE GUIDE FOR BUILD</a><a class="secondary" href={`/builder?build=${data.build.slug}`}>EDIT LOADOUT</a>{/if}<a class="primary" href="/builder">NEW BUILD +</a></div>
|
||||
</header>
|
||||
{#if data.build.summary || data.build.guideBody}
|
||||
<section class="shared-build-guide" aria-labelledby="shared-guide-heading">
|
||||
<header><span>FIELD GUIDE</span><b>AUTHOR NOTES</b></header>
|
||||
{#if data.build.summary}<p class="shared-build-summary">{data.build.summary}</p>{/if}
|
||||
{#if data.build.guideBody}<div><h2 id="shared-guide-heading">HOW TO RUN THIS BUILD</h2><MarkdownContent html={guideHtml}/></div>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
<section class="builder-combat-results" aria-labelledby="shared-combat-heading">
|
||||
<div class="builder-section-title">
|
||||
<div>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getSiteAccessSettings, SITE_SECTION_KEYS } from '$lib/server/site-access';
|
||||
import {
|
||||
getSiteAccessSettings,
|
||||
SITE_SECTION_KEYS,
|
||||
} from '$lib/server/site-access';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
const settings = await getSiteAccessSettings();
|
||||
const unavailable = url.searchParams.get('unavailable');
|
||||
const unavailableSection = SITE_SECTION_KEYS.find((section) => section === unavailable) ?? null;
|
||||
if (!user && !settings.comingSoonEnabled && !unavailableSection) redirect(303, '/');
|
||||
return {
|
||||
settings,
|
||||
authenticated: Boolean(user),
|
||||
unavailable: unavailableSection
|
||||
};
|
||||
const user = await getSessionUser(cookies);
|
||||
const settings = await getSiteAccessSettings();
|
||||
const unavailable = url.searchParams.get('unavailable');
|
||||
const unavailableSection =
|
||||
SITE_SECTION_KEYS.find((section) => section === unavailable) ?? null;
|
||||
if (!user && !settings.comingSoonEnabled && !unavailableSection)
|
||||
redirect(303, '/');
|
||||
return {
|
||||
settings,
|
||||
authenticated: Boolean(user),
|
||||
unavailable: unavailableSection,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,22 +9,26 @@ const submissions = new Map<string, number[]>();
|
||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
function clean(value: FormDataEntryValue | null, maximum: number) {
|
||||
return String(value ?? '').normalize('NFKC').trim().slice(0, maximum);
|
||||
return String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.slice(0, maximum);
|
||||
}
|
||||
|
||||
function allowSubmission(key: string) {
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
const recent = (submissions.get(key) ?? []).filter((time) => time > cutoff);
|
||||
if (recent.length >= 3) return false;
|
||||
recent.push(Date.now());
|
||||
submissions.set(key, recent);
|
||||
return true;
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
const recent = (submissions.get(key) ?? []).filter((time) => time > cutoff);
|
||||
if (recent.length >= 3) return false;
|
||||
recent.push(Date.now());
|
||||
submissions.set(key, recent);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function recipients(team: string) {
|
||||
const database = await getDatabase();
|
||||
const admin = team === 'administrators' || team === 'dmca';
|
||||
return (await database.prepare(`SELECT DISTINCT users.id FROM users
|
||||
const database = await getDatabase();
|
||||
const admin = team === 'administrators' || team === 'dmca';
|
||||
return (
|
||||
await database.prepare(`SELECT DISTINCT users.id FROM users
|
||||
LEFT JOIN user_roles ON user_roles.user_id=users.id
|
||||
LEFT JOIN roles ON roles.id=user_roles.role_id
|
||||
LEFT JOIN role_permissions ON role_permissions.role_id=roles.id
|
||||
@@ -32,76 +36,132 @@ async function recipients(team: string) {
|
||||
LEFT JOIN user_permissions ON user_permissions.user_id=users.id
|
||||
LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM deleted_accounts WHERE deleted_accounts.user_id=users.id)
|
||||
AND (${admin
|
||||
? "users.role='admin' OR role_permission.name='system.admin' OR direct_permission.name='system.admin'"
|
||||
: "users.role='admin' OR roles.id IN ('role-moderator','role-data-approver') OR role_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review') OR direct_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review')"})`))
|
||||
.all();
|
||||
AND (${
|
||||
admin
|
||||
? "users.role='admin' OR role_permission.name='system.admin' OR direct_permission.name='system.admin'"
|
||||
: "users.role='admin' OR roles.id IN ('role-moderator','role-data-approver') OR role_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review') OR direct_permission.name IN ('system.admin','data.approve','users.moderate','avatars.review')"
|
||||
})`)
|
||||
).all();
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
const initialTeam = url.searchParams.get('team') === 'dmca' ? 'dmca' : 'oversight';
|
||||
if (!user) return { sender: null, initialTeam };
|
||||
const database = await getDatabase();
|
||||
const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(user.id);
|
||||
return { sender: { name: user.displayName, email: typeof email?.email === 'string' ? email.email : '' }, initialTeam };
|
||||
const user = await getSessionUser(cookies);
|
||||
const initialTeam =
|
||||
url.searchParams.get('team') === 'dmca' ? 'dmca' : 'oversight';
|
||||
if (!user) return { sender: null, initialTeam };
|
||||
const database = await getDatabase();
|
||||
const email = await (
|
||||
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||
).get(user.id);
|
||||
return {
|
||||
sender: {
|
||||
name: user.displayName,
|
||||
email: typeof email?.email === 'string' ? email.email : '',
|
||||
},
|
||||
initialTeam,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
send: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
if (clean(form.get('website'), 200)) return { contactSent: true };
|
||||
const user = await getSessionUser(cookies);
|
||||
let address = 'unknown';
|
||||
try { address = getClientAddress(); } catch { /* Development adapters may not expose a client address. */ }
|
||||
if (!allowSubmission(user ? `user:${user.id}` : `ip:${address}`)) {
|
||||
return fail(429, { contactError: 'This contact channel has received several recent transmissions. Try again later.' });
|
||||
}
|
||||
send: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
if (clean(form.get('website'), 200)) return { contactSent: true };
|
||||
const user = await getSessionUser(cookies);
|
||||
let address = 'unknown';
|
||||
try {
|
||||
address = getClientAddress();
|
||||
} catch {
|
||||
/* Development adapters may not expose a client address. */
|
||||
}
|
||||
if (!allowSubmission(user ? `user:${user.id}` : `ip:${address}`)) {
|
||||
return fail(429, {
|
||||
contactError:
|
||||
'This contact channel has received several recent transmissions. Try again later.',
|
||||
});
|
||||
}
|
||||
|
||||
const team = clean(form.get('team'), 30);
|
||||
const senderName = clean(form.get('senderName'), 100);
|
||||
const senderEmail = clean(form.get('senderEmail'), 254).toLocaleLowerCase();
|
||||
const subject = clean(form.get('subject'), 140);
|
||||
const body = clean(form.get('body'), 8000);
|
||||
if (!['oversight', 'administrators', 'dmca'].includes(team)) return fail(400, { contactError: 'Choose a valid destination team.' });
|
||||
if (senderName.length < 2 || !emailPattern.test(senderEmail)) return fail(400, { contactError: 'Provide your name and a valid reply email address.' });
|
||||
if (subject.length < 3 || body.length < 10) return fail(400, { contactError: 'Provide a subject and a message of at least 10 characters.' });
|
||||
const team = clean(form.get('team'), 30);
|
||||
const senderName = clean(form.get('senderName'), 100);
|
||||
const senderEmail = clean(form.get('senderEmail'), 254).toLocaleLowerCase();
|
||||
const subject = clean(form.get('subject'), 140);
|
||||
const body = clean(form.get('body'), 8000);
|
||||
if (!['oversight', 'administrators', 'dmca'].includes(team))
|
||||
return fail(400, { contactError: 'Choose a valid destination team.' });
|
||||
if (senderName.length < 2 || !emailPattern.test(senderEmail))
|
||||
return fail(400, {
|
||||
contactError: 'Provide your name and a valid reply email address.',
|
||||
});
|
||||
if (subject.length < 3 || body.length < 10)
|
||||
return fail(400, {
|
||||
contactError:
|
||||
'Provide a subject and a message of at least 10 characters.',
|
||||
});
|
||||
|
||||
let dmca: Record<string, string | boolean> | null = null;
|
||||
if (team === 'dmca') {
|
||||
dmca = {
|
||||
work: clean(form.get('copyrightedWork'), 4000),
|
||||
locations: clean(form.get('contentLocations'), 4000),
|
||||
contact: clean(form.get('claimantContact'), 2000),
|
||||
signature: clean(form.get('signature'), 200),
|
||||
goodFaith: form.get('goodFaith') === '1',
|
||||
accurate: form.get('accurate') === '1'
|
||||
};
|
||||
if (String(dmca.work).length < 5 || String(dmca.locations).length < 5 || String(dmca.contact).length < 10 || String(dmca.signature).length < 2 || !dmca.goodFaith || !dmca.accurate) {
|
||||
return fail(400, { contactError: 'DMCA notices require the work, material location, complete contact information, electronic signature, and both required statements.' });
|
||||
}
|
||||
}
|
||||
let dmca: Record<string, string | boolean> | null = null;
|
||||
if (team === 'dmca') {
|
||||
dmca = {
|
||||
work: clean(form.get('copyrightedWork'), 4000),
|
||||
locations: clean(form.get('contentLocations'), 4000),
|
||||
contact: clean(form.get('claimantContact'), 2000),
|
||||
signature: clean(form.get('signature'), 200),
|
||||
goodFaith: form.get('goodFaith') === '1',
|
||||
accurate: form.get('accurate') === '1',
|
||||
};
|
||||
if (
|
||||
String(dmca.work).length < 5 ||
|
||||
String(dmca.locations).length < 5 ||
|
||||
String(dmca.contact).length < 10 ||
|
||||
String(dmca.signature).length < 2 ||
|
||||
!dmca.goodFaith ||
|
||||
!dmca.accurate
|
||||
) {
|
||||
return fail(400, {
|
||||
contactError:
|
||||
'DMCA notices require the work, material location, complete contact information, electronic signature, and both required statements.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const targetUsers = await recipients(team);
|
||||
if (!targetUsers.length) return fail(503, { contactError: 'No authorized recipient is currently assigned to this channel. Please use the Discord community link for assistance.' });
|
||||
const database = await getDatabase();
|
||||
const messageId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO contact_messages(id,sender_user_id,sender_name,sender_email,team,subject,body,dmca_json,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`)).run(messageId, user?.id ?? null, senderName, senderEmail, team, subject, body, dmca ? JSON.stringify(dmca) : null, createdAt);
|
||||
for (const recipient of targetUsers) {
|
||||
const userId = String(recipient.id);
|
||||
await (await database.prepare('INSERT INTO contact_message_recipients(message_id,user_id) VALUES(?,?)')).run(messageId, userId);
|
||||
await createNotification({
|
||||
userId,
|
||||
category: 'isac',
|
||||
source: 'SHD CONTACT RELAY',
|
||||
title: `${team === 'dmca' ? 'DMCA NOTICE' : team === 'oversight' ? 'OVERSIGHT CONTACT' : 'ADMIN CONTACT'} · ${subject}`,
|
||||
body: `${senderName} (${senderEmail}) submitted a new ${team === 'dmca' ? 'copyright request' : 'contact message'}.\n\n${body.slice(0, 1200)}`,
|
||||
link: `/account/contact/${messageId}`,
|
||||
mandatory: true
|
||||
});
|
||||
}
|
||||
return { contactSent: true, contactToken: messageId };
|
||||
}
|
||||
const targetUsers = await recipients(team);
|
||||
if (!targetUsers.length)
|
||||
return fail(503, {
|
||||
contactError:
|
||||
'No authorized recipient is currently assigned to this channel. Please use the Discord community link for assistance.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const messageId = randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO contact_messages(id,sender_user_id,sender_name,sender_email,team,subject,body,dmca_json,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
messageId,
|
||||
user?.id ?? null,
|
||||
senderName,
|
||||
senderEmail,
|
||||
team,
|
||||
subject,
|
||||
body,
|
||||
dmca ? JSON.stringify(dmca) : null,
|
||||
createdAt,
|
||||
);
|
||||
for (const recipient of targetUsers) {
|
||||
const userId = String(recipient.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO contact_message_recipients(message_id,user_id) VALUES(?,?)',
|
||||
)
|
||||
).run(messageId, userId);
|
||||
await createNotification({
|
||||
userId,
|
||||
category: 'isac',
|
||||
source: 'SHD CONTACT RELAY',
|
||||
title: `${team === 'dmca' ? 'DMCA NOTICE' : team === 'oversight' ? 'OVERSIGHT CONTACT' : 'ADMIN CONTACT'} · ${subject}`,
|
||||
body: `${senderName} (${senderEmail}) submitted a new ${team === 'dmca' ? 'copyright request' : 'contact message'}.\n\n${body.slice(0, 1200)}`,
|
||||
link: `/account/contact/${messageId}`,
|
||||
mandatory: true,
|
||||
});
|
||||
}
|
||||
return { contactSent: true, contactToken: messageId };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,26 +4,33 @@ import { databaseCategories } from '$lib/database/categories';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const tables = {
|
||||
gear: 'gear',
|
||||
weapons: 'weapons',
|
||||
'weapon-mods': 'weapon_mods',
|
||||
'gear-mods': 'gear_mods',
|
||||
'os-protocols': 'os_protocols',
|
||||
talents: 'talents',
|
||||
brands: 'brand_sets',
|
||||
'skill-chips': 'skill_chip_sets',
|
||||
specializations: 'specializations'
|
||||
gear: 'gear',
|
||||
weapons: 'weapons',
|
||||
'weapon-mods': 'weapon_mods',
|
||||
'gear-mods': 'gear_mods',
|
||||
'os-protocols': 'os_protocols',
|
||||
talents: 'talents',
|
||||
brands: 'brand_sets',
|
||||
'skill-chips': 'skill_chip_sets',
|
||||
specializations: 'specializations',
|
||||
} as const;
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const database = await getDatabase();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const counts = await Promise.all(databaseCategories.map(async (category) => {
|
||||
const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`)).get();
|
||||
return { ...category, count: Number(row?.count ?? 0) };
|
||||
}));
|
||||
const searchResults = search
|
||||
? await (await database.prepare(`
|
||||
const database = await getDatabase();
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const counts = await Promise.all(
|
||||
databaseCategories.map(async (category) => {
|
||||
const row = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM ${tables[category.slug]} WHERE active=1`,
|
||||
)
|
||||
).get();
|
||||
return { ...category, count: Number(row?.count ?? 0) };
|
||||
}),
|
||||
);
|
||||
const searchResults = search
|
||||
? await (
|
||||
await database.prepare(`
|
||||
SELECT * FROM (
|
||||
SELECT 'gear' AS slug,'Gear' AS category,item.name,
|
||||
item.slot || ' · T' || item.tier AS detail
|
||||
@@ -113,17 +120,18 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
) matches
|
||||
ORDER BY name COLLATE NOCASE
|
||||
LIMIT 40
|
||||
`)).all(...Array(27).fill(pattern))
|
||||
: [];
|
||||
return {
|
||||
registries: counts,
|
||||
total: counts.reduce((total, registry) => total + registry.count, 0),
|
||||
search,
|
||||
searchResults: searchResults.map((result) => ({
|
||||
slug: String(result.slug),
|
||||
category: String(result.category),
|
||||
name: String(result.name),
|
||||
detail: String(result.detail ?? '')
|
||||
}))
|
||||
};
|
||||
`)
|
||||
).all(...Array(27).fill(pattern))
|
||||
: [];
|
||||
return {
|
||||
registries: counts,
|
||||
total: counts.reduce((total, registry) => total + registry.count, 0),
|
||||
search,
|
||||
searchResults: searchResults.map((result) => ({
|
||||
slug: String(result.slug),
|
||||
category: String(result.category),
|
||||
name: String(result.name),
|
||||
detail: String(result.detail ?? ''),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,15 @@
|
||||
import DatabaseCategoryNav from '$lib/components/DatabaseCategoryNav.svelte';
|
||||
import DatabaseItemCard from '$lib/components/DatabaseItemCard.svelte';
|
||||
import DatabaseDiscoveryConsole from '$lib/components/DatabaseDiscoveryConsole.svelte';
|
||||
let { data } = $props();
|
||||
import DatabaseDiscussionDialog from '$lib/components/DatabaseDiscussionDialog.svelte';
|
||||
import { page } from '$app/state';
|
||||
let { data, form } = $props();
|
||||
const discussionCloseHref = () => {
|
||||
const target = new URL(page.url);
|
||||
target.searchParams.delete('discussion');
|
||||
target.hash = data.selectedItem ? `database-item-${data.selectedItem.id}` : '';
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -27,4 +35,7 @@
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{#if data.selectedItem}
|
||||
<DatabaseDiscussionDialog item={data.selectedItem} comments={data.discussionComments} viewer={data.viewer} {form} signInHref={`/sign-in?returnTo=${encodeURIComponent(`/database/${data.category.slug}?discussion=${data.selectedItem.id}`)}`} closeHref={discussionCloseHref()}/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { listPublishedArticles, syncArticleTags } from '$lib/server/articles';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
const database = await getDatabase();
|
||||
await syncArticleTags(database);
|
||||
const tagSlugs = url.searchParams.getAll('tag').filter(Boolean);
|
||||
const sortValue = url.searchParams.get('sort');
|
||||
const sort = ['relevance', 'newest', 'rating', 'rated'].includes(
|
||||
String(sortValue),
|
||||
)
|
||||
? (sortValue as 'relevance' | 'newest' | 'rating' | 'rated')
|
||||
: undefined;
|
||||
const [results, tags] = await Promise.all([
|
||||
listPublishedArticles({
|
||||
kind: 'guide',
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
tagSlugs,
|
||||
sort,
|
||||
page: Number(url.searchParams.get('page') ?? 1),
|
||||
}),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT name,slug,group_name FROM article_tags WHERE active=1 AND group_name<>'news_channel' ORDER BY group_name,name COLLATE NOCASE",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
return {
|
||||
...results,
|
||||
tags,
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
selectedTags: tagSlugs,
|
||||
sort: sort ?? (url.searchParams.get('q') ? 'relevance' : 'newest'),
|
||||
canCreate: Boolean(user),
|
||||
};
|
||||
};
|
||||
@@ -1,2 +1,24 @@
|
||||
<svelte:head><title>Build guides — SHD.build</title></svelte:head>
|
||||
<main class="page guides-page"><div class="catalogue-hero"><div><p class="eyebrow"><span></span> SHD FIELD MANUAL</p><h1>BUILD<br/><em>GUIDES.</em></h1><p>Long-form tactical notes, play styles, rotations, and loadout walkthroughs from experienced agents.</p></div><div class="catalogue-count"><strong>FIELD</strong><span>INTELLIGENCE NETWORK</span><a class="primary" href="/builder">CREATE A BUILD +</a></div></div><div class="guide-layout"><section class="guide-feature"><div class="guide-code">SHD // INTEL</div><p class="eyebrow">EDITORIAL SYSTEM</p><h2>FIELD REPORTS<br/>COMING ONLINE</h2><p>The guide publishing route is ready for build-linked articles. Rich writing, tactical sections, author profiles, and community discovery arrive in a later increment.</p><div class="guide-meta"><span>STATUS <b>QUEUED</b></span><span>PRIORITY <b>NORMAL</b></span><span>NODE <b>NYC-01</b></span></div></section><aside class="guide-index"><p class="eyebrow">PLANNED CHANNELS</p>{#each ['PVE OPERATIONS','PVP DOCTRINE','WEAPON ANALYSIS','TEAM COMPOSITIONS'] as item,i}<div><small>0{i+1}</small><span>{item}</span><b>›</b></div>{/each}</aside></div></main>
|
||||
<script lang="ts">
|
||||
import ArticleCard from '$lib/components/ArticleCard.svelte';
|
||||
let { data } = $props();
|
||||
const pinned = $derived(data.items.filter((item: any) => item.pinnedOrder !== null).sort((a: any,b: any) => a.pinnedOrder-b.pinnedOrder));
|
||||
const featured = $derived(data.items.filter((item: any) => item.featured && item.pinnedOrder === null));
|
||||
const regular = $derived(data.items.filter((item: any) => item.pinnedOrder === null && !item.featured));
|
||||
function tagHref(slug: string) { const params = new URLSearchParams(); if (data.query) params.set('q',data.query); for (const tag of data.selectedTags) if (tag !== slug) params.append('tag',tag); if (!data.selectedTags.includes(slug)) params.append('tag',slug); params.set('sort',data.sort); return `?${params}`; }
|
||||
</script>
|
||||
<svelte:head><title>Community Guides — SHD.build</title><meta name="description" content="Search tactical Guides, Build walkthroughs, specialization strategies, PvE, PvP, Dark Zone, and weapon advice from SHD.build Agents."/></svelte:head>
|
||||
<main class="page guide-index-page">
|
||||
<header class="guide-index-command"><div><p class="eyebrow"><span></span> SHD FIELD MANUAL</p><h1>TACTICS,<br/><em>AUTHORED.</em></h1><p>Search community intelligence by objective, specialization, Focus, weapon, or exact field terminology.</p></div><div><p><strong>{data.total}</strong><span>PUBLISHED GUIDES</span></p>{#if data.canCreate}<a class="secondary" href="/account/articles">YOUR ARTICLES</a><a class="primary" href="/guides/new">WRITE A GUIDE +</a>{:else}<a class="primary" href="/sign-in?returnTo=%2Fguides%2Fnew">SIGN IN TO WRITE</a>{/if}</div></header>
|
||||
<form class="guide-discovery" method="GET"><label><span>SEARCH TITLE AND CONTENT</span><input name="q" value={data.query} placeholder="Search tactics, equipment, activities…"/></label><label><span>SORT</span><select name="sort" value={data.sort}><option value="relevance">BEST MATCH</option><option value="newest">NEWEST</option><option value="rating">HIGHEST RATED</option><option value="rated">MOST RATED</option></select></label>{#each data.selectedTags as tag}<input type="hidden" name="tag" value={tag}/>{/each}<button class="primary">SEARCH</button></form>
|
||||
<nav class="guide-tag-filter" aria-label="Guide filters">{#each data.tags as tag}<a class:active={data.selectedTags.includes(tag.slug)} href={tagHref(tag.slug)}>{tag.name}</a>{/each}</nav>
|
||||
{#if pinned.length}<section class="guide-pinned"><header><span>PINNED BY COMMUNITY OVERSIGHT</span><b>{pinned.length} ACTIVE</b></header>{#each pinned as article}<ArticleCard {article} kind="guide" prominent={pinned.length === 1}/>{/each}</section>{/if}
|
||||
{#if featured.length}<section class="guide-featured"><header><div><span>FEATURED FIELD GUIDES</span><h2>OVERSIGHT SELECTION</h2></div></header><div>{#each featured as article}<ArticleCard {article} kind="guide"/>{/each}</div></section>{/if}
|
||||
<section class="guide-results"><header><div><span>{data.query ? 'SEARCH RESULTS' : 'LATEST INTELLIGENCE'}</span><h2>{data.total} GUIDE{data.total === 1 ? '' : 'S'}</h2></div></header><div>{#each regular as article}<ArticleCard {article} kind="guide"/>{:else}{#if !pinned.length && !featured.length}<div class="catalogue-empty"><span>◇</span><strong>NO GUIDES MATCH THIS QUERY</strong><p>Adjust the search or filters, or publish the first Guide for this subject.</p></div>{/if}{/each}</div></section>
|
||||
{#if data.pageCount > 1}<nav class="guide-pagination" aria-label="Guide pages">{#each Array(data.pageCount) as _,index}<a class:active={data.page===index+1} href={`?page=${index+1}`}>{index+1}</a>{/each}</nav>{/if}
|
||||
</main>
|
||||
<style>
|
||||
.guide-index-page{padding-top:28px}.guide-index-command{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:30px;padding:24px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.guide-index-command h1,.guide-index-command p{margin:0}.guide-index-command h1{max-width:10ch;font-size:clamp(3.5rem,7vw,6rem);line-height:.84;letter-spacing:-.035em}.guide-index-command>div:first-child>p:last-child{max-width:64ch;margin-top:18px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.12rem}.guide-index-command>div:last-child{min-width:190px;display:grid;grid-template-columns:1fr;justify-items:stretch;gap:7px}.guide-index-command>div:last-child>p{display:flex;align-items:baseline;justify-content:flex-end;gap:8px;margin-bottom:5px}.guide-index-command>div:last-child strong{color:var(--shd-orange);font-size:2.25rem;line-height:1}.guide-index-command>div:last-child span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.guide-index-command>div:last-child a{text-align:center}
|
||||
.guide-discovery{display:grid;grid-template-columns:minmax(260px,1fr) 190px auto;align-items:end;gap:8px;padding:10px;border:1px solid var(--shd-border);border-top:0;background:var(--shd-surface)}.guide-discovery label{display:grid;gap:5px}.guide-discovery label span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.guide-discovery button{min-height:40px}.guide-tag-filter{display:flex;gap:5px;padding:10px 0;overflow:auto}.guide-tag-filter a{min-height:34px;display:flex;align-items:center;padding:0 9px;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;white-space:nowrap}.guide-tag-filter a.active,.guide-tag-filter a:hover,.guide-tag-filter a:focus-visible{border-color:var(--shd-orange);background:#211a0e;color:var(--shd-orange)}
|
||||
.guide-pinned,.guide-featured,.guide-results{display:grid;gap:10px;margin-top:20px}.guide-pinned>header,.guide-featured>header,.guide-results>header{display:flex;justify-content:space-between;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.guide-pinned>header span,.guide-pinned>header b,.guide-featured>header span,.guide-results>header span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.guide-pinned>header span{color:var(--shd-orange)}.guide-featured h2,.guide-results h2{margin:3px 0 0}.guide-featured>div,.guide-results>div{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.guide-pagination{display:flex;justify-content:center;gap:4px;margin-top:24px}.guide-pagination a{width:40px;height:40px;display:grid;place-items:center;border:1px solid var(--shd-border);color:var(--shd-text-muted);font-weight:900}.guide-pagination a.active{border-color:var(--shd-orange);color:var(--shd-orange)}
|
||||
@media(max-width:1000px){.guide-featured>div,.guide-results>div{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:700px){.guide-index-page{padding-top:12px}.guide-index-command{grid-template-columns:1fr;gap:24px;padding:20px 0 24px}.guide-index-command>div:last-child{min-width:0;grid-template-columns:1fr 1fr}.guide-index-command>div:last-child>p{grid-column:1/-1;justify-content:flex-start}.guide-discovery{grid-template-columns:1fr;padding:8px}.guide-discovery button{min-height:48px}.guide-tag-filter{margin-inline:-18px;padding:10px 18px;scroll-padding-inline:18px}.guide-featured>div,.guide-results>div{grid-template-columns:1fr}.guide-index-command h1{font-size:clamp(3.25rem,17vw,4.5rem)}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { articlePageActions, loadArticlePage } from '$lib/server/article-page';
|
||||
import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = async ({ params, cookies }) =>
|
||||
loadArticlePage('guide', params.slug, cookies);
|
||||
export const actions = articlePageActions('guide');
|
||||
@@ -0,0 +1,2 @@
|
||||
<script lang="ts">import ArticlePage from '$lib/components/ArticlePage.svelte';let {data,form}=$props();</script>
|
||||
<ArticlePage {data} {form}/>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import {
|
||||
deleteArticle,
|
||||
loadArticleEditor,
|
||||
NEWS_CHANNELS,
|
||||
} from '$lib/server/articles';
|
||||
import { saveAndSubmitArticleForm } from '$lib/server/article-form';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function guideId(slug: string) {
|
||||
const row = await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare("SELECT id FROM articles WHERE kind='guide' AND slug=?")
|
||||
).get(slug);
|
||||
if (!row) error(404, 'Guide not found');
|
||||
return String(row.id);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
redirect(303, `/sign-in?returnTo=${encodeURIComponent(url.pathname)}`);
|
||||
return {
|
||||
...(await loadArticleEditor(await guideId(params.slug), user.id, false)),
|
||||
newsChannels: NEWS_CHANNELS,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
publish: async ({ params, cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
return fail(401, { articleError: 'Sign in to publish this Guide.' });
|
||||
const result = await saveAndSubmitArticleForm({
|
||||
form: await request.formData(),
|
||||
user,
|
||||
canManageNews: false,
|
||||
});
|
||||
if (!result.ok)
|
||||
return fail(result.status, { articleError: result.message });
|
||||
return {
|
||||
articleMessage:
|
||||
result.state === 'pending_review'
|
||||
? 'Guide submitted to Community Oversight.'
|
||||
: result.state === 'private'
|
||||
? 'Guide saved as private.'
|
||||
: 'Guide published.',
|
||||
};
|
||||
},
|
||||
delete: async ({ params, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
return fail(401, { articleError: 'Sign in to delete this Guide.' });
|
||||
await deleteArticle(await guideId(params.slug), user.id, false);
|
||||
redirect(303, '/guides');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<script lang="ts">import ArticleComposer from '$lib/components/ArticleComposer.svelte';let { data, form } = $props();</script>
|
||||
<svelte:head><title>Edit {data.draft.title} — SHD.build</title></svelte:head>
|
||||
<ArticleComposer {data} {form}/>
|
||||
@@ -0,0 +1,45 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { createArticle } from '$lib/server/articles';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user)
|
||||
redirect(
|
||||
303,
|
||||
`/sign-in?returnTo=${encodeURIComponent(url.pathname + url.search)}`,
|
||||
);
|
||||
const buildId = url.searchParams.get('build')?.trim() || '';
|
||||
const build = buildId
|
||||
? await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('SELECT id,title FROM builds WHERE id=? AND owner_id=?')
|
||||
).get(buildId, user.id)
|
||||
: null;
|
||||
return {
|
||||
build: build ? { id: String(build.id), title: String(build.title) } : null,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { articleError: 'Sign in to create a Guide.' });
|
||||
const form = await request.formData();
|
||||
const title = String(form.get('title') ?? '').trim();
|
||||
if (title.length < 2 || title.length > 140)
|
||||
return fail(400, {
|
||||
articleError: 'Guide titles must be between 2 and 140 characters.',
|
||||
});
|
||||
const article = await createArticle({
|
||||
kind: 'guide',
|
||||
ownerId: user.id,
|
||||
title,
|
||||
prefillBuildId: String(form.get('buildId') ?? '') || null,
|
||||
});
|
||||
redirect(303, `/guides/${encodeURIComponent(article.slug)}/edit`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let { data, form } = $props();</script>
|
||||
<svelte:head><title>Create a Guide — SHD.build</title></svelte:head>
|
||||
<main class="page article-start-page"><section><p class="eyebrow"><span></span> SHD FIELD MANUAL</p><h1>START A<br/><em>GUIDE.</em></h1><p>Open a private writing workspace. Your Guide remains private until you submit a finished revision for moderation.</p><PendingForm action="?/create"><label>WORKING TITLE<input name="title" minlength="2" maxlength="140" required placeholder="Name the tactic, Build, or problem this Guide explains"/></label>{#if data.build}<input type="hidden" name="buildId" value={data.build.id}/><div class="prefill-build"><span>INITIAL BUILD EMBED</span><strong>{data.build.title}</strong><small>You can remove it or add more Builds in the composer.</small></div>{/if}{#if form?.articleError}<p class="error">{form.articleError}</p>{/if}<button class="primary">OPEN GUIDE COMPOSER →</button></PendingForm></section></main>
|
||||
<style>.article-start-page{min-height:calc(100vh - 160px);display:grid;place-items:start center;padding-top:clamp(48px,8vh,110px)}.article-start-page>section{width:min(760px,100%);display:grid;gap:16px;padding:clamp(24px,5vw,60px);border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-start-page h1,.article-start-page p{margin:0}.article-start-page h1{font-size:clamp(3rem,8vw,6rem);line-height:.85}.article-start-page>section>p:not(.eyebrow){max-width:62ch;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.1rem}.article-start-page :global(form){display:grid;gap:12px}.article-start-page label{display:grid;gap:6px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.article-start-page input{min-height:52px;font-size:1.1rem}.prefill-build{display:grid;gap:3px;padding:12px;border:1px solid var(--shd-orange);background:#17140e}.prefill-build span,.prefill-build small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.prefill-build strong{font-size:1.2rem}@media(max-width:650px){.article-start-page{padding-top:40px}.article-start-page>section{padding:24px}.article-start-page :global(form button){width:100%;min-height:48px}}</style>
|
||||
@@ -4,14 +4,21 @@ import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
if (!GAME_ICON_TYPES.includes(params.type as (typeof GAME_ICON_TYPES)[number])) error(404, 'Icon not found');
|
||||
const image = await readGameDataIcon(await getDatabase(), params.type, params.id);
|
||||
if (!image) error(404, 'Icon not found');
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
'x-content-type-options': 'nosniff'
|
||||
}
|
||||
});
|
||||
if (
|
||||
!GAME_ICON_TYPES.includes(params.type as (typeof GAME_ICON_TYPES)[number])
|
||||
)
|
||||
error(404, 'Icon not found');
|
||||
const image = await readGameDataIcon(
|
||||
await getDatabase(),
|
||||
params.type,
|
||||
params.id,
|
||||
);
|
||||
if (!image) error(404, 'Icon not found');
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
'x-content-type-options': 'nosniff',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,13 +4,13 @@ import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const image = await readManagedImage(await getDatabase(), params.id);
|
||||
if (!image) error(404, 'Image not found');
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
'x-content-type-options': 'nosniff'
|
||||
}
|
||||
});
|
||||
const image = await readManagedImage(await getDatabase(), params.id);
|
||||
if (!image) error(404, 'Image not found');
|
||||
return new Response(new Uint8Array(image), {
|
||||
headers: {
|
||||
'content-type': 'image/webp',
|
||||
'cache-control': 'public, max-age=31536000, immutable',
|
||||
'x-content-type-options': 'nosniff',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,56 +6,106 @@ import type { LayoutServerLoad } from './$types';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account');
|
||||
const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission));
|
||||
const canManageData = permissions.includes('*') || permissions.includes('data.manage');
|
||||
const canProposeData = permissions.includes('*') || permissions.includes('data.propose');
|
||||
const canApproveData = permissions.includes('*') || permissions.includes('data.approve');
|
||||
const canReviewAvatars = permissions.includes('*') || permissions.includes('avatars.review');
|
||||
const canReviewCommunity = permissions.includes('*')
|
||||
|| permissions.includes('community.comments.moderate')
|
||||
|| permissions.includes('community.profiles.moderate');
|
||||
const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null;
|
||||
const database = canSeeData || canReviewAvatars || canReviewCommunity ? await getDatabase() : null;
|
||||
const pendingRows = canSeeData
|
||||
? await (await database!.prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
|
||||
: [];
|
||||
const pendingProposalTotal = pendingRows.reduce((total, row) => total + Number(row.count), 0);
|
||||
const pendingAvatarReviews = canReviewAvatars
|
||||
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
|
||||
: 0;
|
||||
const canReviewComments = permissions.includes('*') || permissions.includes('community.comments.moderate');
|
||||
const canReviewProfiles = permissions.includes('*') || permissions.includes('community.profiles.moderate');
|
||||
const communityReviewTypes = [
|
||||
...(canReviewComments ? ['comment', 'guide'] : []),
|
||||
...(canReviewProfiles ? ['profile_bio'] : [])
|
||||
];
|
||||
const communityTypePlaceholders = communityReviewTypes.map(() => '?').join(',');
|
||||
const pendingCommunityReviews = canReviewCommunity && communityReviewTypes.length
|
||||
? Number((await (await database!.prepare(`SELECT
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('isac.access'))
|
||||
redirect(303, '/account');
|
||||
const canSeeData =
|
||||
permissions.includes('*') ||
|
||||
['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
|
||||
(permission) => permissions.includes(permission),
|
||||
);
|
||||
const canManageData =
|
||||
permissions.includes('*') || permissions.includes('data.manage');
|
||||
const canProposeData =
|
||||
permissions.includes('*') || permissions.includes('data.propose');
|
||||
const canApproveData =
|
||||
permissions.includes('*') || permissions.includes('data.approve');
|
||||
const canReviewAvatars =
|
||||
permissions.includes('*') || permissions.includes('avatars.review');
|
||||
const canReviewCommunity =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.comments.moderate') ||
|
||||
permissions.includes('community.profiles.moderate') ||
|
||||
permissions.includes('community.guides.moderate');
|
||||
const contributorMode =
|
||||
!canManageData && canProposeData ? await getContributorMode(user.id) : null;
|
||||
const database =
|
||||
canSeeData || canReviewAvatars || canReviewCommunity
|
||||
? await getDatabase()
|
||||
: null;
|
||||
const pendingRows = canSeeData
|
||||
? await (
|
||||
await database!.prepare(
|
||||
"SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module",
|
||||
)
|
||||
).all()
|
||||
: [];
|
||||
const pendingProposalTotal = pendingRows.reduce(
|
||||
(total, row) => total + Number(row.count),
|
||||
0,
|
||||
);
|
||||
const pendingAvatarReviews = canReviewAvatars
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database!.prepare(
|
||||
"SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'",
|
||||
)
|
||||
).get()
|
||||
)?.count ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const canReviewComments =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.comments.moderate');
|
||||
const canReviewProfiles =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.profiles.moderate');
|
||||
const canReviewGuides =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.guides.moderate');
|
||||
const communityReviewTypes = [
|
||||
...(canReviewComments ? ['comment'] : []),
|
||||
...(canReviewGuides ? ['guide'] : []),
|
||||
...(canReviewProfiles ? ['profile_bio'] : []),
|
||||
];
|
||||
const communityTypePlaceholders = communityReviewTypes
|
||||
.map(() => '?')
|
||||
.join(',');
|
||||
const pendingCommunityReviews =
|
||||
canReviewCommunity && communityReviewTypes.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database!.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM content_moderation_scans scan
|
||||
WHERE scan.content_type IN (${communityTypePlaceholders})
|
||||
AND scan.status IN ('review','priority_review','error')
|
||||
AND NOT EXISTS(SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id))
|
||||
+
|
||||
(SELECT COUNT(*) FROM user_content_reports
|
||||
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`))
|
||||
.get(...communityReviewTypes, ...communityReviewTypes))?.count ?? 0)
|
||||
: 0;
|
||||
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
|
||||
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
|
||||
return {
|
||||
permissions,
|
||||
contributorMode,
|
||||
buildNumber,
|
||||
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
|
||||
oversightReviewCounts: {
|
||||
data: canApproveData ? pendingProposalTotal : 0,
|
||||
avatars: pendingAvatarReviews,
|
||||
community: pendingCommunityReviews
|
||||
}
|
||||
};
|
||||
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`)
|
||||
).get(...communityReviewTypes, ...communityReviewTypes)
|
||||
)?.count ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
|
||||
const buildNumber = /^\d+$/.test(configuredBuildNumber)
|
||||
? configuredBuildNumber
|
||||
: '0';
|
||||
return {
|
||||
permissions,
|
||||
contributorMode,
|
||||
buildNumber,
|
||||
pendingProposals: Object.fromEntries(
|
||||
pendingRows.map((row) => [String(row.module), Number(row.count)]),
|
||||
),
|
||||
oversightReviewCounts: {
|
||||
data: canApproveData ? pendingProposalTotal : 0,
|
||||
avatars: pendingAvatarReviews,
|
||||
community: pendingCommunityReviews,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||
const isacNode = 'NYC-01';
|
||||
let readOnlyBlocked = $state(false);
|
||||
let contributorNote = $state('');
|
||||
const dataSections = [
|
||||
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
||||
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
|
||||
@@ -36,7 +37,8 @@
|
||||
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' | 'community' }> = [
|
||||
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
|
||||
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
|
||||
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'], reviewKey: 'community' },
|
||||
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate', 'community.guides.moderate'], reviewKey: 'community' },
|
||||
{ href: '/isac/community/guides', label: 'Guide Oversight', permissions: ['community.guides.moderate', 'guides.curate'] },
|
||||
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
|
||||
];
|
||||
const visibleOversightSections = $derived(oversightSections.filter((section) =>
|
||||
@@ -128,13 +130,23 @@
|
||||
event.preventDefault();
|
||||
readOnlyBlocked = true;
|
||||
}
|
||||
const form = event.target;
|
||||
if (form instanceof HTMLFormElement && contributorEnabled && can('data.propose') && !can('data.manage') && form.method.toLowerCase() === 'post') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden'; input.name = 'contributorNote'; input.value = contributorNote;
|
||||
form.append(input);
|
||||
}
|
||||
}}>
|
||||
{#if dataModulePage && page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
|
||||
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
|
||||
{:else if dataModulePage && contributorEnabled && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
|
||||
{#if dataModulePage && readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
|
||||
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
|
||||
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/>{#if contributorEnabled && can('data.propose') && !can('data.manage')}<label class="contributor-note"><span>CONTRIBUTOR RATIONALE <small>OPTIONAL · SHARED WITH OVERSIGHT</small></span><textarea bind:value={contributorNote} maxlength="1000" rows="2" placeholder="Explain the observed inaccuracy, source, or reason for this change…"></textarea></label>{/if}</div>{/if}
|
||||
{@render children()}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.contributor-note{display:grid;gap:6px;margin-top:8px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.contributor-note>span{display:flex;justify-content:space-between;gap:8px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.contributor-note small{color:var(--shd-text-muted);font:inherit}.contributor-note textarea{width:100%;resize:vertical}
|
||||
</style>
|
||||
|
||||
@@ -4,14 +4,23 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const permissions = await getPermissions(await getSessionUser(cookies));
|
||||
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
|
||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
||||
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||
if (permissions.includes('site.settings.manage')) redirect(303, '/isac/site-controls');
|
||||
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
|
||||
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||
redirect(303, '/account');
|
||||
const permissions = await getPermissions(await getSessionUser(cookies));
|
||||
if (permissions.includes('*') || permissions.includes('users.view'))
|
||||
redirect(303, '/isac/users');
|
||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
||||
if (
|
||||
permissions.includes('*') ||
|
||||
['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
|
||||
(permission) => permissions.includes(permission),
|
||||
)
|
||||
)
|
||||
redirect(303, '/isac/configuration');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||
if (permissions.includes('site.settings.manage'))
|
||||
redirect(303, '/isac/site-controls');
|
||||
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
|
||||
if (permissions.includes('system.notifications.manage'))
|
||||
redirect(303, '/isac/system-messages');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||
redirect(303, '/account');
|
||||
};
|
||||
|
||||
@@ -1,116 +1,327 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const pending = (await getPendingEntities(user.id, [{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
|
||||
}])).map((stat) => ({
|
||||
...stat,
|
||||
calculation_type: stat.calculationType,
|
||||
gear_min: stat.gearMin || null,
|
||||
gear_max: stat.gearMax || null,
|
||||
weapon_min: stat.weaponMin || null,
|
||||
weapon_max: stat.weaponMax || null,
|
||||
skill_chip_min: stat.chipMin || null,
|
||||
skill_chip_max: stat.chipMax || null,
|
||||
cap_value: stat.capValue || null
|
||||
}));
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const pending = (
|
||||
await getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: [
|
||||
'description',
|
||||
'type',
|
||||
'category',
|
||||
'calculationType',
|
||||
'unit',
|
||||
'gearMin',
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
'capValue',
|
||||
],
|
||||
},
|
||||
])
|
||||
).map((stat) => ({
|
||||
...stat,
|
||||
calculation_type: stat.calculationType,
|
||||
gear_min: stat.gearMin || null,
|
||||
gear_max: stat.gearMax || null,
|
||||
weapon_min: stat.weaponMin || null,
|
||||
weapon_max: stat.weaponMax || null,
|
||||
skill_chip_min: stat.chipMin || null,
|
||||
skill_chip_max: stat.chipMax || null,
|
||||
cap_value: stat.capValue || null,
|
||||
}));
|
||||
const summary = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
|
||||
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
|
||||
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC' },
|
||||
type: { asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
category: { asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
calculation: { asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const visiblePending = pending.filter((stat) =>
|
||||
(filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true)
|
||||
&& (!search || String(stat.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
).sort((left, right) => {
|
||||
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
|
||||
const result = String((left as any)[field] ?? '').localeCompare(String((right as any)[field] ?? ''), undefined, { sensitivity: 'base', numeric: true });
|
||||
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(...args);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const stats = databaseLimit
|
||||
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset)
|
||||
: [];
|
||||
return {
|
||||
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
sort
|
||||
};
|
||||
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)
|
||||
).get();
|
||||
const filter = ['active', 'offensive', 'defensive'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC',
|
||||
},
|
||||
type: {
|
||||
asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
category: {
|
||||
asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
calculation: {
|
||||
asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'offensive'
|
||||
? ["type='Offensive'"]
|
||||
: filter === 'defensive'
|
||||
? ["type='Defensive'"]
|
||||
: []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const visiblePending = pending
|
||||
.filter(
|
||||
(stat) =>
|
||||
(filter === 'offensive'
|
||||
? String((stat as any).type) === 'Offensive'
|
||||
: filter === 'defensive'
|
||||
? String((stat as any).type) === 'Defensive'
|
||||
: true) &&
|
||||
(!search ||
|
||||
String(stat.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
|
||||
const result = String((left as any)[field] ?? '').localeCompare(
|
||||
String((right as any)[field] ?? ''),
|
||||
undefined,
|
||||
{ sensitivity: 'base', numeric: true },
|
||||
);
|
||||
return (
|
||||
(sort.direction === 'asc' ? result : -result) ||
|
||||
String(left.name).localeCompare(String(right.name), undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
);
|
||||
});
|
||||
const filtered = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS total FROM stat_attributes ${where}`,
|
||||
)
|
||||
).get(...args);
|
||||
const pagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + visiblePending.length,
|
||||
);
|
||||
const pendingOnPage =
|
||||
pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const stats = databaseLimit
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [];
|
||||
return {
|
||||
stats: [
|
||||
...pendingOnPage,
|
||||
...stats.map((stat) => ({
|
||||
...stat,
|
||||
unit: stat.unit === 'flat' ? '' : stat.unit,
|
||||
})),
|
||||
],
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pending.length,
|
||||
pending: pending.length,
|
||||
},
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
|
||||
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
|
||||
if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' });
|
||||
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
|
||||
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
|
||||
const number = (key: string, allowNegative = true) => {
|
||||
const raw = String(form.get(key) ?? '').trim();
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || (!allowNegative && value < 0)) throw new Error();
|
||||
return value;
|
||||
};
|
||||
let values: Array<number|null>;
|
||||
try {
|
||||
values = [
|
||||
...['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax'].map((key) => number(key)),
|
||||
number('capValue', false)
|
||||
];
|
||||
} catch { return fail(400, { error: 'Ranges must be numbers. Optional caps cannot be negative.' }); }
|
||||
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?')).get(name, unit, id || 'new')) return fail(409, { error: 'That attribute and unit combination already exists.' });
|
||||
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
||||
if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id);
|
||||
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
|
||||
const savedItem = await auditRecord('stat_attributes', statId);
|
||||
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage'); const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('stat_attributes', id);
|
||||
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'attribute.archived', entityId: id, before, after: await auditRecord('stat_attributes', id) }); return { archived: true };
|
||||
}
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? ''),
|
||||
name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const description = String(form.get('description') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const type = String(form.get('type') ?? ''),
|
||||
category = String(form.get('category') ?? ''),
|
||||
calculation = String(form.get('calculationType') ?? ''),
|
||||
unit = String(form.get('unit') ?? '');
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return fail(400, { error: 'Attribute names must be 2–80 characters.' });
|
||||
if (description.length > 1200)
|
||||
return fail(400, {
|
||||
error: 'Attribute descriptions must be 1,200 characters or fewer.',
|
||||
});
|
||||
if (
|
||||
!['Offensive', 'Defensive'].includes(type) ||
|
||||
!['Weapon', 'Skill', 'Character'].includes(category) ||
|
||||
!['Additive', 'Multiplicative'].includes(calculation)
|
||||
)
|
||||
return fail(400, { error: 'Choose valid attribute options.' });
|
||||
if (!['', '%', 's', 'Shots', 'm'].includes(unit))
|
||||
return fail(400, { error: 'Choose a valid unit.' });
|
||||
const number = (key: string, allowNegative = true) => {
|
||||
const raw = String(form.get(key) ?? '').trim();
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || (!allowNegative && value < 0))
|
||||
throw new Error();
|
||||
return value;
|
||||
};
|
||||
let values: Array<number | null>;
|
||||
try {
|
||||
values = [
|
||||
...[
|
||||
'gearMin',
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
].map((key) => number(key)),
|
||||
number('capValue', false),
|
||||
];
|
||||
} catch {
|
||||
return fail(400, {
|
||||
error: 'Ranges must be numbers. Optional caps cannot be negative.',
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 6; index += 2)
|
||||
if (
|
||||
(values[index] === null) !== (values[index + 1] === null) ||
|
||||
(values[index] !== null &&
|
||||
Number(values[index]) > Number(values[index + 1]))
|
||||
)
|
||||
return fail(400, {
|
||||
error: 'Each enabled source needs a valid minimum and maximum.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?',
|
||||
)
|
||||
).get(name, unit, id || 'new')
|
||||
)
|
||||
return fail(409, {
|
||||
error: 'That attribute and unit combination already exists.',
|
||||
});
|
||||
const now = new Date().toISOString(),
|
||||
statId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(
|
||||
name,
|
||||
description || null,
|
||||
type,
|
||||
category,
|
||||
calculation,
|
||||
unit,
|
||||
...values,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
statId,
|
||||
name,
|
||||
description || null,
|
||||
type,
|
||||
category,
|
||||
calculation,
|
||||
unit,
|
||||
...values,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'attribute.updated' : 'attribute.created',
|
||||
entityId: statId,
|
||||
before,
|
||||
after: await auditRecord('stat_attributes', statId),
|
||||
});
|
||||
const savedItem = await auditRecord('stat_attributes', statId);
|
||||
return {
|
||||
saved: true,
|
||||
editorMode: id ? 'edit' : 'add',
|
||||
savedItem: {
|
||||
...savedItem,
|
||||
unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit,
|
||||
},
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('stat_attributes', id);
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'attribute.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('stat_attributes', id),
|
||||
});
|
||||
return { archived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
getPermissions,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||
@@ -8,83 +11,137 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const reversibleActions = new Set([
|
||||
'attribute.updated', 'attribute.archived',
|
||||
'brand_set.archived', 'gear.archived', 'weapon.archived',
|
||||
'role.updated', 'access.updated'
|
||||
'attribute.updated',
|
||||
'attribute.archived',
|
||||
'brand_set.archived',
|
||||
'gear.archived',
|
||||
'weapon.archived',
|
||||
'role.updated',
|
||||
'access.updated',
|
||||
]);
|
||||
|
||||
const parseSnapshot = (value: unknown): Record<string, any> | null => {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; }
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map(normalize);
|
||||
return normalized.every((item) => ['string', 'number', 'boolean'].includes(typeof item))
|
||||
? normalized.sort((left, right) => String(left).localeCompare(String(right)))
|
||||
: normalized;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, normalize(item)]));
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map(normalize);
|
||||
return normalized.every((item) =>
|
||||
['string', 'number', 'boolean'].includes(typeof item),
|
||||
)
|
||||
? normalized.sort((left, right) =>
|
||||
String(left).localeCompare(String(right)),
|
||||
)
|
||||
: normalized;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, normalize(item)]),
|
||||
);
|
||||
};
|
||||
const comparable = (value: unknown): string => JSON.stringify(normalize(value));
|
||||
|
||||
const sameRecord = (current: Record<string, unknown> | null, expected: Record<string, unknown> | null) => {
|
||||
if (!current || !expected) return false;
|
||||
return Object.entries(expected).every(([key, value]) =>
|
||||
!['created_at', 'updated_at'].includes(key) && comparable(current[key]) === comparable(value)
|
||||
);
|
||||
const sameRecord = (
|
||||
current: Record<string, unknown> | null,
|
||||
expected: Record<string, unknown> | null,
|
||||
) => {
|
||||
if (!current || !expected) return false;
|
||||
return Object.entries(expected).every(
|
||||
([key, value]) =>
|
||||
!['created_at', 'updated_at'].includes(key) &&
|
||||
comparable(current[key]) === comparable(value),
|
||||
);
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'audit.view');
|
||||
const canRevert = (await getPermissions(operator)).includes('*') || (await getPermissions(operator)).includes('audit.revert');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
|
||||
const category = url.searchParams.get('category')?.trim() ?? '';
|
||||
const action = url.searchParams.get('action')?.trim() ?? '';
|
||||
const sort = getRegistrySort(url, {
|
||||
event: {
|
||||
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
|
||||
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC'
|
||||
},
|
||||
operator: {
|
||||
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
|
||||
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC'
|
||||
},
|
||||
target: { asc: 'audit_log.entity_id COLLATE NOCASE ASC', desc: 'audit_log.entity_id COLLATE NOCASE DESC' },
|
||||
time: {
|
||||
asc: 'audit_log.created_at ASC',
|
||||
desc: 'audit_log.created_at DESC',
|
||||
defaultDirection: 'desc'
|
||||
}
|
||||
}, 'time', 'desc');
|
||||
const conditions: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (query) {
|
||||
conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'audit.view',
|
||||
);
|
||||
const canRevert =
|
||||
(await getPermissions(operator)).includes('*') ||
|
||||
(await getPermissions(operator)).includes('audit.revert');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
|
||||
const category = url.searchParams.get('category')?.trim() ?? '';
|
||||
const action = url.searchParams.get('action')?.trim() ?? '';
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
event: {
|
||||
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
|
||||
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC',
|
||||
},
|
||||
operator: {
|
||||
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
|
||||
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC',
|
||||
},
|
||||
target: {
|
||||
asc: 'audit_log.entity_id COLLATE NOCASE ASC',
|
||||
desc: 'audit_log.entity_id COLLATE NOCASE DESC',
|
||||
},
|
||||
time: {
|
||||
asc: 'audit_log.created_at ASC',
|
||||
desc: 'audit_log.created_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'time',
|
||||
'desc',
|
||||
);
|
||||
const conditions: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (query) {
|
||||
conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR audit_log.before_json LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR audit_log.after_json LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
args.push(pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
if (category === 'user') conditions.push("audit_log.entity_type IN ('user','user_access','user_moderation','identity')");
|
||||
else if (category === 'access_policy') conditions.push("audit_log.entity_type IN ('role','role_management')");
|
||||
else if (category) { conditions.push('audit_log.entity_type=?'); args.push(category); }
|
||||
if (action) { conditions.push('audit_log.action=?'); args.push(action); }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [total, categories, actions, summary] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM audit_log
|
||||
JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`)).get(...args),
|
||||
(await database.prepare('SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type')).all(),
|
||||
(await database.prepare('SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action')).all(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
args.push(pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
if (category === 'user')
|
||||
conditions.push(
|
||||
"audit_log.entity_type IN ('user','user_access','user_moderation','identity')",
|
||||
);
|
||||
else if (category === 'access_policy')
|
||||
conditions.push("audit_log.entity_type IN ('role','role_management')");
|
||||
else if (category) {
|
||||
conditions.push('audit_log.entity_type=?');
|
||||
args.push(category);
|
||||
}
|
||||
if (action) {
|
||||
conditions.push('audit_log.action=?');
|
||||
args.push(action);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [total, categories, actions, summary] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM audit_log
|
||||
JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`)
|
||||
).get(...args),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
|
||||
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
|
||||
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
|
||||
@@ -93,95 +150,315 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
|
||||
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration,
|
||||
SUM(CASE WHEN entity_type='ai_assistance' THEN 1 ELSE 0 END) AS ai_assistance
|
||||
FROM audit_log`)).get()
|
||||
]);
|
||||
const pagination = getPagination(url, Number(total?.total ?? 0));
|
||||
const entries = await (await database.prepare(`SELECT audit_log.*,
|
||||
FROM audit_log`)
|
||||
).get(),
|
||||
]);
|
||||
const pagination = getPagination(url, Number(total?.total ?? 0));
|
||||
const entries = await (
|
||||
await database.prepare(`SELECT audit_log.*,
|
||||
COALESCE(user_profiles.display_name,users.username) AS actor_name,users.login_username AS actor_login
|
||||
FROM audit_log JOIN users ON users.id=audit_log.actor_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log
|
||||
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : [];
|
||||
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
|
||||
return {
|
||||
entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })),
|
||||
categories, actions, summary, pagination, filters: { query, category, action }, canRevert, sort
|
||||
};
|
||||
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)
|
||||
).all(...args, pagination.perPage, pagination.offset);
|
||||
const revertedIds = entries.length
|
||||
? await (
|
||||
await database.prepare(`SELECT entity_id FROM audit_log
|
||||
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)
|
||||
).all(...entries.map((entry) => entry.id))
|
||||
: [];
|
||||
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
reversible:
|
||||
canRevert &&
|
||||
reversibleActions.has(String(entry.action)) &&
|
||||
Boolean(entry.before_json) &&
|
||||
!reverted.has(String(entry.id)),
|
||||
reverted: reverted.has(String(entry.id)),
|
||||
})),
|
||||
categories,
|
||||
actions,
|
||||
summary,
|
||||
pagination,
|
||||
filters: { query, category, action },
|
||||
canRevert,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
revert: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'audit.revert');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const isFullAdmin = operatorPermissions.includes('*');
|
||||
const eventId = String((await request.formData()).get('eventId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const event = await (await database.prepare('SELECT * FROM audit_log WHERE id=?')).get(eventId);
|
||||
if (!event) return fail(404, { revertError: 'Audit event not found.' });
|
||||
if (!reversibleActions.has(String(event.action)) || !event.before_json) return fail(400, { revertError: 'This event does not contain a supported reversible change.' });
|
||||
if (await (await database.prepare("SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?")).get(eventId)) {
|
||||
return fail(409, { revertError: 'This audit event has already been reverted.' });
|
||||
}
|
||||
const before = parseSnapshot(event.before_json), after = parseSnapshot(event.after_json);
|
||||
if (!before || !after) return fail(400, { revertError: 'The stored snapshots cannot be safely interpreted.' });
|
||||
const action = String(event.action), targetId = String(event.entity_id);
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | null = null;
|
||||
try {
|
||||
if (action.startsWith('attribute.')) {
|
||||
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
|
||||
await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
|
||||
.run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
|
||||
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||
} else if (action.endsWith('.archived')) {
|
||||
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
|
||||
if (!table) return fail(400, { revertError: 'Unsupported archive category.' });
|
||||
current = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
|
||||
if (!sameRecord(current, after)) return fail(409, { revertError: 'The record changed after it was archived. Reversion was blocked.' });
|
||||
await (await database.prepare(`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`)).run(before.active, new Date().toISOString(), targetId);
|
||||
restored = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
|
||||
} else if (action === 'role.updated') {
|
||||
const currentRole = await (await database.prepare('SELECT name,description FROM roles WHERE id=?')).get(targetId);
|
||||
const currentPermissions = await (await database.prepare(`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(targetId);
|
||||
current = { ...currentRole, permissions: currentPermissions.map((row) => row.name) };
|
||||
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The role changed after this event. Reversion was blocked.' });
|
||||
const permissionNames = Array.isArray(before.permissions) ? before.permissions.map(String) : [];
|
||||
if (permissionNames.includes('system.admin') && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore unrestricted system access.' });
|
||||
const permissionRows = permissionNames.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`)).all(...permissionNames) : [];
|
||||
if (permissionRows.length !== permissionNames.length) return fail(409, { revertError: 'A previous permission no longer exists.' });
|
||||
await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(before.name, before.description ?? null, targetId);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(targetId);
|
||||
for (const permission of permissionRows) await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(targetId, permission.id);
|
||||
restored = { name: before.name, description: before.description ?? null, permissions: permissionNames };
|
||||
} else if (action === 'access.updated') {
|
||||
const currentRoles = await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(targetId);
|
||||
const currentPermissions = await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(targetId);
|
||||
current = { roleIds: currentRoles.map((row) => row.role_id), permissionIds: currentPermissions.map((row) => row.permission_id) };
|
||||
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The user’s access changed after this event. Reversion was blocked.' });
|
||||
const roleIds = Array.isArray(before.roleIds) ? before.roleIds.map(String) : [];
|
||||
const permissionIds = Array.isArray(before.permissionIds) ? before.permissionIds.map(String) : [];
|
||||
const restoresAdminRole = roleIds.includes('role-admin');
|
||||
const restoresSystemPermission = permissionIds.includes('permission-system-admin');
|
||||
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore administrator access.' });
|
||||
const roleCount = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
|
||||
const permissionCount = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
|
||||
if (roleCount !== roleIds.length || permissionCount !== permissionIds.length) return fail(409, { revertError: 'A previous role or permission no longer exists.' });
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(targetId);
|
||||
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(targetId);
|
||||
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(targetId, roleId);
|
||||
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(targetId, permissionId);
|
||||
restored = { roleIds, permissionIds };
|
||||
} else {
|
||||
return fail(400, { revertError: 'This change requires a specialized recovery workflow that is not available yet.' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return fail(409, { revertError: 'The previous state could not be restored because related data has changed.' });
|
||||
}
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'audit_control', action: 'audit.reverted', entityId: eventId, before: current, after: { originalEventId: eventId, originalAction: action, targetId, restored } });
|
||||
return { reverted: true, resultToken: randomUUID() };
|
||||
}
|
||||
revert: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'audit.revert',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const isFullAdmin = operatorPermissions.includes('*');
|
||||
const eventId = String((await request.formData()).get('eventId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const event = await (
|
||||
await database.prepare('SELECT * FROM audit_log WHERE id=?')
|
||||
).get(eventId);
|
||||
if (!event) return fail(404, { revertError: 'Audit event not found.' });
|
||||
if (!reversibleActions.has(String(event.action)) || !event.before_json)
|
||||
return fail(400, {
|
||||
revertError:
|
||||
'This event does not contain a supported reversible change.',
|
||||
});
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
"SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?",
|
||||
)
|
||||
).get(eventId)
|
||||
) {
|
||||
return fail(409, {
|
||||
revertError: 'This audit event has already been reverted.',
|
||||
});
|
||||
}
|
||||
const before = parseSnapshot(event.before_json),
|
||||
after = parseSnapshot(event.after_json);
|
||||
if (!before || !after)
|
||||
return fail(400, {
|
||||
revertError: 'The stored snapshots cannot be safely interpreted.',
|
||||
});
|
||||
const action = String(event.action),
|
||||
targetId = String(event.entity_id);
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | null = null;
|
||||
try {
|
||||
if (action.startsWith('attribute.')) {
|
||||
current =
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(targetId)) ?? null;
|
||||
if (!sameRecord(current, after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The attribute changed after this event. Reversion was blocked to preserve newer work.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
before.name,
|
||||
before.description ?? null,
|
||||
before.type,
|
||||
before.category,
|
||||
before.calculation_type,
|
||||
before.unit,
|
||||
before.gear_min,
|
||||
before.gear_max,
|
||||
before.weapon_min,
|
||||
before.weapon_max,
|
||||
before.skill_chip_min,
|
||||
before.skill_chip_max,
|
||||
before.cap_value,
|
||||
before.active,
|
||||
new Date().toISOString(),
|
||||
targetId,
|
||||
);
|
||||
restored =
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(targetId)) ?? null;
|
||||
} else if (action.endsWith('.archived')) {
|
||||
const table = action.startsWith('brand_set.')
|
||||
? 'brand_sets'
|
||||
: action.startsWith('gear.')
|
||||
? 'gear'
|
||||
: action.startsWith('weapon.')
|
||||
? 'weapons'
|
||||
: '';
|
||||
if (!table)
|
||||
return fail(400, { revertError: 'Unsupported archive category.' });
|
||||
current =
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
).get(targetId)) ?? null;
|
||||
if (!sameRecord(current, after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The record changed after it was archived. Reversion was blocked.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(before.active, new Date().toISOString(), targetId);
|
||||
restored =
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
).get(targetId)) ?? null;
|
||||
} else if (action === 'role.updated') {
|
||||
const currentRole = await (
|
||||
await database.prepare(
|
||||
'SELECT name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(targetId);
|
||||
const currentPermissions = await (
|
||||
await database.prepare(
|
||||
`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`,
|
||||
)
|
||||
).all(targetId);
|
||||
current = {
|
||||
...currentRole,
|
||||
permissions: currentPermissions.map((row) => row.name),
|
||||
};
|
||||
if (comparable(current) !== comparable(after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The role changed after this event. Reversion was blocked.',
|
||||
});
|
||||
const permissionNames = Array.isArray(before.permissions)
|
||||
? before.permissions.map(String)
|
||||
: [];
|
||||
if (permissionNames.includes('system.admin') && !isFullAdmin)
|
||||
return fail(403, {
|
||||
revertError:
|
||||
'Only a full administrator can restore unrestricted system access.',
|
||||
});
|
||||
const permissionRows = permissionNames.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionNames)
|
||||
: [];
|
||||
if (permissionRows.length !== permissionNames.length)
|
||||
return fail(409, {
|
||||
revertError: 'A previous permission no longer exists.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE roles SET name=?,description=? WHERE id=?',
|
||||
)
|
||||
).run(before.name, before.description ?? null, targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(targetId);
|
||||
for (const permission of permissionRows)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, permission.id);
|
||||
restored = {
|
||||
name: before.name,
|
||||
description: before.description ?? null,
|
||||
permissions: permissionNames,
|
||||
};
|
||||
} else if (action === 'access.updated') {
|
||||
const currentRoles = await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(targetId);
|
||||
const currentPermissions = await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(targetId);
|
||||
current = {
|
||||
roleIds: currentRoles.map((row) => row.role_id),
|
||||
permissionIds: currentPermissions.map((row) => row.permission_id),
|
||||
};
|
||||
if (comparable(current) !== comparable(after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The user’s access changed after this event. Reversion was blocked.',
|
||||
});
|
||||
const roleIds = Array.isArray(before.roleIds)
|
||||
? before.roleIds.map(String)
|
||||
: [];
|
||||
const permissionIds = Array.isArray(before.permissionIds)
|
||||
? before.permissionIds.map(String)
|
||||
: [];
|
||||
const restoresAdminRole = roleIds.includes('role-admin');
|
||||
const restoresSystemPermission = permissionIds.includes(
|
||||
'permission-system-admin',
|
||||
);
|
||||
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin)
|
||||
return fail(403, {
|
||||
revertError:
|
||||
'Only a full administrator can restore administrator access.',
|
||||
});
|
||||
const roleCount = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const permissionCount = permissionIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...permissionIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
roleCount !== roleIds.length ||
|
||||
permissionCount !== permissionIds.length
|
||||
)
|
||||
return fail(409, {
|
||||
revertError: 'A previous role or permission no longer exists.',
|
||||
});
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(targetId);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, permissionId);
|
||||
restored = { roleIds, permissionIds };
|
||||
} else {
|
||||
return fail(400, {
|
||||
revertError:
|
||||
'This change requires a specialized recovery workflow that is not available yet.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The previous state could not be restored because related data has changed.',
|
||||
});
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: eventId,
|
||||
before: current,
|
||||
after: {
|
||||
originalEventId: eventId,
|
||||
originalAction: action,
|
||||
targetId,
|
||||
restored,
|
||||
},
|
||||
});
|
||||
return { reverted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,21 +12,34 @@ import { createNotification } from '$lib/server/notifications';
|
||||
const statuses = ['pending', 'approved', 'removed', 'withdrawn', 'superseded'];
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const conditions = ['r.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (search) {
|
||||
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
conditions.push("(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)");
|
||||
args.push(value, value);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const reviews = await (await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
|
||||
await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '')
|
||||
? String(url.searchParams.get('status'))
|
||||
: 'pending';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const conditions = ['r.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (search) {
|
||||
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
conditions.push(
|
||||
"(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)",
|
||||
);
|
||||
args.push(value, value);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`,
|
||||
)
|
||||
).get(...args)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const pagination = getPagination(url, total);
|
||||
const reviews = await (
|
||||
await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
|
||||
CASE WHEN a.source='upload' AND a.updated_at=r.avatar_updated_at THEN 1 ELSE 0 END AS is_current,
|
||||
COALESCE(rp.display_name,ru.username) AS reviewer_name
|
||||
FROM avatar_reviews r
|
||||
@@ -35,56 +48,145 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN user_avatars a ON a.user_id=r.user_id
|
||||
LEFT JOIN users ru ON ru.id=r.reviewed_by
|
||||
LEFT JOIN user_profiles rp ON rp.user_id=ru.id
|
||||
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status')).all();
|
||||
return { reviews, pagination, status, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])) };
|
||||
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...args, pagination.perPage, pagination.offset);
|
||||
const summaryRows = await (
|
||||
await database.prepare(
|
||||
'SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status',
|
||||
)
|
||||
).all();
|
||||
return {
|
||||
reviews,
|
||||
pagination,
|
||||
status,
|
||||
search,
|
||||
summary: Object.fromEntries(
|
||||
summaryRows.map((row) => [String(row.status), Number(row.count)]),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
approve: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '').trim().slice(0, 500);
|
||||
const database = await getDatabase();
|
||||
const review = await (await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
|
||||
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
|
||||
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at) {
|
||||
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'"))
|
||||
.run(now, reviewer.id, note || null, id);
|
||||
await (await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
|
||||
.run(now, reviewer.id, note || 'Oversight approved the current custom avatar.', review.user_id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } });
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
},
|
||||
remove: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '').trim().slice(0, 500);
|
||||
if (note.length < 3) return fail(400, { avatarReviewError: 'Add a brief reason for removing this avatar.' });
|
||||
const database = await getDatabase();
|
||||
const review = await (await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
|
||||
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
|
||||
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at || review.current_storage_key !== review.storage_key) {
|
||||
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
|
||||
}
|
||||
const before = { reviewId: id, status: 'pending', storageKey: review.storage_key, byteSize: review.byte_size };
|
||||
await deleteUploadedAvatar(database, String(review.user_id));
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
|
||||
.run(now, reviewer.id, note, id);
|
||||
await (await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
|
||||
.run(now, reviewer.id, note, review.user_id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
|
||||
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
}
|
||||
approve: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'avatars.review',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
const database = await getDatabase();
|
||||
const review = await (
|
||||
await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)
|
||||
).get(id);
|
||||
if (!review)
|
||||
return fail(404, {
|
||||
avatarReviewError: 'That pending avatar review no longer exists.',
|
||||
});
|
||||
if (
|
||||
review.source !== 'upload' ||
|
||||
review.current_updated_at !== review.avatar_updated_at
|
||||
) {
|
||||
return fail(409, {
|
||||
avatarReviewError:
|
||||
'This image is no longer the agent’s current custom avatar.',
|
||||
});
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(now, reviewer.id, note || null, id);
|
||||
await (
|
||||
await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`)
|
||||
).run(
|
||||
now,
|
||||
reviewer.id,
|
||||
note || 'Oversight approved the current custom avatar.',
|
||||
review.user_id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.approved',
|
||||
entityId: String(review.user_id),
|
||||
before: { reviewId: id, status: 'pending' },
|
||||
after: { reviewId: id, status: 'approved', note: note || null },
|
||||
});
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
},
|
||||
remove: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'avatars.review',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
if (note.length < 3)
|
||||
return fail(400, {
|
||||
avatarReviewError: 'Add a brief reason for removing this avatar.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const review = await (
|
||||
await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)
|
||||
).get(id);
|
||||
if (!review)
|
||||
return fail(404, {
|
||||
avatarReviewError: 'That pending avatar review no longer exists.',
|
||||
});
|
||||
if (
|
||||
review.source !== 'upload' ||
|
||||
review.current_updated_at !== review.avatar_updated_at ||
|
||||
review.current_storage_key !== review.storage_key
|
||||
) {
|
||||
return fail(409, {
|
||||
avatarReviewError:
|
||||
'This image is no longer the agent’s current custom avatar.',
|
||||
});
|
||||
}
|
||||
const before = {
|
||||
reviewId: id,
|
||||
status: 'pending',
|
||||
storageKey: review.storage_key,
|
||||
byteSize: review.byte_size,
|
||||
};
|
||||
await deleteUploadedAvatar(database, String(review.user_id));
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?",
|
||||
)
|
||||
).run(now, reviewer.id, note, id);
|
||||
await (
|
||||
await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`)
|
||||
).run(now, reviewer.id, note, review.user_id);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: String(review.user_id),
|
||||
before,
|
||||
after: { reviewId: id, status: 'removed', source: 'default', note },
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(review.user_id),
|
||||
category: 'isac',
|
||||
source: 'ISAC AVATAR OVERSIGHT',
|
||||
title: 'Custom avatar removed',
|
||||
body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`,
|
||||
link: '/account#avatar',
|
||||
mandatory: true,
|
||||
});
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,39 +1,99 @@
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import {
|
||||
applyIconMutation,
|
||||
gameDataIconMetadata,
|
||||
gameDataIconUrl,
|
||||
GameDataIconValidationError,
|
||||
readIconMutation,
|
||||
} from '$lib/server/game-data-icons';
|
||||
import {
|
||||
applyGameDataRender,
|
||||
ManagedImageValidationError,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
readGameDataRenderMutation,
|
||||
} from '$lib/server/managed-images';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
const thresholds = [2, 3, 4] as const;
|
||||
|
||||
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
||||
render
|
||||
? {
|
||||
id: render.id,
|
||||
name: render.name,
|
||||
category: render.category,
|
||||
tags_json: render.tags_json,
|
||||
original_filename: render.original_filename,
|
||||
byte_size: render.byte_size,
|
||||
width: render.width,
|
||||
height: render.height,
|
||||
source_entity_type: render.source_entity_type,
|
||||
source_entity_id: render.source_entity_id,
|
||||
created_at: render.created_at,
|
||||
updated_at: render.updated_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const [pendingBrands, pendingStats] = await Promise.all([
|
||||
getPendingEntities(user.id, [{ module: 'brands', action: 'save', kind: 'brand', fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }])
|
||||
]);
|
||||
const mappedPendingBrands = pendingBrands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: '',
|
||||
effects: thresholds.map((pieces) => ({
|
||||
pieces_required: pieces,
|
||||
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
|
||||
value: Number(brand[`value${pieces}`] ?? 0)
|
||||
}))
|
||||
}));
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const [pendingBrands, pendingStats] = await Promise.all([
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'brands',
|
||||
action: 'save',
|
||||
kind: 'brand',
|
||||
fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'],
|
||||
},
|
||||
]),
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['unit'],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const mappedPendingBrands = pendingBrands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: '',
|
||||
renderUrl: '',
|
||||
effects: thresholds.map((pieces) => ({
|
||||
pieces_required: pieces,
|
||||
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
|
||||
value: Number(brand[`value${pieces}`] ?? 0),
|
||||
})),
|
||||
}));
|
||||
const summary = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)).get();
|
||||
const filter = ['active', 'archived', 'complete'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE((
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)
|
||||
).get();
|
||||
const filter = ['active', 'archived', 'complete'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE((
|
||||
SELECT attributes.name FROM brand_set_effects effect
|
||||
JOIN stat_attributes attributes ON attributes.id=effect.stat_attribute_id
|
||||
WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces}
|
||||
@@ -43,102 +103,356 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces}
|
||||
LIMIT 1
|
||||
), 0) ${direction}, name COLLATE NOCASE ASC, id ASC`;
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
|
||||
bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') },
|
||||
bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') },
|
||||
bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') },
|
||||
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'complete' ? ['(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(...args);
|
||||
const visiblePending = mappedPendingBrands.filter((brand) => filter !== 'archived' && (!search || String(brand.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))).sort((left, right) => {
|
||||
const pieces = sort.key.startsWith('bonus') ? Number(sort.key.slice(5)) : 0;
|
||||
const valueFor = (brand: any) => pieces
|
||||
? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}`
|
||||
: sort.key === 'state' ? String(brand.active ?? 1) : String(brand.name);
|
||||
const result = valueFor(left).localeCompare(valueFor(right), undefined, { sensitivity: 'base', numeric: true });
|
||||
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
|
||||
]);
|
||||
return {
|
||||
brands: [...pendingOnPage, ...brands.map((brand) => {
|
||||
const icon = icons.find((item) => item.entity_id === brand.id);
|
||||
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
|
||||
})],
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) },
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
sort
|
||||
};
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC, id DESC',
|
||||
},
|
||||
bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') },
|
||||
bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') },
|
||||
bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') },
|
||||
state: {
|
||||
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(requestedItemId
|
||||
? ['id=?']
|
||||
: filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'archived'
|
||||
? ['active=0']
|
||||
: filter === 'complete'
|
||||
? [
|
||||
'(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3',
|
||||
]
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||||
: []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = [
|
||||
...(requestedItemId ? [requestedItemId] : []),
|
||||
...(!requestedItemId && search ? [pattern] : []),
|
||||
];
|
||||
const filtered = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)
|
||||
).get(...args);
|
||||
const visiblePending = mappedPendingBrands
|
||||
.filter((brand) =>
|
||||
requestedItemId
|
||||
? brand.id === requestedItemId
|
||||
: filter !== 'archived' &&
|
||||
(!search ||
|
||||
String(brand.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const pieces = sort.key.startsWith('bonus')
|
||||
? Number(sort.key.slice(5))
|
||||
: 0;
|
||||
const valueFor = (brand: any) =>
|
||||
pieces
|
||||
? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}`
|
||||
: sort.key === 'state'
|
||||
? String(brand.active ?? 1)
|
||||
: String(brand.name);
|
||||
const result = valueFor(left).localeCompare(valueFor(right), undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
});
|
||||
return (
|
||||
(sort.direction === 'asc' ? result : -result) ||
|
||||
String(left.name).localeCompare(String(right.name), undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
);
|
||||
});
|
||||
const pagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + visiblePending.length,
|
||||
);
|
||||
const pendingOnPage =
|
||||
pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [brands, effects, stats, effectSummary, icons, renders] =
|
||||
await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(
|
||||
`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS total FROM brand_set_effects',
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='brand_set_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
return {
|
||||
brands: [
|
||||
...pendingOnPage,
|
||||
...brands.map((brand) => {
|
||||
const icon = icons.find((item) => item.entity_id === brand.id);
|
||||
const render = renders.find(
|
||||
(item) => item.source_entity_id === brand.id,
|
||||
);
|
||||
return {
|
||||
...brand,
|
||||
iconUrl: icon
|
||||
? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at)
|
||||
: '',
|
||||
renderUrl: render
|
||||
? managedImageUrl(String(render.id), render.updated_at)
|
||||
: '',
|
||||
effects: effects.filter((effect) => effect.brand_set_id === brand.id),
|
||||
};
|
||||
}),
|
||||
],
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingBrands.length,
|
||||
pending: pendingBrands.length,
|
||||
effects: Number(effectSummary?.total ?? 0),
|
||||
},
|
||||
pagination,
|
||||
filter,
|
||||
requestedItemId,
|
||||
search,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
let iconMutation;
|
||||
try { iconMutation = await readIconMutation(form); }
|
||||
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { brandError: error.message }); throw error; }
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
|
||||
const effects = thresholds.map((pieces) => ({ pieces, statId: String(form.get(`stat${pieces}`) ?? ''), value: Number(form.get(`value${pieces}`)) }));
|
||||
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) {
|
||||
return fail(400, { brandError: 'Every threshold needs an attribute and a non-negative value.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { brandError: 'That brand set already exists.' });
|
||||
}
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
||||
const brandId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
|
||||
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
||||
for (const effect of effects) {
|
||||
await (await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
|
||||
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
|
||||
}
|
||||
await applyIconMutation(database, 'brand_set', brandId, iconMutation, operator.id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), icon: await gameDataIconMetadata(database, 'brand_set', brandId), effects } });
|
||||
return { brandSaved: true, brandMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('brand_sets', id);
|
||||
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
||||
return { brandArchived: true, resultToken: randomUUID() };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('brand_sets', id);
|
||||
if (!before) return fail(404, { brandError: 'Brand set not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.unarchived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
||||
return { brandUnarchived: true, resultToken: randomUUID() };
|
||||
}
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
let iconMutation;
|
||||
try {
|
||||
iconMutation = await readIconMutation(form);
|
||||
} catch (error) {
|
||||
if (error instanceof GameDataIconValidationError)
|
||||
return fail(400, { brandError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file)
|
||||
renderMutation.processed = await processManagedImage(
|
||||
renderMutation.file,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { brandError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return fail(400, {
|
||||
brandError: 'Brand set names must be 2–80 characters.',
|
||||
});
|
||||
const effects = thresholds.map((pieces) => ({
|
||||
pieces,
|
||||
statId: String(form.get(`stat${pieces}`) ?? ''),
|
||||
value: Number(form.get(`value${pieces}`)),
|
||||
}));
|
||||
if (
|
||||
effects.some(
|
||||
(effect) =>
|
||||
!effect.statId || !Number.isFinite(effect.value) || effect.value < 0,
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
brandError:
|
||||
'Every threshold needs an attribute and a non-negative value.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { brandError: 'That brand set already exists.' });
|
||||
}
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length)
|
||||
return fail(400, {
|
||||
brandError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const brandId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
|
||||
now = new Date().toISOString();
|
||||
const before = id
|
||||
? {
|
||||
record: await auditRecord('brand_sets', id),
|
||||
icon: await gameDataIconMetadata(database, 'brand_set', id),
|
||||
effects: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required',
|
||||
)
|
||||
).all(id),
|
||||
render: renderAuditMetadata(
|
||||
await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM managed_images WHERE source_entity_type='brand_set_render' AND source_entity_id=?",
|
||||
)
|
||||
).get(id),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(name, now, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(brandId, name, now, now);
|
||||
for (const effect of effects) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
brandId,
|
||||
effect.pieces,
|
||||
effect.statId,
|
||||
effect.value,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
await applyIconMutation(
|
||||
database,
|
||||
'brand_set',
|
||||
brandId,
|
||||
iconMutation,
|
||||
operator.id,
|
||||
);
|
||||
const render = renderAuditMetadata(
|
||||
await applyGameDataRender(
|
||||
database,
|
||||
'brand_set',
|
||||
brandId,
|
||||
name,
|
||||
renderMutation,
|
||||
operator.id,
|
||||
),
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'brand_set.updated' : 'brand_set.created',
|
||||
entityId: brandId,
|
||||
before,
|
||||
after: {
|
||||
record: await auditRecord('brand_sets', brandId),
|
||||
icon: await gameDataIconMetadata(database, 'brand_set', brandId),
|
||||
effects,
|
||||
render,
|
||||
},
|
||||
});
|
||||
return {
|
||||
brandSaved: true,
|
||||
brandMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('brand_sets', id);
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'brand_set.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('brand_sets', id),
|
||||
});
|
||||
return { brandArchived: true, resultToken: randomUUID() };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('brand_sets', id);
|
||||
if (!before) return fail(404, { brandError: 'Brand set not found.' });
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'brand_set.unarchived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('brand_sets', id),
|
||||
});
|
||||
return { brandUnarchived: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,20 +5,28 @@
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
let { data, form } = $props();
|
||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||
type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] };
|
||||
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
||||
type Editor = { id: string; name: string; iconUrl: string; renderUrl: string; active: number; effects: Effect[] };
|
||||
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', renderUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
||||
let editing = $state<Editor | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
|
||||
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
|
||||
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
|
||||
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
||||
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, renderUrl: brand.renderUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.brands.find((brand) => brand.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if ((form?.brandArchived || form?.brandUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = null;
|
||||
@@ -26,7 +34,10 @@
|
||||
}
|
||||
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.brandMode === 'add') iconResetToken += 1;
|
||||
if (form.brandMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
editing = form.brandMode === 'add' ? blank() : null;
|
||||
}
|
||||
});
|
||||
@@ -40,6 +51,7 @@
|
||||
<input type="hidden" name="id" value={editing.id} />
|
||||
<div class="brand-editor-body">
|
||||
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="BRAND SET RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||
<div class="effect-editor">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const allowed = (permissions: string[], permission: string) =>
|
||||
permissions.includes('*') || permissions.includes(permission);
|
||||
const reason = (value: unknown) =>
|
||||
String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const permissions = await getPermissions(user);
|
||||
const canModerate = allowed(permissions, 'community.guides.moderate');
|
||||
const canCurate = allowed(permissions, 'guides.curate');
|
||||
if (!canModerate && !canCurate)
|
||||
error(403, 'Guide Oversight permission required');
|
||||
const database = await getDatabase();
|
||||
const [requests, reports, guides] = await Promise.all([
|
||||
canModerate
|
||||
? (
|
||||
await database.prepare(`SELECT request.*,revision.title,revision.deck,revision.plain_text,
|
||||
scan.status AS scan_status,scan.risk_level,scan.reason AS scan_reason,scan.provider,scan.model,
|
||||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,articles.slug
|
||||
FROM article_publication_requests request JOIN articles ON articles.id=request.article_id
|
||||
JOIN article_revisions revision ON revision.id=request.revision_id
|
||||
LEFT JOIN content_moderation_scans scan ON scan.id=request.scan_id
|
||||
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
WHERE request.status='pending_review' ORDER BY request.requested_at`)
|
||||
).all()
|
||||
: [],
|
||||
canModerate
|
||||
? (
|
||||
await database.prepare(`SELECT report.*,revision.title,revision.deck,articles.slug,
|
||||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author
|
||||
FROM user_content_reports report JOIN articles ON articles.id=report.content_id AND articles.kind='guide'
|
||||
JOIN article_revisions revision ON revision.id=report.article_revision_id
|
||||
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
WHERE report.content_type='guide' AND report.status='pending' ORDER BY report.created_at`)
|
||||
).all()
|
||||
: [],
|
||||
canCurate
|
||||
? (
|
||||
await database.prepare(`SELECT articles.id,articles.slug,revision.id AS revision_id,revision.title,
|
||||
curation.pinned_order,COALESCE(curation.featured,0) AS featured,COALESCE(curation.recommended,0) AS recommended
|
||||
FROM articles JOIN article_revisions revision ON revision.id=articles.published_revision_id
|
||||
LEFT JOIN guide_curations curation ON curation.article_id=articles.id AND curation.curated_revision_id=revision.id
|
||||
WHERE articles.kind='guide' AND articles.visibility='public' AND articles.deleted_at IS NULL
|
||||
ORDER BY articles.published_at DESC`)
|
||||
).all()
|
||||
: [],
|
||||
]);
|
||||
return { canModerate, canCurate, requests, reports, guides };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
decide: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { guideError: 'Sign in required.' });
|
||||
const permissions = await getPermissions(user);
|
||||
if (!allowed(permissions, 'community.guides.moderate'))
|
||||
return fail(403, { guideError: 'Guide moderation permission required.' });
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const decision = String(form.get('decision') ?? '');
|
||||
const reviewReason = reason(form.get('reason'));
|
||||
if (
|
||||
!id ||
|
||||
!['approve', 'reject'].includes(decision) ||
|
||||
reviewReason.length < 3
|
||||
)
|
||||
return fail(400, {
|
||||
guideError: 'Choose a decision and provide a reason.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const row = await (
|
||||
await database.prepare(`SELECT request.*,articles.owner_id,articles.slug,articles.published_revision_id,
|
||||
revision.title,revision.deck,revision.plain_text,scan.id AS scan_id,scan.status AS scan_status
|
||||
FROM article_publication_requests request JOIN articles ON articles.id=request.article_id
|
||||
JOIN article_revisions revision ON revision.id=request.revision_id JOIN content_moderation_scans scan ON scan.id=request.scan_id
|
||||
WHERE request.id=? AND request.status='pending_review' AND scan.article_revision_id=request.revision_id`)
|
||||
).get(id);
|
||||
if (!row)
|
||||
return fail(404, {
|
||||
guideError: 'That Guide review is no longer pending.',
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const reviewId = randomUUID();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO content_moderation_reviews(id,scan_id,reviewer_id,decision,reason,created_at) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
reviewId,
|
||||
row.scan_id,
|
||||
user.id,
|
||||
decision === 'approve' ? 'approved' : 'rejected',
|
||||
reviewReason,
|
||||
now,
|
||||
);
|
||||
if (decision === 'approve') {
|
||||
const changed = await (
|
||||
await database.prepare(`UPDATE articles SET published_revision_id=?,pending_revision_id=NULL,visibility=?,moderation_hold=0,
|
||||
published_at=COALESCE(published_at,?),updated_at=? WHERE id=? AND pending_revision_id=?
|
||||
AND COALESCE(published_revision_id,'')=COALESCE(?,'')`)
|
||||
).run(
|
||||
row.revision_id,
|
||||
row.requested_visibility,
|
||||
now,
|
||||
now,
|
||||
row.article_id,
|
||||
row.revision_id,
|
||||
row.base_published_revision_id ?? null,
|
||||
);
|
||||
if (!changed.changes)
|
||||
throw new Error('The Guide changed before approval.');
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM article_search WHERE article_id=?',
|
||||
)
|
||||
).run(row.article_id);
|
||||
if (row.requested_visibility === 'public') {
|
||||
const searchTags = await (
|
||||
await database.prepare(
|
||||
`SELECT GROUP_CONCAT(tags.name,' ') AS names FROM article_revision_tags links JOIN article_tags tags ON tags.id=links.tag_id WHERE links.revision_id=?`,
|
||||
)
|
||||
).get(row.revision_id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO article_search(article_id,title,deck,body,tags) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
row.article_id,
|
||||
row.title,
|
||||
row.deck,
|
||||
row.plain_text,
|
||||
String(searchTags?.names ?? ''),
|
||||
);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM guide_curations WHERE article_id=?',
|
||||
)
|
||||
).run(row.article_id);
|
||||
} else
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE articles SET pending_revision_id=NULL,moderation_hold=0 WHERE id=? AND pending_revision_id=?',
|
||||
)
|
||||
).run(row.article_id, row.revision_id);
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE article_publication_requests SET status=?,decided_by=?,decided_at=?,decision_reason=? WHERE id=? AND status='pending_review'`,
|
||||
)
|
||||
).run(
|
||||
decision === 'approve' ? 'approved' : 'rejected',
|
||||
user.id,
|
||||
now,
|
||||
reviewReason,
|
||||
id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'community_moderation',
|
||||
action: `guide.review_${decision === 'approve' ? 'approved' : 'rejected'}`,
|
||||
entityId: String(row.article_id),
|
||||
before: {
|
||||
requestId: id,
|
||||
revisionId: row.revision_id,
|
||||
scanId: row.scan_id,
|
||||
},
|
||||
after: {
|
||||
reviewId,
|
||||
reason: reviewReason,
|
||||
visibility:
|
||||
decision === 'approve' ? row.requested_visibility : 'unchanged',
|
||||
},
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
guideError:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'The review could not be saved.',
|
||||
});
|
||||
}
|
||||
await createNotification({
|
||||
userId: String(row.owner_id),
|
||||
category: 'isac',
|
||||
source: 'ISAC COMMUNITY OVERSIGHT',
|
||||
title: `Guide ${decision === 'approve' ? 'approved' : 'rejected'}`,
|
||||
body: `${row.title}: ${reviewReason}`,
|
||||
link: `/guides/${encodeURIComponent(String(row.slug))}`,
|
||||
actions: [
|
||||
{
|
||||
label: 'VIEW GUIDE',
|
||||
href: `/guides/${encodeURIComponent(String(row.slug))}`,
|
||||
},
|
||||
],
|
||||
mandatory: true,
|
||||
});
|
||||
return { guideSaved: true };
|
||||
},
|
||||
report: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { guideError: 'Sign in required.' });
|
||||
const permissions = await getPermissions(user);
|
||||
if (!allowed(permissions, 'community.guides.moderate'))
|
||||
return fail(403, { guideError: 'Guide moderation permission required.' });
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const decision = String(form.get('decision') ?? '');
|
||||
const reviewReason = reason(form.get('reason'));
|
||||
if (
|
||||
!id ||
|
||||
!['dismiss', 'hide'].includes(decision) ||
|
||||
reviewReason.length < 3
|
||||
)
|
||||
return fail(400, {
|
||||
guideError: 'Choose a decision and provide a reason.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
let report: Record<string, unknown> | null = null;
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
report =
|
||||
(await (
|
||||
await database.prepare(`SELECT report.*,articles.owner_id,articles.slug,articles.visibility,articles.published_revision_id,revision.title
|
||||
FROM user_content_reports report JOIN articles ON articles.id=report.content_id JOIN article_revisions revision ON revision.id=report.article_revision_id
|
||||
WHERE report.id=? AND report.status='pending' AND report.content_type='guide'`)
|
||||
).get(id)) ?? null;
|
||||
if (!report) throw new Error('That report is no longer pending.');
|
||||
if (decision === 'hide') {
|
||||
const hidden = await (
|
||||
await database.prepare(
|
||||
"UPDATE articles SET visibility='private',moderation_hold=1,updated_at=? WHERE id=? AND published_revision_id=?",
|
||||
)
|
||||
).run(now, report.content_id, report.article_revision_id);
|
||||
if (!hidden.changes)
|
||||
throw new Error(
|
||||
'A newer Guide revision is now public; dismiss this stale report instead.',
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM article_search WHERE article_id=?',
|
||||
)
|
||||
).run(report.content_id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM guide_curations WHERE article_id=?',
|
||||
)
|
||||
).run(report.content_id);
|
||||
}
|
||||
const reviewed = await (
|
||||
await database.prepare(
|
||||
"UPDATE user_content_reports SET status=?,reviewed_at=?,reviewed_by=?,resolution_reason=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(
|
||||
decision === 'hide' ? 'resolved' : 'dismissed',
|
||||
now,
|
||||
user.id,
|
||||
reviewReason,
|
||||
id,
|
||||
);
|
||||
if (!reviewed.changes)
|
||||
throw new Error('Another reviewer already handled this report.');
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'community_moderation',
|
||||
action:
|
||||
decision === 'hide'
|
||||
? 'guide.report_hidden'
|
||||
: 'guide.report_dismissed',
|
||||
entityId: String(report.content_id),
|
||||
before: {
|
||||
reportId: id,
|
||||
revisionId: report.article_revision_id,
|
||||
category: report.category,
|
||||
},
|
||||
after: { reason: reviewReason },
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
guideError:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'The report could not be handled.',
|
||||
});
|
||||
}
|
||||
return { guideSaved: true };
|
||||
},
|
||||
curate: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { guideError: 'Sign in required.' });
|
||||
const permissions = await getPermissions(user);
|
||||
if (!allowed(permissions, 'guides.curate'))
|
||||
return fail(403, { guideError: 'Guide curation permission required.' });
|
||||
const form = await request.formData();
|
||||
const articleId = String(form.get('articleId') ?? '');
|
||||
const placement = String(form.get('placement') ?? '');
|
||||
const enabled = form.get('enabled') === '1';
|
||||
if (!['pinned', 'featured', 'recommended'].includes(placement))
|
||||
return fail(400, { guideError: 'Choose a valid placement.' });
|
||||
const database = await getDatabase();
|
||||
const guide = await (
|
||||
await database.prepare(
|
||||
"SELECT id,published_revision_id FROM articles WHERE id=? AND kind='guide' AND visibility='public' AND deleted_at IS NULL",
|
||||
)
|
||||
).get(articleId);
|
||||
if (!guide) return fail(404, { guideError: 'Public Guide not found.' });
|
||||
const current = await (
|
||||
await database.prepare('SELECT * FROM guide_curations WHERE article_id=?')
|
||||
).get(articleId);
|
||||
const values = {
|
||||
pinned: current?.pinned_order ?? null,
|
||||
featured: Number(current?.featured ?? 0),
|
||||
recommended: Number(current?.recommended ?? 0),
|
||||
};
|
||||
if (placement === 'pinned') {
|
||||
const next = enabled
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT COALESCE(MAX(pinned_order),-1)+1 AS position FROM guide_curations',
|
||||
)
|
||||
).get()
|
||||
: null;
|
||||
values.pinned = enabled ? Number(next?.position ?? 0) : null;
|
||||
}
|
||||
if (placement === 'featured') values.featured = enabled ? 1 : 0;
|
||||
if (placement === 'recommended') values.recommended = enabled ? 1 : 0;
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO guide_curations(article_id,pinned_order,featured,recommended,curated_revision_id,curated_by,curated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(article_id) DO UPDATE SET pinned_order=excluded.pinned_order,featured=excluded.featured,recommended=excluded.recommended,curated_revision_id=excluded.curated_revision_id,curated_by=excluded.curated_by,curated_at=excluded.curated_at`,
|
||||
)
|
||||
).run(
|
||||
articleId,
|
||||
values.pinned,
|
||||
values.featured,
|
||||
values.recommended,
|
||||
guide.published_revision_id,
|
||||
user.id,
|
||||
now,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'community_moderation',
|
||||
action: `guide.${placement}_${enabled ? 'enabled' : 'disabled'}`,
|
||||
entityId: articleId,
|
||||
before: current ?? null,
|
||||
after: values,
|
||||
});
|
||||
return { guideSaved: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let{data,form}=$props();</script>
|
||||
<svelte:head><title>Guide Oversight — ISAC</title></svelte:head>
|
||||
<section class="dashboard-section guide-oversight"><div class="section-title"><div><p class="eyebrow">COMMUNITY OVERSIGHT</p><h2>GUIDE REVIEW & CURATION</h2></div><span>{data.requests.length+data.reports.length} PENDING</span></div>{#if form?.guideError}<p class="error">{form.guideError}</p>{/if}
|
||||
{#if data.canModerate}<h3>PUBLICATION REVIEWS</h3><div class="guide-review-list">{#each data.requests as item}<article><header><div><span>{item.scan_status?.toUpperCase()} · {item.risk_level?.toUpperCase()}</span><h4>{item.title}</h4><small>BY {item.author} · {item.requested_visibility.toUpperCase()}</small></div><a href={`/isac/community/guides/${item.id}`}>REVIEW IMMUTABLE REVISION →</a></header><p>{item.deck}</p><details><summary>REVIEW TEXT</summary><p>{item.plain_text}</p></details><div class="review-actions">{#each [['approve','APPROVE'],['reject','REJECT']] as action}<PendingForm action="?/decide"><input type="hidden" name="id" value={item.id}/><input type="hidden" name="decision" value={action[0]}/><label>DECISION REASON<textarea name="reason" required maxlength="500"></textarea></label><button class={action[0]==='reject'?'danger':'primary'}>{action[1]}</button></PendingForm>{/each}</div></article>{:else}<p class="empty-state">No Guide revisions need review.</p>{/each}</div>
|
||||
<h3>REPORTED GUIDES</h3><div class="guide-review-list">{#each data.reports as item}<article><header><div><span>REPORTED · {item.category.toUpperCase()}</span><h4>{item.title}</h4><small>BY {item.author}</small></div></header><p>{item.details||'No additional details supplied.'}</p><div class="review-actions">{#each [['dismiss','DISMISS'],['hide','HIDE GUIDE']] as action}<PendingForm action="?/report"><input type="hidden" name="id" value={item.id}/><input type="hidden" name="decision" value={action[0]}/><label>DECISION REASON<textarea name="reason" required maxlength="500"></textarea></label><button class={action[0]==='hide'?'danger':'secondary'}>{action[1]}</button></PendingForm>{/each}</div></article>{:else}<p class="empty-state">No Guide reports are pending.</p>{/each}</div>{/if}
|
||||
{#if data.canCurate}<h3>PUBLIC GUIDE CURATION</h3><div class="guide-curation-list">{#each data.guides as guide}<article><div><strong>{guide.title}</strong><a href={`/guides/${guide.slug}`}>VIEW →</a></div>{#each [['pinned',guide.pinned_order!==null],['featured',Boolean(guide.featured)],['recommended',Boolean(guide.recommended)]] as placement}<PendingForm action="?/curate"><input type="hidden" name="articleId" value={guide.id}/><input type="hidden" name="placement" value={placement[0]}/><input type="hidden" name="enabled" value={placement[1]?'0':'1'}/><button class:active={placement[1]}>{placement[1]?'REMOVE ':''}{String(placement[0]).toUpperCase()}</button></PendingForm>{/each}</article>{:else}<p class="empty-state">No public Guides are available to curate.</p>{/each}</div>{/if}</section>
|
||||
<style>.guide-oversight{display:grid;gap:12px}.guide-oversight>h3{margin:18px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.guide-review-list{display:grid;gap:8px}.guide-review-list>article{display:grid;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.guide-review-list article>header{display:flex;justify-content:space-between;gap:12px}.guide-review-list h4,.guide-review-list p{margin:0}.guide-review-list h4{font-size:1.3rem}.guide-review-list header span,.guide-review-list header small,.guide-review-list header a{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-review-list header span,.guide-review-list header a{color:var(--shd-orange)}.guide-review-list details>p{max-height:260px;overflow:auto;padding:10px;border:1px solid var(--shd-border);white-space:pre-wrap}.review-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.review-actions :global(form){display:grid;gap:6px}.review-actions label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation-list{display:grid}.guide-curation-list>article{display:grid;grid-template-columns:minmax(0,1fr) repeat(3,auto);align-items:center;gap:5px;padding:7px;border:1px solid var(--shd-border);border-top:0}.guide-curation-list>article:first-child{border-top:1px solid var(--shd-border)}.guide-curation-list>article>div{display:grid}.guide-curation-list a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation-list button.active{color:var(--shd-orange);border-color:var(--shd-orange)}@media(max-width:800px){.review-actions{grid-template-columns:1fr}.guide-curation-list>article{grid-template-columns:1fr}.guide-curation-list :global(button){width:100%}}</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { parseArticleDocument } from '$lib/articles/document';
|
||||
import { managedImageUrl } from '$lib/server/managed-images';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const permissions = await getPermissions(user);
|
||||
if (
|
||||
!permissions.includes('*') &&
|
||||
!permissions.includes('community.guides.moderate')
|
||||
)
|
||||
error(403, 'Guide moderation permission required');
|
||||
const database = await getDatabase();
|
||||
const row = await (
|
||||
await database.prepare(`SELECT request.id AS request_id,request.status,revision.*,COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,
|
||||
scan.reason,scan.risk_level,cover.updated_at AS cover_updated_at FROM article_publication_requests request JOIN article_revisions revision ON revision.id=request.revision_id
|
||||
JOIN articles ON articles.id=request.article_id JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
LEFT JOIN content_moderation_scans scan ON scan.id=request.scan_id LEFT JOIN managed_images cover ON cover.id=revision.cover_image_id WHERE request.id=? AND articles.kind='guide'`)
|
||||
).get(params.requestId);
|
||||
if (!row) error(404, 'Guide review not found');
|
||||
const parsed = parseArticleDocument(String(row.document_json));
|
||||
if (!parsed) error(500, 'Guide revision is invalid');
|
||||
const [buildRows, imageRows] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT embed_key,build_snapshot_json FROM article_revision_build_embeds WHERE revision_id=?',
|
||||
)
|
||||
).all(row.id),
|
||||
parsed.imageIds.length
|
||||
? (
|
||||
await database.prepare(
|
||||
`SELECT id,updated_at FROM managed_images WHERE id IN (${parsed.imageIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...parsed.imageIds)
|
||||
: [],
|
||||
]);
|
||||
return {
|
||||
revision: {
|
||||
...row,
|
||||
document: parsed.document,
|
||||
coverUrl: row.cover_image_id
|
||||
? managedImageUrl(String(row.cover_image_id), row.cover_updated_at)
|
||||
: '',
|
||||
},
|
||||
builds: Object.fromEntries(
|
||||
buildRows.map((item) => [
|
||||
String(item.embed_key),
|
||||
{ ...JSON.parse(String(item.build_snapshot_json)), available: true },
|
||||
]),
|
||||
),
|
||||
images: Object.fromEntries(
|
||||
imageRows.map((item) => [
|
||||
String(item.id),
|
||||
{
|
||||
url: managedImageUrl(String(item.id), item.updated_at),
|
||||
alt: '',
|
||||
caption: '',
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">import ArticleContent from '$lib/components/ArticleContent.svelte';let { data }=$props();</script>
|
||||
<svelte:head><title>Guide Revision Review // ISAC</title></svelte:head>
|
||||
<main class="review-preview"><a href="/isac/community/guides">← GUIDE OVERSIGHT</a><header><span>IMMUTABLE REVISION // {data.revision.status}</span><h1>{data.revision.title}</h1><p>{data.revision.deck}</p><small>BY {data.revision.author} · RISK {String(data.revision.risk_level??'UNKNOWN').toUpperCase()}</small></header>{#if data.revision.coverUrl}<figure><img src={data.revision.coverUrl} alt={data.revision.cover_alt}/><figcaption>{data.revision.cover_caption}</figcaption></figure>{/if}<section class="evidence"><strong>AUTOMATED REVIEW</strong><p>{data.revision.reason??'No automated evidence recorded.'}</p></section><ArticleContent document={data.revision.document.document} builds={data.builds} images={data.images}/></main>
|
||||
<style>.review-preview{max-width:980px;margin:0 auto;padding:40px 24px 90px}.review-preview>a,.review-preview header>span,.review-preview small,.evidence strong{font:700 11px var(--shd-font-display);letter-spacing:.12em;color:var(--shd-orange)}header{padding:55px 0 35px;border-bottom:1px solid var(--shd-border)}h1{font:900 clamp(40px,8vw,78px)/.9 var(--shd-font-display);text-transform:uppercase;max-width:900px;margin:12px 0}header p{font:400 21px var(--shd-font-editorial);color:var(--shd-text-muted)}figure{margin:30px 0}figure img{width:100%;max-height:520px;object-fit:cover}.evidence{margin:30px 0;padding:18px;border:1px solid var(--shd-border);background:var(--shd-surface)}</style>
|
||||
@@ -1,39 +1,76 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const ranges = await (await (await getDatabase()).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
|
||||
return { ranges };
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const ranges = await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')
|
||||
).all();
|
||||
return { ranges };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveCoreRanges: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const ranges = [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
min: Number(form.get(`tier${tier}Min`)),
|
||||
max: Number(form.get(`tier${tier}Max`))
|
||||
}));
|
||||
if (ranges.some((range) => !Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min < 0 || range.max < range.min)) {
|
||||
return fail(400, { configurationError: 'Every tier requires a valid nominal minimum and maximum.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
|
||||
const now = new Date().toISOString();
|
||||
for (const range of ranges) {
|
||||
await (await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
|
||||
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`))
|
||||
.run(range.tier, range.min, range.max, now);
|
||||
}
|
||||
const after = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'configuration.core_ranges_updated', entityId: 'core-attribute-tier-ranges', before, after });
|
||||
return { configurationSaved: true, resultToken: randomUUID() };
|
||||
}
|
||||
saveCoreRanges: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const ranges = [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
min: Number(form.get(`tier${tier}Min`)),
|
||||
max: Number(form.get(`tier${tier}Max`)),
|
||||
}));
|
||||
if (
|
||||
ranges.some(
|
||||
(range) =>
|
||||
!Number.isFinite(range.min) ||
|
||||
!Number.isFinite(range.max) ||
|
||||
range.min < 0 ||
|
||||
range.max < range.min,
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
configurationError:
|
||||
'Every tier requires a valid nominal minimum and maximum.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
|
||||
)
|
||||
).all();
|
||||
const now = new Date().toISOString();
|
||||
for (const range of ranges) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
|
||||
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`)
|
||||
).run(range.tier, range.min, range.max, now);
|
||||
}
|
||||
const after = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
|
||||
)
|
||||
).all();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'configuration.core_ranges_updated',
|
||||
entityId: 'core-attribute-tier-ranges',
|
||||
before,
|
||||
after,
|
||||
});
|
||||
return { configurationSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,70 +3,91 @@ import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { CONTRIBUTOR_GUIDELINES_VERSION, getContributorMode } from '$lib/server/contributor-mode';
|
||||
import {
|
||||
CONTRIBUTOR_GUIDELINES_VERSION,
|
||||
getContributorMode,
|
||||
} from '$lib/server/contributor-mode';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const safeReturn = (value: FormDataEntryValue | null) => {
|
||||
const path = String(value ?? '/isac/attributes');
|
||||
return path.startsWith('/isac/') && !path.startsWith('//') ? path : '/isac/attributes';
|
||||
const path = String(value ?? '/isac/attributes');
|
||||
return path.startsWith('/isac/') && !path.startsWith('//')
|
||||
? path
|
||||
: '/isac/attributes';
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible = permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/isac/attributes');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo'))
|
||||
};
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible =
|
||||
permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/isac/attributes');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo')),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('data.propose')) return fail(403, { contributorError: 'This account is not eligible to contribute data.' });
|
||||
const form = await request.formData();
|
||||
if (form.get('acceptGuidelines') !== 'on') return fail(400, { contributorError: 'Accept the contributor guidelines to enable contributor mode.' });
|
||||
const returnTo = safeReturn(form.get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
|
||||
enable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('data.propose'))
|
||||
return fail(403, {
|
||||
contributorError: 'This account is not eligible to contribute data.',
|
||||
});
|
||||
const form = await request.formData();
|
||||
if (form.get('acceptGuidelines') !== 'on')
|
||||
return fail(400, {
|
||||
contributorError:
|
||||
'Accept the contributor guidelines to enable contributor mode.',
|
||||
});
|
||||
const returnTo = safeReturn(form.get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
|
||||
VALUES(?,?,?,1,?,NULL)
|
||||
ON CONFLICT(user_id) DO UPDATE SET guidelines_version=excluded.guidelines_version,accepted_at=excluded.accepted_at,
|
||||
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: before.accepted ? 'contributor.enabled' : 'contributor.guidelines_accepted',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
disable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const returnTo = safeReturn((await request.formData()).get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
if (before.enabled) {
|
||||
await (await database.prepare('UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?')).run(new Date().toISOString(), user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'contributor.disabled',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
}
|
||||
redirect(303, returnTo);
|
||||
}
|
||||
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)
|
||||
).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: before.accepted
|
||||
? 'contributor.enabled'
|
||||
: 'contributor.guidelines_accepted',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id),
|
||||
});
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
disable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const returnTo = safeReturn((await request.formData()).get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
if (before.enabled) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'contributor.disabled',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id),
|
||||
});
|
||||
}
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,10 @@ import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { deserializeProposal, refreshProposalDependencies } from '$lib/server/data-moderation';
|
||||
import {
|
||||
deserializeProposal,
|
||||
refreshProposalDependencies,
|
||||
} from '$lib/server/data-moderation';
|
||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -11,258 +14,625 @@ import { createNotification } from '$lib/server/notifications';
|
||||
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
|
||||
const modules = [
|
||||
'configuration',
|
||||
'calculations',
|
||||
'specializations',
|
||||
'attributes',
|
||||
'talents',
|
||||
'brands',
|
||||
'gear',
|
||||
'weapons',
|
||||
'weaponmods',
|
||||
'gearmods',
|
||||
'protocols',
|
||||
'skillchips',
|
||||
];
|
||||
type SerializedEntry =
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
|
||||
| { name: string; kind: 'text'; value: string }
|
||||
| {
|
||||
name: string;
|
||||
kind: 'file';
|
||||
value: string;
|
||||
fileName: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
async function specializationProposalSnapshot(database: any, proposal: any) {
|
||||
const id = String(proposal.entity_id ?? '');
|
||||
if (proposal.action === 'saveProgression') {
|
||||
return {
|
||||
record: await (await database.prepare("SELECT * FROM specialization_progression_config WHERE id='default'")).get(),
|
||||
rankProfiles: await (await database.prepare('SELECT * FROM specialization_rank_cost_profiles ORDER BY rank')).all()
|
||||
};
|
||||
}
|
||||
if (!id) return null;
|
||||
if (proposal.action === 'saveSpecialization') {
|
||||
return { record: await (await database.prepare('SELECT * FROM specializations WHERE id=?')).get(id) ?? null };
|
||||
}
|
||||
if (proposal.action === 'saveFocus') {
|
||||
return { record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null };
|
||||
}
|
||||
if (proposal.action === 'saveAbility') {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM specialization_abilities WHERE id=?')).get(id) ?? null,
|
||||
attack: await (await database.prepare(
|
||||
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?'
|
||||
)).get(id) ?? null,
|
||||
effects: await (await database.prepare(
|
||||
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveCombatEffect') {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM specialization_combat_effects WHERE id=?')).get(id) ?? null,
|
||||
attachments: await (await database.prepare(
|
||||
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveFocusAbilities') {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM specialization_focuses WHERE id=?')).get(id) ?? null,
|
||||
abilities: await (await database.prepare(
|
||||
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveNode') {
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM specialization_tree_nodes WHERE id=?')).get(id) ?? null,
|
||||
ranks: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank'
|
||||
)).all(id),
|
||||
effects: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order'
|
||||
)).all(id),
|
||||
costs: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id'
|
||||
)).all(id),
|
||||
combatEffects: await (await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order'
|
||||
)).all(id)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
const id = String(proposal.entity_id ?? '');
|
||||
if (proposal.action === 'saveProgression') {
|
||||
return {
|
||||
record: await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM specialization_progression_config WHERE id='default'",
|
||||
)
|
||||
).get(),
|
||||
rankProfiles: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank',
|
||||
)
|
||||
).all(),
|
||||
};
|
||||
}
|
||||
if (!id) return null;
|
||||
if (proposal.action === 'saveSpecialization') {
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM specializations WHERE id=?')
|
||||
).get(id)) ?? null,
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveFocus') {
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focuses WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveAbility') {
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_abilities WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
attack:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_ability_attacks WHERE ability_id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
effects: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order',
|
||||
)
|
||||
).all(id),
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveCombatEffect') {
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_combat_effects WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
attachments: await (
|
||||
await database.prepare(
|
||||
'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order',
|
||||
)
|
||||
).all(id),
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveFocusAbilities') {
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_focuses WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
abilities: await (
|
||||
await database.prepare(
|
||||
'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot',
|
||||
)
|
||||
).all(id),
|
||||
};
|
||||
}
|
||||
if (proposal.action === 'saveNode') {
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_nodes WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
ranks: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank',
|
||||
)
|
||||
).all(id),
|
||||
effects: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order',
|
||||
)
|
||||
).all(id),
|
||||
costs: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id',
|
||||
)
|
||||
).all(id),
|
||||
combatEffects: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order',
|
||||
)
|
||||
).all(id),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function skillChipProposalSnapshot(database: any, id: string) {
|
||||
const talent = await (await database.prepare(
|
||||
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?'
|
||||
)).get(id);
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')).get(id) ?? null,
|
||||
twoPiece: await (await database.prepare(
|
||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?'
|
||||
)).get(id) ?? null,
|
||||
primaryOptions: await (await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
|
||||
)).all(id),
|
||||
secondaryOptions: await (await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
|
||||
)).all(id),
|
||||
talent: talent ?? null,
|
||||
talentEffects: talent ? await (await database.prepare(
|
||||
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order'
|
||||
)).all(talent.id) : [],
|
||||
conditional: talent
|
||||
? await conditionalRuleSnapshot(database, 'skill_chip_talent', String(talent.id))
|
||||
: null
|
||||
};
|
||||
const talent = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).get(id);
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?')
|
||||
).get(id)) ?? null,
|
||||
twoPiece:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
primaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
talent: talent ?? null,
|
||||
talentEffects: talent
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order',
|
||||
)
|
||||
).all(talent.id)
|
||||
: [],
|
||||
conditional: talent
|
||||
? await conditionalRuleSnapshot(
|
||||
database,
|
||||
'skill_chip_talent',
|
||||
String(talent.id),
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function calculationProposalSnapshot(database: any, proposal: any) {
|
||||
const id = String(proposal.entity_id ?? '');
|
||||
if (!id) return null;
|
||||
if (proposal.action === 'saveRevision') return {
|
||||
record: await (await database.prepare('SELECT * FROM calculation_revisions WHERE id=?')).get(id) ?? null,
|
||||
stages: await (await database.prepare('SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order')).all(id),
|
||||
profiles: await (await database.prepare(
|
||||
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
|
||||
)).all(id),
|
||||
skillProfiles: await (await database.prepare(
|
||||
'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id'
|
||||
)).all(id)
|
||||
};
|
||||
if (proposal.action === 'saveCalibration') return {
|
||||
record: await (await database.prepare('SELECT * FROM calculation_calibration_cases WHERE id=?')).get(id) ?? null
|
||||
};
|
||||
return null;
|
||||
const id = String(proposal.entity_id ?? '');
|
||||
if (!id) return null;
|
||||
if (proposal.action === 'saveRevision')
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM calculation_revisions WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
stages: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order',
|
||||
)
|
||||
).all(id),
|
||||
profiles: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
skillProfiles: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
};
|
||||
if (proposal.action === 'saveCalibration')
|
||||
return {
|
||||
record:
|
||||
(await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM calculation_calibration_cases WHERE id=?',
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
|
||||
const module = modules.includes(url.searchParams.get('module') ?? '') ? String(url.searchParams.get('module')) : '';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const sort = getRegistrySort(url, {
|
||||
change: {
|
||||
asc: 'p.summary COLLATE NOCASE ASC,p.module COLLATE NOCASE ASC,p.action COLLATE NOCASE ASC',
|
||||
desc: 'p.summary COLLATE NOCASE DESC,p.module COLLATE NOCASE DESC,p.action COLLATE NOCASE DESC'
|
||||
},
|
||||
contributor: {
|
||||
asc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE ASC',
|
||||
desc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE DESC'
|
||||
},
|
||||
submitted: { asc: 'p.created_at ASC', desc: 'p.created_at DESC', defaultDirection: 'desc' },
|
||||
status: { asc: 'p.status COLLATE NOCASE ASC', desc: 'p.status COLLATE NOCASE DESC' }
|
||||
}, 'submitted', 'desc');
|
||||
const conditions = ['p.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (module) { conditions.push('p.module=?'); args.push(module); }
|
||||
if (search) { conditions.push("(p.summary LIKE ? ESCAPE '\\' COLLATE NOCASE OR proposer.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR profile.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)"); const value = `%${search.replaceAll('\\','\\\\').replaceAll('%','\\%').replaceAll('_','\\_')}%`; args.push(value, value, value); }
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM data_change_proposals p JOIN users proposer ON proposer.id=p.proposed_by LEFT JOIN user_profiles profile ON profile.user_id=proposer.id ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const proposals = await (await database.prepare(`SELECT p.*,COALESCE(profile.display_name,proposer.username) AS proposer_name,
|
||||
await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '')
|
||||
? String(url.searchParams.get('status'))
|
||||
: 'pending';
|
||||
const module = modules.includes(url.searchParams.get('module') ?? '')
|
||||
? String(url.searchParams.get('module'))
|
||||
: '';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
change: {
|
||||
asc: 'p.summary COLLATE NOCASE ASC,p.module COLLATE NOCASE ASC,p.action COLLATE NOCASE ASC',
|
||||
desc: 'p.summary COLLATE NOCASE DESC,p.module COLLATE NOCASE DESC,p.action COLLATE NOCASE DESC',
|
||||
},
|
||||
contributor: {
|
||||
asc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE ASC',
|
||||
desc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE DESC',
|
||||
},
|
||||
submitted: {
|
||||
asc: 'p.created_at ASC',
|
||||
desc: 'p.created_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
status: {
|
||||
asc: 'p.status COLLATE NOCASE ASC',
|
||||
desc: 'p.status COLLATE NOCASE DESC',
|
||||
},
|
||||
},
|
||||
'submitted',
|
||||
'desc',
|
||||
);
|
||||
const conditions = ['p.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (module) {
|
||||
conditions.push('p.module=?');
|
||||
args.push(module);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(
|
||||
"(p.summary LIKE ? ESCAPE '\\' COLLATE NOCASE OR proposer.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR profile.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)",
|
||||
);
|
||||
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
args.push(value, value, value);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS total FROM data_change_proposals p JOIN users proposer ON proposer.id=p.proposed_by LEFT JOIN user_profiles profile ON profile.user_id=proposer.id ${where}`,
|
||||
)
|
||||
).get(...args)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const pagination = getPagination(url, total);
|
||||
const proposals = await (
|
||||
await database.prepare(`SELECT p.*,COALESCE(profile.display_name,proposer.username) AS proposer_name,
|
||||
COALESCE(reviewer_profile.display_name,reviewer.username) AS reviewer_name
|
||||
FROM data_change_proposals p
|
||||
JOIN users proposer ON proposer.id=p.proposed_by
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
|
||||
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
|
||||
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
|
||||
${where} ORDER BY ${sort.orderBy},p.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const proposalsWithSnapshots = await Promise.all(proposals.map(async (proposal) => ({
|
||||
...proposal,
|
||||
current_snapshot: proposal.module === 'attributes' && proposal.entity_id
|
||||
? await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(String(proposal.entity_id)) ?? null
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(database, String(proposal.entity_id))
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(database, proposal)
|
||||
: null
|
||||
})));
|
||||
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
|
||||
return { proposals: proposalsWithSnapshots, pagination, status, module, search, sort, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
|
||||
${where} ORDER BY ${sort.orderBy},p.id LIMIT ? OFFSET ?`)
|
||||
).all(...args, pagination.perPage, pagination.offset);
|
||||
const proposalsWithSnapshots = await Promise.all(
|
||||
proposals.map(async (proposal) => ({
|
||||
...proposal,
|
||||
current_snapshot:
|
||||
proposal.module === 'attributes' && proposal.entity_id
|
||||
? ((await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(String(proposal.entity_id))) ?? null)
|
||||
: proposal.module === 'skillchips' && proposal.entity_id
|
||||
? await skillChipProposalSnapshot(
|
||||
database,
|
||||
String(proposal.entity_id),
|
||||
)
|
||||
: proposal.module === 'calculations'
|
||||
? await calculationProposalSnapshot(database, proposal)
|
||||
: proposal.module === 'specializations'
|
||||
? await specializationProposalSnapshot(database, proposal)
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
const summaryRows = await (
|
||||
await database.prepare(
|
||||
'SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status',
|
||||
)
|
||||
).all();
|
||||
return {
|
||||
proposals: proposalsWithSnapshots,
|
||||
pagination,
|
||||
status,
|
||||
module,
|
||||
search,
|
||||
sort,
|
||||
summary: Object.fromEntries(
|
||||
summaryRows.map((row) => [String(row.status), Number(row.count)]),
|
||||
),
|
||||
modules,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
correct: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
let corrections: Array<{ index: number; value: string }>;
|
||||
try {
|
||||
const parsed = JSON.parse(String(form.get('corrections') ?? '[]'));
|
||||
if (!Array.isArray(parsed)) throw new Error();
|
||||
corrections = parsed.map((item) => ({ index: Number(item.index), value: String(item.value ?? '') }));
|
||||
} catch {
|
||||
return fail(400, { moderationError: 'The proposed corrections could not be read.' });
|
||||
}
|
||||
if (corrections.length > 250 || corrections.some((item) => !Number.isInteger(item.index) || item.index < 0 || item.value.length > 100_000)) {
|
||||
return fail(400, { moderationError: 'One or more corrected fields exceed the safe proposal limits.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const entries = JSON.parse(String(proposal.payload_json)) as SerializedEntry[];
|
||||
const changes: Array<{ index: number; name: string; before: string; after: string }> = [];
|
||||
for (const correction of corrections) {
|
||||
const entry = entries[correction.index];
|
||||
if (!entry || entry.kind !== 'text') return fail(400, { moderationError: 'Only existing text and numeric proposal fields may be corrected.' });
|
||||
if (entry.value !== correction.value) {
|
||||
changes.push({ index: correction.index, name: entry.name, before: entry.value, after: correction.value });
|
||||
entry.value = correction.value;
|
||||
}
|
||||
}
|
||||
if (!changes.length) return fail(400, { moderationError: 'No proposal fields were changed.' });
|
||||
const payload = JSON.stringify(entries);
|
||||
const oldDependencies = (await (await database.prepare('SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id')).all(id)).map((row) => String(row.depends_on_proposal_id));
|
||||
const name = entries.find((entry) => entry.kind === 'text' && entry.name === 'name')?.value.trim();
|
||||
const summary = `${String(proposal.action).replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
|
||||
await (await database.prepare('UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?')).run(payload, summary, id);
|
||||
const dependencies = await refreshProposalDependencies(id, payload);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.corrected',
|
||||
entityId: id,
|
||||
before: { summary: proposal.summary, dependencies: oldDependencies, fields: changes.map((change) => ({ index: change.index, name: change.name, value: change.before })) },
|
||||
after: { summary, dependencies, fields: changes.map((change) => ({ index: change.index, name: change.name, value: change.after })) }
|
||||
});
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
approve: async ({ cookies, request, fetch, url }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '').trim().slice(0, 500);
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const unresolved = await (await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||
correct: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.approve',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
let corrections: Array<{ index: number; value: string }>;
|
||||
try {
|
||||
const parsed = JSON.parse(String(form.get('corrections') ?? '[]'));
|
||||
if (!Array.isArray(parsed)) throw new Error();
|
||||
corrections = parsed.map((item) => ({
|
||||
index: Number(item.index),
|
||||
value: String(item.value ?? ''),
|
||||
}));
|
||||
} catch {
|
||||
return fail(400, {
|
||||
moderationError: 'The proposed corrections could not be read.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
corrections.length > 250 ||
|
||||
corrections.some(
|
||||
(item) =>
|
||||
!Number.isInteger(item.index) ||
|
||||
item.index < 0 ||
|
||||
item.value.length > 100_000,
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
moderationError:
|
||||
'One or more corrected fields exceed the safe proposal limits.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const proposal = await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(id);
|
||||
if (!proposal)
|
||||
return fail(404, {
|
||||
moderationError: 'That pending proposal no longer exists.',
|
||||
});
|
||||
const entries = JSON.parse(
|
||||
String(proposal.payload_json),
|
||||
) as SerializedEntry[];
|
||||
const changes: Array<{
|
||||
index: number;
|
||||
name: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}> = [];
|
||||
for (const correction of corrections) {
|
||||
const entry = entries[correction.index];
|
||||
if (!entry || entry.kind !== 'text')
|
||||
return fail(400, {
|
||||
moderationError:
|
||||
'Only existing text and numeric proposal fields may be corrected.',
|
||||
});
|
||||
if (entry.value !== correction.value) {
|
||||
changes.push({
|
||||
index: correction.index,
|
||||
name: entry.name,
|
||||
before: entry.value,
|
||||
after: correction.value,
|
||||
});
|
||||
entry.value = correction.value;
|
||||
}
|
||||
}
|
||||
if (!changes.length)
|
||||
return fail(400, { moderationError: 'No proposal fields were changed.' });
|
||||
const payload = JSON.stringify(entries);
|
||||
const oldDependencies = (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id',
|
||||
)
|
||||
).all(id)
|
||||
).map((row) => String(row.depends_on_proposal_id));
|
||||
const name = entries
|
||||
.find((entry) => entry.kind === 'text' && entry.name === 'name')
|
||||
?.value.trim();
|
||||
const summary = `${String(proposal.action)
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?',
|
||||
)
|
||||
).run(payload, summary, id);
|
||||
const dependencies = await refreshProposalDependencies(id, payload);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.corrected',
|
||||
entityId: id,
|
||||
before: {
|
||||
summary: proposal.summary,
|
||||
dependencies: oldDependencies,
|
||||
fields: changes.map((change) => ({
|
||||
index: change.index,
|
||||
name: change.name,
|
||||
value: change.before,
|
||||
})),
|
||||
},
|
||||
after: {
|
||||
summary,
|
||||
dependencies,
|
||||
fields: changes.map((change) => ({
|
||||
index: change.index,
|
||||
name: change.name,
|
||||
value: change.after,
|
||||
})),
|
||||
},
|
||||
});
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
approve: async ({ cookies, request, fetch, url }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.approve',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
const database = await getDatabase();
|
||||
const proposal = await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(id);
|
||||
if (!proposal)
|
||||
return fail(404, {
|
||||
moderationError: 'That pending proposal no longer exists.',
|
||||
});
|
||||
const unresolved = await (
|
||||
await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
|
||||
FROM data_change_proposal_dependencies link
|
||||
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)).all(id);
|
||||
if (unresolved.length) {
|
||||
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
|
||||
return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` });
|
||||
}
|
||||
const target = new URL(`/isac/${proposal.module}?/${proposal.action}`, url.origin);
|
||||
const proposalForm = deserializeProposal(String(proposal.payload_json));
|
||||
if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
||||
const response = await fetch(target, { method: 'POST', body: proposalForm, headers: { origin: url.origin } });
|
||||
const now = new Date().toISOString();
|
||||
if (!response.ok) {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
|
||||
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/isac/${proposal.module}`, mandatory: true });
|
||||
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
|
||||
}
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
|
||||
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/isac/${proposal.module}`, mandatory: true });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '').trim().slice(0, 500);
|
||||
if (note.length < 3) return fail(400, { moderationError: 'Add a brief reason for rejecting this proposal.' });
|
||||
const database = await getDatabase();
|
||||
const proposal = await (await database.prepare("SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
|
||||
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
|
||||
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/isac/${proposal.module}`, mandatory: true });
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
}
|
||||
WHERE link.proposal_id=? AND dependency.status<>'approved'`)
|
||||
).all(id);
|
||||
if (unresolved.length) {
|
||||
const names = unresolved
|
||||
.map((item) => `${item.summary} (${item.status})`)
|
||||
.join(', ');
|
||||
return fail(409, {
|
||||
moderationError: `Approve this proposal's dependencies first: ${names}.`,
|
||||
});
|
||||
}
|
||||
const target = new URL(
|
||||
`/isac/${proposal.module}?/${proposal.action}`,
|
||||
url.origin,
|
||||
);
|
||||
const proposalForm = deserializeProposal(String(proposal.payload_json));
|
||||
if (proposal.draft_entity_id && !proposal.entity_id)
|
||||
proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
|
||||
const response = await fetch(target, {
|
||||
method: 'POST',
|
||||
body: proposalForm,
|
||||
headers: { origin: url.origin },
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
if (!response.ok) {
|
||||
const details = (await response.text()).slice(0, 1000);
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
||||
)
|
||||
).run(
|
||||
reviewer.id,
|
||||
details || note || 'Canonical validation failed.',
|
||||
now,
|
||||
id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.failed',
|
||||
entityId: id,
|
||||
before: { status: 'pending' },
|
||||
after: { status: 'failed', responseStatus: response.status },
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution could not be deployed',
|
||||
body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
});
|
||||
return fail(409, {
|
||||
moderationError:
|
||||
'The proposal did not pass the current canonical validation and was marked failed.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?",
|
||||
)
|
||||
).run(reviewer.id, note || null, now, now, id);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.approved',
|
||||
entityId: id,
|
||||
before: { status: 'pending' },
|
||||
after: {
|
||||
status: 'approved',
|
||||
module: proposal.module,
|
||||
action: proposal.action,
|
||||
proposedBy: proposal.proposed_by,
|
||||
appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id,
|
||||
},
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution approved',
|
||||
body: `${proposal.summary} was approved and deployed to the live builder data set.`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
});
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
reject: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.approve',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const note = String(form.get('note') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
if (note.length < 3)
|
||||
return fail(400, {
|
||||
moderationError: 'Add a brief reason for rejecting this proposal.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const proposal = await (
|
||||
await database.prepare(
|
||||
"SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'",
|
||||
)
|
||||
).get(id);
|
||||
if (!proposal)
|
||||
return fail(404, {
|
||||
moderationError: 'That pending proposal no longer exists.',
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?",
|
||||
)
|
||||
).run(reviewer.id, note, now, id);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'data_moderation',
|
||||
action: 'proposal.rejected',
|
||||
entityId: id,
|
||||
before: { status: 'pending' },
|
||||
after: {
|
||||
status: 'rejected',
|
||||
note,
|
||||
module: proposal.module,
|
||||
action: proposal.action,
|
||||
proposedBy: proposal.proposed_by,
|
||||
},
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(proposal.proposed_by),
|
||||
category: 'contributions',
|
||||
source: 'ISAC DATA OVERSIGHT',
|
||||
title: 'Contribution needs revision',
|
||||
body: `${String(proposal.action)
|
||||
.replace(/([A-Z])/g, ' $1')
|
||||
.trim()} was not approved. Oversight reason: ${note}`,
|
||||
link: `/isac/${proposal.module}`,
|
||||
mandatory: true,
|
||||
});
|
||||
return { moderationSaved: true, moderationToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
|
||||
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
|
||||
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
|
||||
{#if reviewing.contributor_note}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{reviewing.contributor_note}</p></div>{/if}
|
||||
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
|
||||
<details>
|
||||
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
|
||||
|
||||
@@ -1,281 +1,706 @@
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images';
|
||||
import {
|
||||
applyGameDataRender,
|
||||
ManagedImageValidationError,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
readGameDataRenderMutation,
|
||||
} from '$lib/server/managed-images';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const slots = [
|
||||
'Holster',
|
||||
'Gloves',
|
||||
'Backpack',
|
||||
'Knee Pads',
|
||||
'Body Armor',
|
||||
'Mask',
|
||||
];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
|
||||
const augmentSlotCount = (slot: string) =>
|
||||
slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
|
||||
const augmentPercentage = (slot: string, augmentSlot: number) =>
|
||||
slot === 'Backpack' || slot === 'Body Armor'
|
||||
? [0, 0, 40, 70, 100][augmentSlot]
|
||||
: [0, 0, 50, 100][augmentSlot];
|
||||
slot === 'Backpack' || slot === 'Body Armor'
|
||||
? [0, 0, 40, 70, 100][augmentSlot]
|
||||
: [0, 0, 50, 100][augmentSlot];
|
||||
|
||||
async function augmentProfileSnapshot(slot: string) {
|
||||
const database = await getDatabase();
|
||||
const augments = await (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
|
||||
const costs = await (await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
|
||||
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)).all(slot);
|
||||
return augments.map((augment) => ({ ...augment, costs: costs.filter((cost) => cost.augment_id === augment.id) }));
|
||||
const database = await getDatabase();
|
||||
const augments = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot',
|
||||
)
|
||||
).all(slot);
|
||||
const costs = await (
|
||||
await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
|
||||
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)
|
||||
).all(slot);
|
||||
return augments.map((augment) => ({
|
||||
...augment,
|
||||
costs: costs.filter((cost) => cost.augment_id === augment.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const summary = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN slot IN ('Gloves','Knee Pads','Body Armor') THEN 1 ELSE 0 END) AS armored,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)).get();
|
||||
const filter = ['active', 'armored', 'talented'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
|
||||
slot: { asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
tier: { asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC', desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
ranges: { asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC', desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' },
|
||||
brands: {
|
||||
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
|
||||
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC"
|
||||
},
|
||||
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'armored' ? ["slot IN ('Gloves','Knee Pads','Body Armor')"] : filter === 'talented' ? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(...args);
|
||||
const pendingDefinitions = await getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
|
||||
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||
{ module: 'gear', action: 'save', kind: 'gear', fields: ['slot', 'tier', 'healthMin', 'healthMax', 'armorMin', 'armorMax', 'brandIds', 'talentIds'] }
|
||||
]);
|
||||
const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) =>
|
||||
(filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true)
|
||||
&& (!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
).sort((left, right) => {
|
||||
const valueFor = (item: any) => sort.key === 'ranges' ? Number(item.healthMin ?? 0)
|
||||
: sort.key === 'brands' ? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds]).filter(Boolean).sort().join(':')
|
||||
: sort.key === 'state' ? Number(item.active ?? 1)
|
||||
: item[sort.key] ?? item.name;
|
||||
const leftValue = valueFor(left), rightValue = valueFor(right);
|
||||
const result = typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
|
||||
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
|
||||
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
|
||||
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
|
||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons, renders] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
|
||||
(await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'")).all()
|
||||
]);
|
||||
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
|
||||
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
|
||||
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
|
||||
const talentOptionWithIcon = (talent: Record<string, any>) => {
|
||||
const talentId = String(talent.id);
|
||||
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
|
||||
return {
|
||||
...talent,
|
||||
id: talentId,
|
||||
name: String(talent.name),
|
||||
category: String(talent.category),
|
||||
showIcon: true,
|
||||
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
|
||||
};
|
||||
};
|
||||
return {
|
||||
gear: [...pendingGearOnPage.map((item) => ({
|
||||
...item,
|
||||
tier: Number(item.tier),
|
||||
base_health_min: item.healthMin,
|
||||
base_health_max: item.healthMax,
|
||||
base_armor_min: item.armorMin || null,
|
||||
base_armor_max: item.armorMax || null,
|
||||
brandIds: Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds].filter(Boolean),
|
||||
talentIds: Array.isArray(item.talentIds) ? item.talentIds : [item.talentIds].filter(Boolean)
|
||||
})), ...gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find((image) => image.source_entity_id === item.id);
|
||||
return render ? managedImageUrl(String(render.id), render.updated_at) : '';
|
||||
})()
|
||||
}))],
|
||||
brands: [...pendingBrands, ...brands.map((brand) => {
|
||||
const icon = brandIcons.find((item) => item.entity_id === brand.id);
|
||||
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
|
||||
})],
|
||||
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))].map(talentOptionWithIcon),
|
||||
augmentProfiles: slots.map((slot) => ({
|
||||
slot,
|
||||
requiredSlots: augmentSlotCount(slot),
|
||||
augments: augmentRows.filter((augment) => augment.gear_slot === slot).map((augment) => ({
|
||||
...augment,
|
||||
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id)
|
||||
}))
|
||||
})),
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
|
||||
pagination: adjustedPagination,
|
||||
filter,
|
||||
search,
|
||||
sort
|
||||
};
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)
|
||||
).get();
|
||||
const filter = ['active', 'armored', 'talented'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC, id DESC',
|
||||
},
|
||||
slot: {
|
||||
asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
tier: {
|
||||
asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
ranges: {
|
||||
asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
brands: {
|
||||
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
|
||||
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC",
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(requestedItemId
|
||||
? ['id=?']
|
||||
: filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'armored'
|
||||
? ["slot IN ('Gloves','Knee Pads','Body Armor')"]
|
||||
: filter === 'talented'
|
||||
? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)']
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||||
: []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = [
|
||||
...(requestedItemId ? [requestedItemId] : []),
|
||||
...(!requestedItemId && search ? [pattern] : []),
|
||||
];
|
||||
const filtered = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)
|
||||
).get(...args);
|
||||
const pendingDefinitions = await getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{
|
||||
module: 'talents',
|
||||
action: 'saveTalent',
|
||||
kind: 'talent',
|
||||
fields: ['category'],
|
||||
},
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['unit'],
|
||||
},
|
||||
{
|
||||
module: 'gear',
|
||||
action: 'save',
|
||||
kind: 'gear',
|
||||
fields: [
|
||||
'slot',
|
||||
'tier',
|
||||
'healthMin',
|
||||
'healthMax',
|
||||
'armorMin',
|
||||
'armorMax',
|
||||
'brandIds',
|
||||
'talentIds',
|
||||
],
|
||||
},
|
||||
]);
|
||||
const pendingGear = pendingDefinitions
|
||||
.filter((item) => item.kind === 'gear')
|
||||
.filter(
|
||||
(item) =>
|
||||
(requestedItemId
|
||||
? item.id === requestedItemId
|
||||
: filter === 'armored'
|
||||
? armoredSlots.has(String(item.slot))
|
||||
: filter === 'talented'
|
||||
? ['Backpack', 'Body Armor'].includes(String(item.slot))
|
||||
: true) &&
|
||||
(requestedItemId ||
|
||||
!search ||
|
||||
String(item.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const valueFor = (item: any) =>
|
||||
sort.key === 'ranges'
|
||||
? Number(item.healthMin ?? 0)
|
||||
: sort.key === 'brands'
|
||||
? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds])
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(':')
|
||||
: sort.key === 'state'
|
||||
? Number(item.active ?? 1)
|
||||
: (item[sort.key] ?? item.name);
|
||||
const leftValue = valueFor(left),
|
||||
rightValue = valueFor(right);
|
||||
const result =
|
||||
typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
});
|
||||
return (
|
||||
(sort.direction === 'asc' ? result : -result) ||
|
||||
String(left.name).localeCompare(String(right.name), undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
);
|
||||
});
|
||||
const adjustedPagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + pendingGear.length,
|
||||
);
|
||||
const pendingGearOnPage =
|
||||
adjustedPagination.page === 1
|
||||
? pendingGear.slice(0, adjustedPagination.perPage)
|
||||
: [];
|
||||
const databaseOffset = Math.max(
|
||||
0,
|
||||
adjustedPagination.offset - pendingGear.length,
|
||||
);
|
||||
const databaseLimit = Math.max(
|
||||
0,
|
||||
adjustedPagination.perPage - pendingGearOnPage.length,
|
||||
);
|
||||
const [
|
||||
gear,
|
||||
brandLinks,
|
||||
talentLinks,
|
||||
brands,
|
||||
talents,
|
||||
augmentRows,
|
||||
augmentCosts,
|
||||
stats,
|
||||
brandIcons,
|
||||
talentIcons,
|
||||
renders,
|
||||
] = await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(
|
||||
`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(
|
||||
await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`,
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const pendingBrands = pendingDefinitions.filter(
|
||||
(item) => item.kind === 'brand',
|
||||
);
|
||||
const pendingTalents = pendingDefinitions.filter(
|
||||
(item) =>
|
||||
item.kind === 'talent' &&
|
||||
['Backpack', 'Body Armor'].includes(String(item.category)),
|
||||
);
|
||||
const pendingStats = pendingDefinitions.filter(
|
||||
(item) => item.kind === 'attribute',
|
||||
);
|
||||
const talentOptionWithIcon = (talent: Record<string, any>) => {
|
||||
const talentId = String(talent.id);
|
||||
const icon = talentIcons.find(
|
||||
(item) => String(item.entity_id) === talentId,
|
||||
);
|
||||
return {
|
||||
...talent,
|
||||
id: talentId,
|
||||
name: String(talent.name),
|
||||
category: String(talent.category),
|
||||
showIcon: true,
|
||||
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : '',
|
||||
};
|
||||
};
|
||||
return {
|
||||
gear: [
|
||||
...pendingGearOnPage.map((item) => ({
|
||||
...item,
|
||||
tier: Number(item.tier),
|
||||
base_health_min: item.healthMin,
|
||||
base_health_max: item.healthMax,
|
||||
base_armor_min: item.armorMin || null,
|
||||
base_armor_max: item.armorMax || null,
|
||||
brandIds: Array.isArray(item.brandIds)
|
||||
? item.brandIds
|
||||
: [item.brandIds].filter(Boolean),
|
||||
talentIds: Array.isArray(item.talentIds)
|
||||
? item.talentIds
|
||||
: [item.talentIds].filter(Boolean),
|
||||
})),
|
||||
...gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks
|
||||
.filter((link) => link.gear_id === item.id)
|
||||
.map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks
|
||||
.filter((link) => link.gear_id === item.id)
|
||||
.map((link) => String(link.talent_id)),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find(
|
||||
(image) => image.source_entity_id === item.id,
|
||||
);
|
||||
return render
|
||||
? managedImageUrl(String(render.id), render.updated_at)
|
||||
: '';
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
brands: [
|
||||
...pendingBrands,
|
||||
...brands.map((brand) => {
|
||||
const icon = brandIcons.find((item) => item.entity_id === brand.id);
|
||||
return {
|
||||
...brand,
|
||||
iconUrl: icon
|
||||
? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at)
|
||||
: '',
|
||||
};
|
||||
}),
|
||||
],
|
||||
talents: [
|
||||
...pendingTalents,
|
||||
...talents.map((talent) => ({
|
||||
...talent,
|
||||
category: talent.category === 'Vest' ? 'Body Armor' : talent.category,
|
||||
})),
|
||||
].map(talentOptionWithIcon),
|
||||
augmentProfiles: slots.map((slot) => ({
|
||||
slot,
|
||||
requiredSlots: augmentSlotCount(slot),
|
||||
augments: augmentRows
|
||||
.filter((augment) => augment.gear_slot === slot)
|
||||
.map((augment) => ({
|
||||
...augment,
|
||||
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id),
|
||||
})),
|
||||
})),
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: {
|
||||
...summary,
|
||||
total:
|
||||
Number(summary?.total ?? 0) +
|
||||
pendingDefinitions.filter((item) => item.kind === 'gear').length,
|
||||
pending: pendingDefinitions.filter((item) => item.kind === 'gear').length,
|
||||
},
|
||||
pagination: adjustedPagination,
|
||||
filter,
|
||||
requestedItemId,
|
||||
search,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
|
||||
const readRange = (form: FormData, prefix: string) => {
|
||||
const min = Number(form.get(`${prefix}Min`));
|
||||
const max = Number(form.get(`${prefix}Max`));
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error();
|
||||
return [min, max] as const;
|
||||
const min = Number(form.get(`${prefix}Min`));
|
||||
const max = Number(form.get(`${prefix}Max`));
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min)
|
||||
throw new Error();
|
||||
return [min, max] as const;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveAugmentProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
|
||||
const requiredSlots = augmentSlotCount(slot);
|
||||
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
effectType: index === 0 ? 'Custom' : 'Base Percentage',
|
||||
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
|
||||
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
|
||||
value: index === 0 ? Number(form.get('value1')) : augmentPercentage(slot, index + 1),
|
||||
costs: [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
|
||||
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`))
|
||||
}))
|
||||
}));
|
||||
if (!augments[0].statId || !augments[0].rawValue || !Number.isFinite(augments[0].value)) {
|
||||
return fail(400, { augmentProfileError: 'Tactical Augment 01 requires one custom attribute and value.' });
|
||||
}
|
||||
if (augments.some((augment) => augment.costs.some((cost) => !cost.raw || !Number.isInteger(cost.value) || cost.value < 0))) {
|
||||
return fail(400, { augmentProfileError: 'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = [augments[0].statId];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length) return fail(400, { augmentProfileError: 'One or more selected attributes are unavailable.' });
|
||||
const before = await augmentProfileSnapshot(slot);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
|
||||
for (const augment of augments) {
|
||||
const augmentId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO gear_tactical_augments
|
||||
saveAugmentProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slots.includes(slot))
|
||||
return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
|
||||
const requiredSlots = augmentSlotCount(slot);
|
||||
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
effectType: index === 0 ? 'Custom' : 'Base Percentage',
|
||||
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
|
||||
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
|
||||
value:
|
||||
index === 0
|
||||
? Number(form.get('value1'))
|
||||
: augmentPercentage(slot, index + 1),
|
||||
costs: [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
|
||||
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`)),
|
||||
})),
|
||||
}));
|
||||
if (
|
||||
!augments[0].statId ||
|
||||
!augments[0].rawValue ||
|
||||
!Number.isFinite(augments[0].value)
|
||||
) {
|
||||
return fail(400, {
|
||||
augmentProfileError:
|
||||
'Tactical Augment 01 requires one custom attribute and value.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
augments.some((augment) =>
|
||||
augment.costs.some(
|
||||
(cost) =>
|
||||
!cost.raw || !Number.isInteger(cost.value) || cost.value < 0,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
augmentProfileError:
|
||||
'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = [augments[0].statId];
|
||||
const validStats = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length)
|
||||
return fail(400, {
|
||||
augmentProfileError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const before = await augmentProfileSnapshot(slot);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_tactical_augments WHERE gear_slot=?',
|
||||
)
|
||||
).run(slot);
|
||||
for (const augment of augments) {
|
||||
const augmentId = randomUUID();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_tactical_augments
|
||||
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`)).run(augmentId, slot, augment.slot, augment.effectType, augment.statId || null, augment.value, now, now);
|
||||
for (const cost of augment.costs) {
|
||||
await (await database.prepare('INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)')).run(augmentId, cost.tier, cost.value);
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created',
|
||||
entityId: slot,
|
||||
before,
|
||||
after: await augmentProfileSnapshot(slot)
|
||||
});
|
||||
return { augmentProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError) return fail(400, { gearError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const tier = Number(form.get('tier'));
|
||||
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
|
||||
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
|
||||
if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2) {
|
||||
return fail(400, { gearError: 'Choose a valid slot and tier.' });
|
||||
}
|
||||
if (brandIds.length < 2) return fail(400, { gearError: 'Gear must be available from at least two brand sets.' });
|
||||
const talentCategory = slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
|
||||
if (talentCategory && talentIds.length < 1) return fail(400, { gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.` });
|
||||
if (!talentCategory && talentIds.length) return fail(400, { gearError: `${slot} gear cannot have talents.` });
|
||||
let health: readonly [number, number], armor: readonly [number, number] | [null, null];
|
||||
try {
|
||||
health = readRange(form, 'health');
|
||||
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
|
||||
} catch {
|
||||
return fail(400, { gearError: 'Base ranges require non-negative minimums and maximums greater than or equal to them.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { gearError: 'That gear name already exists.' });
|
||||
}
|
||||
const validBrands = await (await database.prepare(`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`)).get(...brandIds);
|
||||
if (Number(validBrands?.count) !== brandIds.length) return fail(400, { gearError: 'One or more selected brand sets are unavailable.' });
|
||||
if (talentCategory) {
|
||||
const allowedCategories = talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
|
||||
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`)).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
|
||||
}
|
||||
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id), render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?")).get(id) ?? null } : null;
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
|
||||
.run(name, slot, tier, ...health, ...armor, now, id);
|
||||
} else {
|
||||
await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`))
|
||||
.run(gearId, name, slot, tier, ...health, ...armor, now, now);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId);
|
||||
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
|
||||
await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId);
|
||||
for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId);
|
||||
const render = await applyGameDataRender(database, 'gear', gearId, name, renderMutation, operator.id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds, render } });
|
||||
return { gearSaved: true, gearMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('gear', id);
|
||||
await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear.archived', entityId: id, before, after: await auditRecord('gear', id) });
|
||||
return { gearArchived: true };
|
||||
}
|
||||
VALUES(?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
augmentId,
|
||||
slot,
|
||||
augment.slot,
|
||||
augment.effectType,
|
||||
augment.statId || null,
|
||||
augment.value,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
for (const cost of augment.costs) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)',
|
||||
)
|
||||
).run(augmentId, cost.tier, cost.value);
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: before.length
|
||||
? 'gear_augment_profile.updated'
|
||||
: 'gear_augment_profile.created',
|
||||
entityId: slot,
|
||||
before,
|
||||
after: await augmentProfileSnapshot(slot),
|
||||
});
|
||||
return { augmentProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file)
|
||||
renderMutation.processed = await processManagedImage(
|
||||
renderMutation.file,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { gearError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const tier = Number(form.get('tier'));
|
||||
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
|
||||
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
|
||||
if (
|
||||
!slots.includes(slot) ||
|
||||
!Number.isInteger(tier) ||
|
||||
tier < 0 ||
|
||||
tier > 2
|
||||
) {
|
||||
return fail(400, { gearError: 'Choose a valid slot and tier.' });
|
||||
}
|
||||
if (brandIds.length < 2)
|
||||
return fail(400, {
|
||||
gearError: 'Gear must be available from at least two brand sets.',
|
||||
});
|
||||
const talentCategory =
|
||||
slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
|
||||
if (talentCategory && talentIds.length < 1)
|
||||
return fail(400, {
|
||||
gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.`,
|
||||
});
|
||||
if (!talentCategory && talentIds.length)
|
||||
return fail(400, { gearError: `${slot} gear cannot have talents.` });
|
||||
let health: readonly [number, number],
|
||||
armor: readonly [number, number] | [null, null];
|
||||
try {
|
||||
health = readRange(form, 'health');
|
||||
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
|
||||
} catch {
|
||||
return fail(400, {
|
||||
gearError:
|
||||
'Base ranges require non-negative minimums and maximums greater than or equal to them.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { gearError: 'That gear name already exists.' });
|
||||
}
|
||||
const validBrands = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...brandIds);
|
||||
if (Number(validBrands?.count) !== brandIds.length)
|
||||
return fail(400, {
|
||||
gearError: 'One or more selected brand sets are unavailable.',
|
||||
});
|
||||
if (talentCategory) {
|
||||
const allowedCategories =
|
||||
talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
|
||||
const validTalents = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length)
|
||||
return fail(400, {
|
||||
gearError: `Every selected talent must be a ${slot} talent.`,
|
||||
});
|
||||
}
|
||||
const gearId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
|
||||
now = new Date().toISOString();
|
||||
const before = id
|
||||
? {
|
||||
record: await auditRecord('gear', id),
|
||||
brandIds: await (
|
||||
await database.prepare(
|
||||
'SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?',
|
||||
)
|
||||
).all(id),
|
||||
talentIds: await (
|
||||
await database.prepare(
|
||||
'SELECT talent_id FROM gear_talents WHERE gear_id=?',
|
||||
)
|
||||
).all(id),
|
||||
render:
|
||||
(await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?",
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
}
|
||||
: null;
|
||||
if (id) {
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(name, slot, tier, ...health, ...armor, now, id);
|
||||
} else {
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(gearId, name, slot, tier, ...health, ...armor, now, now);
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')
|
||||
).run(gearId);
|
||||
for (const brandId of brandIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)',
|
||||
)
|
||||
).run(gearId, brandId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')
|
||||
).run(gearId);
|
||||
for (const talentId of talentIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)',
|
||||
)
|
||||
).run(gearId, talentId);
|
||||
const render = await applyGameDataRender(
|
||||
database,
|
||||
'gear',
|
||||
gearId,
|
||||
name,
|
||||
renderMutation,
|
||||
operator.id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear.updated' : 'gear.created',
|
||||
entityId: gearId,
|
||||
before,
|
||||
after: {
|
||||
record: await auditRecord('gear', gearId),
|
||||
brandIds,
|
||||
talentIds,
|
||||
render,
|
||||
},
|
||||
});
|
||||
return {
|
||||
gearSaved: true,
|
||||
gearMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('gear', id);
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('gear', id),
|
||||
});
|
||||
return { gearArchived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
let augmentEditing = $state<any | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const compatibleBrands = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)).filter(Boolean);
|
||||
const stat = (id: string) => data.stats.find((item) => item.id === id);
|
||||
const format = (value: number | string, statId: string) => {
|
||||
@@ -58,6 +59,11 @@
|
||||
editing = null;
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.gear.find((item) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
augmentEditing = null;
|
||||
@@ -95,7 +101,7 @@
|
||||
{/if}
|
||||
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
|
||||
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,198 +1,495 @@
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import {
|
||||
applyGameDataRender,
|
||||
ManagedImageValidationError,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
readGameDataRenderMutation,
|
||||
} from '$lib/server/managed-images';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
||||
type QualityValue = {
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
primaryLevel1Max: number;
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
primaryLevel1Max: number;
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type SecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<string, { min: number; max: number }>;
|
||||
};
|
||||
type SecondaryRange = { statId: string; values: Record<string, { min: number; max: number }> };
|
||||
|
||||
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
||||
try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; }
|
||||
try {
|
||||
return JSON.parse(String(value ?? '')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
return {
|
||||
record: await auditRecord('gear_mods', id),
|
||||
qualityValues: await (await database.prepare('SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality')).all(id),
|
||||
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id')).all(id),
|
||||
secondaryQualityValues: await (await database.prepare('SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality')).all(id)
|
||||
};
|
||||
const database = await getDatabase();
|
||||
return {
|
||||
record: await auditRecord('gear_mods', id),
|
||||
qualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryQualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(id),
|
||||
render:
|
||||
(await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM managed_images WHERE source_entity_type='gear_mod_render' AND source_entity_id=?",
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC' },
|
||||
secondaries: { asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC' },
|
||||
state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'pending', 'archived'].includes(requestedFilter)
|
||||
? requestedFilter
|
||||
: '';
|
||||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC,id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC,id DESC',
|
||||
},
|
||||
secondaries: {
|
||||
asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC',
|
||||
desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC',
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC,name COLLATE NOCASE ASC',
|
||||
desc: 'active DESC,name COLLATE NOCASE ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(requestedItemId
|
||||
? ['id=?']
|
||||
: filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'archived'
|
||||
? ['active=0']
|
||||
: filter === 'pending'
|
||||
? ['1=0']
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||||
: []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = [
|
||||
...(requestedItemId ? [requestedItemId] : []),
|
||||
...(!requestedItemId && search ? [pattern] : []),
|
||||
];
|
||||
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)).get(...args),
|
||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'gearmods', action: 'save', kind: 'gearmod', fields: ['qualityValues', 'secondaryRanges'] }])
|
||||
]);
|
||||
const pendingRows = filter === 'archived' || filter === 'active' ? [] : pendingMods.filter((item) =>
|
||||
!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length);
|
||||
const pendingOnPage = pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), [])
|
||||
}));
|
||||
return {
|
||||
mods: [...parsedPending, ...mods.map((mod) => ({
|
||||
...mod,
|
||||
qualityValues: qualityRows.filter((row) => row.gear_mod_id === mod.id).map((row) => ({
|
||||
quality: row.quality,
|
||||
primaryLevel1Min: Number(row.primary_level_1_min),
|
||||
primaryLevel1Max: Number(row.primary_level_1_max),
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
primaryLevel20Max: Number(row.primary_level_20_max)
|
||||
})),
|
||||
secondaryRanges: secondaryOptions.filter((option) => option.gear_mod_id === mod.id).map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(secondaryValues
|
||||
.filter((row) => row.gear_mod_id === mod.id && row.stat_attribute_id === option.stat_attribute_id)
|
||||
.map((row) => [String(row.quality), { min: Number(row.min_value), max: Number(row.max_value) }]))
|
||||
}))
|
||||
}))],
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingMods.length,
|
||||
pending: pendingMods.length
|
||||
},
|
||||
filter,
|
||||
pagination,
|
||||
sort,
|
||||
search
|
||||
};
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)
|
||||
).get(...args),
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['unit'],
|
||||
},
|
||||
]),
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'gearmods',
|
||||
action: 'save',
|
||||
kind: 'gearmod',
|
||||
fields: ['qualityValues', 'secondaryRanges'],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const pendingRows = requestedItemId
|
||||
? pendingMods.filter((item) => item.id === requestedItemId)
|
||||
: filter === 'archived' || filter === 'active'
|
||||
? []
|
||||
: pendingMods.filter(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
||||
);
|
||||
const pagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + pendingRows.length,
|
||||
);
|
||||
const pendingOnPage =
|
||||
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
|
||||
await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM gear_mod_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
||||
}));
|
||||
return {
|
||||
mods: [
|
||||
...parsedPending,
|
||||
...mods.map((mod) => ({
|
||||
...mod,
|
||||
qualityValues: qualityRows
|
||||
.filter((row) => row.gear_mod_id === mod.id)
|
||||
.map((row) => ({
|
||||
quality: row.quality,
|
||||
primaryLevel1Min: Number(row.primary_level_1_min),
|
||||
primaryLevel1Max: Number(row.primary_level_1_max),
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
primaryLevel20Max: Number(row.primary_level_20_max),
|
||||
})),
|
||||
secondaryRanges: secondaryOptions
|
||||
.filter((option) => option.gear_mod_id === mod.id)
|
||||
.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
secondaryValues
|
||||
.filter(
|
||||
(row) =>
|
||||
row.gear_mod_id === mod.id &&
|
||||
row.stat_attribute_id === option.stat_attribute_id,
|
||||
)
|
||||
.map((row) => [
|
||||
String(row.quality),
|
||||
{ min: Number(row.min_value), max: Number(row.max_value) },
|
||||
]),
|
||||
),
|
||||
})),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find(
|
||||
(item) => item.source_entity_id === mod.id,
|
||||
);
|
||||
return render
|
||||
? managedImageUrl(String(render.id), render.updated_at)
|
||||
: '';
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingMods.length,
|
||||
pending: pendingMods.length,
|
||||
},
|
||||
filter,
|
||||
requestedItemId,
|
||||
pagination,
|
||||
sort,
|
||||
search,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const qualityValues = parseJson<QualityValue[]>(form.get('qualityValues'), []);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(form.get('secondaryRanges'), []);
|
||||
if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.' });
|
||||
if (qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length ||
|
||||
qualityValues.some((row) => !qualities.includes(row.quality as typeof qualities[number]) ||
|
||||
[row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max].some((value) => !Number.isFinite(Number(value))) ||
|
||||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
|
||||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max))) {
|
||||
return fail(400, { gearModError: 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.' });
|
||||
}
|
||||
if (secondaryRanges.length < 2 || secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !== secondaryRanges.length ||
|
||||
secondaryRanges.some((range) => !range.statId || qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return !values || !Number.isFinite(Number(values.min)) || !Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min);
|
||||
}))) {
|
||||
return fail(400, { gearModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { gearModError: 'That Gear Mod name is already registered.' });
|
||||
}
|
||||
const statIds = secondaryRanges.map((range) => range.statId);
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length) return fail(400, { gearModError: 'One or more selected attributes are unavailable.' });
|
||||
const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
if (id && !before?.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id) await (await database.prepare('UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(modId, name, now, now);
|
||||
await (await database.prepare('DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?')).run(modId);
|
||||
for (const row of qualityValues) await (await database.prepare(`INSERT INTO gear_mod_quality_values
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file)
|
||||
renderMutation.processed = await processManagedImage(
|
||||
renderMutation.file,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { gearModError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const qualityValues = parseJson<QualityValue[]>(
|
||||
form.get('qualityValues'),
|
||||
[],
|
||||
);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
||||
form.get('secondaryRanges'),
|
||||
[],
|
||||
);
|
||||
if (name.length < 2 || name.length > 100)
|
||||
return fail(400, {
|
||||
gearModError: 'Gear Mod names must be 2–100 characters.',
|
||||
});
|
||||
if (
|
||||
qualityValues.length !== qualities.length ||
|
||||
new Set(qualityValues.map((row) => row.quality)).size !==
|
||||
qualities.length ||
|
||||
qualityValues.some(
|
||||
(row) =>
|
||||
!qualities.includes(row.quality as (typeof qualities)[number]) ||
|
||||
[
|
||||
row.primaryLevel1Min,
|
||||
row.primaryLevel1Max,
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
].some((value) => !Number.isFinite(Number(value))) ||
|
||||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
|
||||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
secondaryRanges.length < 2 ||
|
||||
secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !==
|
||||
secondaryRanges.length ||
|
||||
secondaryRanges.some(
|
||||
(range) =>
|
||||
!range.statId ||
|
||||
qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return (
|
||||
!values ||
|
||||
!Number.isFinite(Number(values.min)) ||
|
||||
!Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min)
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, {
|
||||
gearModError: 'That Gear Mod name is already registered.',
|
||||
});
|
||||
}
|
||||
const statIds = secondaryRanges.map((range) => range.statId);
|
||||
const validStats = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes
|
||||
WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`)
|
||||
).get(...statIds);
|
||||
if (Number(validStats?.count) !== statIds.length)
|
||||
return fail(400, {
|
||||
gearModError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const modId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
if (id && !before?.record)
|
||||
return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(name, now, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(modId, name, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const row of qualityValues)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_mod_quality_values
|
||||
(gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max)
|
||||
VALUES(?,?,?,?,?,?)`)).run(modId,row.quality,row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max);
|
||||
await (await database.prepare('DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?')).run(modId);
|
||||
for (const range of secondaryRanges) {
|
||||
await (await database.prepare('INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId, range.statId);
|
||||
for (const quality of qualities) await (await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`))
|
||||
.run(modId, range.statId, quality, range.values[quality].min, range.values[quality].max);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear_mod.updated' : 'gear_mod.created',
|
||||
entityId: modId,
|
||||
before,
|
||||
after: await snapshot(modId)
|
||||
});
|
||||
return { gearModSaved: true, gearModMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.archived', entityId: id, before, after: await snapshot(id) });
|
||||
return { gearModArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.unarchived', entityId: id, before, after: await snapshot(id) });
|
||||
return { gearModUnarchived: true };
|
||||
}
|
||||
VALUES(?,?,?,?,?,?)`)
|
||||
).run(
|
||||
modId,
|
||||
row.quality,
|
||||
row.primaryLevel1Min,
|
||||
row.primaryLevel1Max,
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const range of secondaryRanges) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(modId, range.statId);
|
||||
for (const quality of qualities)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
|
||||
).run(
|
||||
modId,
|
||||
range.statId,
|
||||
quality,
|
||||
range.values[quality].min,
|
||||
range.values[quality].max,
|
||||
);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await applyGameDataRender(
|
||||
database,
|
||||
'gear_mod',
|
||||
modId,
|
||||
name,
|
||||
renderMutation,
|
||||
operator.id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear_mod.updated' : 'gear_mod.created',
|
||||
entityId: modId,
|
||||
before,
|
||||
after: await snapshot(modId),
|
||||
});
|
||||
return {
|
||||
gearModSaved: true,
|
||||
gearModMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
archive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record)
|
||||
return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear_mod.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
});
|
||||
return { gearModArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record)
|
||||
return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear_mod.unarchived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
});
|
||||
return { gearModUnarchived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
@@ -21,6 +22,7 @@
|
||||
id: '',
|
||||
name: '',
|
||||
active: 1,
|
||||
renderUrl: '',
|
||||
qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(), blankSecondary()]
|
||||
});
|
||||
@@ -28,6 +30,8 @@
|
||||
let levelPreview = $state(1);
|
||||
let qualityPreview = $state('High-end');
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||
@@ -57,8 +61,14 @@
|
||||
}
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.gearModSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.gearModMode === 'add') renderResetToken += 1;
|
||||
editing = form.gearModMode === 'add' ? blank() : null;
|
||||
levelPreview = 1;
|
||||
qualityPreview = 'High-end';
|
||||
@@ -90,7 +100,7 @@
|
||||
<div><p class="eyebrow">{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}</p><h3>{editing.id ? editing.name : 'GEAR MOD DEFINITION'}</h3></div>
|
||||
<button aria-label="Close editor" onclick={() => editing = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/save">
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||||
@@ -108,6 +118,7 @@
|
||||
<small>One primary attribute is selected while configuring the mod in a build.</small>
|
||||
</div>
|
||||
</div>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="GEAR MOD RENDER" resetToken={renderResetToken}/>
|
||||
|
||||
<section class="weapon-mod-quality-section">
|
||||
<div class="selection-head">
|
||||
@@ -178,7 +189,7 @@
|
||||
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
|
||||
<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div>
|
||||
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
|
||||
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
|
||||
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
|
||||
<span>STANDARD → HIGH-END</span>
|
||||
|
||||
@@ -5,146 +5,246 @@ import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
MANAGED_IMAGE_CATEGORIES,
|
||||
ManagedImageDeletionError,
|
||||
ManagedImageValidationError,
|
||||
deleteManagedImage,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
saveManagedImageFile
|
||||
MANAGED_IMAGE_CATEGORIES,
|
||||
ManagedImageDeletionError,
|
||||
ManagedImageValidationError,
|
||||
deleteManagedImage,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
saveManagedImageFile,
|
||||
} from '$lib/server/managed-images';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const normalizeTags = (value: unknown) => [...new Set(String(value ?? '')
|
||||
.split(',').map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase()).filter(Boolean))]
|
||||
.slice(0, 20);
|
||||
const normalizeTags = (value: unknown) =>
|
||||
[
|
||||
...new Set(
|
||||
String(value ?? '')
|
||||
.split(',')
|
||||
.map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 20);
|
||||
|
||||
const readMetadata = (form: FormData) => {
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const category = String(form.get('category') ?? '').trim();
|
||||
const tags = normalizeTags(form.get('tags'));
|
||||
if (name.length < 2 || name.length > 80) return { error: 'Name must be 2–80 characters.' } as const;
|
||||
if (!MANAGED_IMAGE_CATEGORIES.includes(category as (typeof MANAGED_IMAGE_CATEGORIES)[number])) return { error: 'Choose a valid assignment category.' } as const;
|
||||
if (tags.some((tag) => tag.length > 30)) return { error: 'Tags must be 30 characters or fewer.' } as const;
|
||||
return { name, category, tags } as const;
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const category = String(form.get('category') ?? '').trim();
|
||||
const tags = normalizeTags(form.get('tags'));
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return { error: 'Name must be 2–80 characters.' } as const;
|
||||
if (
|
||||
!MANAGED_IMAGE_CATEGORIES.includes(
|
||||
category as (typeof MANAGED_IMAGE_CATEGORIES)[number],
|
||||
)
|
||||
)
|
||||
return { error: 'Choose a valid assignment category.' } as const;
|
||||
if (tags.some((tag) => tag.length > 30))
|
||||
return { error: 'Tags must be 30 characters or fewer.' } as const;
|
||||
return { name, category, tags } as const;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
|
||||
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
|
||||
const perPage = [10, 25, 50, 100].includes(requestedPerPage) ? requestedPerPage : 25;
|
||||
const requestedPage = Math.max(1, Number(url.searchParams.get('page') ?? 1) || 1);
|
||||
const where = query ? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE` : '';
|
||||
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const args = query ? [escaped, escaped, escaped] : [];
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM managed_images ${where}`)).get(...args))?.count ?? 0);
|
||||
const pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(requestedPage, pageCount);
|
||||
const images = await (await database.prepare(`SELECT managed_images.*, users.username AS creator_username
|
||||
await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
|
||||
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
|
||||
const perPage = [10, 25, 50, 100].includes(requestedPerPage)
|
||||
? requestedPerPage
|
||||
: 25;
|
||||
const requestedPage = Math.max(
|
||||
1,
|
||||
Number(url.searchParams.get('page') ?? 1) || 1,
|
||||
);
|
||||
const where = query
|
||||
? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE`
|
||||
: '';
|
||||
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const args = query ? [escaped, escaped, escaped] : [];
|
||||
const total = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM managed_images ${where}`,
|
||||
)
|
||||
).get(...args)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(requestedPage, pageCount);
|
||||
const images = await (
|
||||
await database.prepare(`SELECT managed_images.*, users.username AS creator_username
|
||||
FROM managed_images LEFT JOIN users ON users.id=managed_images.created_by ${where}
|
||||
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)).all(...args, perPage, (page - 1) * perPage);
|
||||
const stats = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...args, perPage, (page - 1) * perPage);
|
||||
const stats = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(is_icon),0) AS icons, COALESCE(SUM(byte_size),0) AS bytes,
|
||||
COUNT(DISTINCT category) AS categories,
|
||||
COALESCE(SUM(CASE WHEN source_entity_type IS NOT NULL THEN 1 ELSE 0 END),0) AS linked
|
||||
FROM managed_images`)).get();
|
||||
return {
|
||||
categories: MANAGED_IMAGE_CATEGORIES,
|
||||
images: images.map((image) => ({
|
||||
...image,
|
||||
tags: JSON.parse(String(image.tags_json)),
|
||||
url: managedImageUrl(String(image.id), image.updated_at)
|
||||
})),
|
||||
stats,
|
||||
search: query,
|
||||
pagination: { page, perPage, total, pageCount }
|
||||
};
|
||||
FROM managed_images`)
|
||||
).get();
|
||||
return {
|
||||
categories: MANAGED_IMAGE_CATEGORIES,
|
||||
images: images.map((image) => ({
|
||||
...image,
|
||||
tags: JSON.parse(String(image.tags_json)),
|
||||
url: managedImageUrl(String(image.id), image.updated_at),
|
||||
})),
|
||||
stats,
|
||||
search: query,
|
||||
pagination: { page, perPage, total, pageCount },
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const form = await request.formData();
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const file = form.get('image');
|
||||
if (!(file instanceof File) || !file.size) return fail(400, { imageError: 'Choose an image to upload.' });
|
||||
const isIcon = form.get('isIcon') === 'on';
|
||||
const forceWhite = isIcon && form.get('forceWhite') === 'on';
|
||||
try {
|
||||
const processed = await processManagedImage(file, isIcon, forceWhite);
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
id, ...metadata, is_icon: isIcon ? 1 : 0, force_white: forceWhite ? 1 : 0,
|
||||
storage_key: storageKey, original_filename: file.name, byte_size: processed.output.length,
|
||||
width: processed.width, height: processed.height, created_by: operator.id,
|
||||
created_at: now, updated_at: now
|
||||
};
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO managed_images
|
||||
upload: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'images.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const file = form.get('image');
|
||||
if (!(file instanceof File) || !file.size)
|
||||
return fail(400, { imageError: 'Choose an image to upload.' });
|
||||
const isIcon = form.get('isIcon') === 'on';
|
||||
const forceWhite = isIcon && form.get('forceWhite') === 'on';
|
||||
try {
|
||||
const processed = await processManagedImage(file, isIcon, forceWhite);
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
id,
|
||||
...metadata,
|
||||
is_icon: isIcon ? 1 : 0,
|
||||
force_white: forceWhite ? 1 : 0,
|
||||
storage_key: storageKey,
|
||||
original_filename: file.name,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
created_by: operator.id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
const database = await getDatabase();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO managed_images
|
||||
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
id, metadata.name, metadata.category, JSON.stringify(metadata.tags), isIcon ? 1 : 0,
|
||||
forceWhite ? 1 : 0, storageKey, file.name, processed.output.length,
|
||||
processed.width, processed.height, operator.id, now, now
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.created', entityId: id, before: null, after
|
||||
});
|
||||
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError) return fail(400, { imageError: error.message });
|
||||
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
|
||||
if (!before) return fail(404, { imageError: 'Image not found.' });
|
||||
const updatedAt = new Date().toISOString();
|
||||
try {
|
||||
await (await database.prepare('UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?'))
|
||||
.run(metadata.name, metadata.category, JSON.stringify(metadata.tags), updatedAt, id);
|
||||
const after = { ...before, name: metadata.name, category: metadata.category, tags_json: JSON.stringify(metadata.tags), updated_at: updatedAt };
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.updated', entityId: id, before, after
|
||||
});
|
||||
return { imageSaved: true, editorMode: 'edit', resultToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const database = await getDatabase();
|
||||
let deleted;
|
||||
try {
|
||||
deleted = await deleteManagedImage(database, id);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageDeletionError) return fail(409, { imageError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!deleted) return fail(404, { imageError: 'Image not found.' });
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.deleted', entityId: id,
|
||||
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
|
||||
after: { libraryImage: null, sourceIcon: null }
|
||||
});
|
||||
return { imageDeleted: true, resultToken: randomUUID() };
|
||||
}
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
metadata.name,
|
||||
metadata.category,
|
||||
JSON.stringify(metadata.tags),
|
||||
isIcon ? 1 : 0,
|
||||
forceWhite ? 1 : 0,
|
||||
storageKey,
|
||||
file.name,
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
operator.id,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'image.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after,
|
||||
});
|
||||
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { imageError: error.message });
|
||||
if (String(error).includes('UNIQUE'))
|
||||
return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'images.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const database = await getDatabase();
|
||||
const before = await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id);
|
||||
if (!before) return fail(404, { imageError: 'Image not found.' });
|
||||
const updatedAt = new Date().toISOString();
|
||||
try {
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(
|
||||
metadata.name,
|
||||
metadata.category,
|
||||
JSON.stringify(metadata.tags),
|
||||
updatedAt,
|
||||
id,
|
||||
);
|
||||
const after = {
|
||||
...before,
|
||||
name: metadata.name,
|
||||
category: metadata.category,
|
||||
tags_json: JSON.stringify(metadata.tags),
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'image.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
return {
|
||||
imageSaved: true,
|
||||
editorMode: 'edit',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (String(error).includes('UNIQUE'))
|
||||
return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'images.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const database = await getDatabase();
|
||||
let deleted;
|
||||
try {
|
||||
deleted = await deleteManagedImage(database, id);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageDeletionError)
|
||||
return fail(409, { imageError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!deleted) return fail(404, { imageError: 'Image not found.' });
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'image.deleted',
|
||||
entityId: id,
|
||||
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
|
||||
after: { libraryImage: null, sourceIcon: null },
|
||||
});
|
||||
return { imageDeleted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,21 +4,25 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const attributes = await (await database.prepare(`
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const attributes = await (
|
||||
await database.prepare(`
|
||||
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
|
||||
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
|
||||
FROM stat_attributes
|
||||
WHERE active=1
|
||||
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
|
||||
`)).all();
|
||||
`)
|
||||
).all();
|
||||
|
||||
return {
|
||||
attributes,
|
||||
attributeSummary: {
|
||||
total: attributes.length,
|
||||
documented: attributes.filter((attribute) => String(attribute.description ?? '').trim()).length
|
||||
}
|
||||
};
|
||||
return {
|
||||
attributes,
|
||||
attributeSummary: {
|
||||
total: attributes.length,
|
||||
documented: attributes.filter((attribute) =>
|
||||
String(attribute.description ?? '').trim(),
|
||||
).length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,10 +4,23 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os';
|
||||
import {
|
||||
arch,
|
||||
availableParallelism,
|
||||
cpus,
|
||||
freemem,
|
||||
loadavg,
|
||||
platform,
|
||||
release,
|
||||
totalmem,
|
||||
uptime,
|
||||
} from 'node:os';
|
||||
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getRemoveBgCreditStatus, refreshRemoveBgCredits } from '$lib/server/remove-bg-monitor';
|
||||
import {
|
||||
getRemoveBgCreditStatus,
|
||||
refreshRemoveBgCredits,
|
||||
} from '$lib/server/remove-bg-monitor';
|
||||
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||
loopDelay.enable();
|
||||
@@ -16,79 +29,110 @@ let previousCpuAt = performance.now();
|
||||
let previousElu = performance.eventLoopUtilization();
|
||||
|
||||
const numericPragma = (row: Record<string, unknown> | undefined) =>
|
||||
Number(Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0);
|
||||
Number(
|
||||
Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0,
|
||||
);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'metrics.view');
|
||||
const startedAt = performance.now();
|
||||
const database = await getDatabase();
|
||||
const databasePath = resolve(env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||
const now = performance.now();
|
||||
const cpu = process.cpuUsage(previousCpu);
|
||||
const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000);
|
||||
previousCpu = process.cpuUsage();
|
||||
previousCpuAt = now;
|
||||
const elu = performance.eventLoopUtilization(previousElu);
|
||||
previousElu = performance.eventLoopUtilization();
|
||||
const memory = process.memoryUsage();
|
||||
await requirePermission(await getSessionUser(cookies), 'metrics.view');
|
||||
const startedAt = performance.now();
|
||||
const database = await getDatabase();
|
||||
const databasePath = resolve(
|
||||
env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||
);
|
||||
const now = performance.now();
|
||||
const cpu = process.cpuUsage(previousCpu);
|
||||
const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000);
|
||||
previousCpu = process.cpuUsage();
|
||||
previousCpuAt = now;
|
||||
const elu = performance.eventLoopUtilization(previousElu);
|
||||
previousElu = performance.eventLoopUtilization();
|
||||
const memory = process.memoryUsage();
|
||||
|
||||
const [pageCount, pageSize, freePages, tableCount, counts, databaseFile, removeBackground] = await Promise.all([
|
||||
(await database.prepare('PRAGMA page_count')).get(),
|
||||
(await database.prepare('PRAGMA page_size')).get(),
|
||||
(await database.prepare('PRAGMA freelist_count')).get(),
|
||||
(await database.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")).get(),
|
||||
(await database.prepare(`SELECT
|
||||
const [
|
||||
pageCount,
|
||||
pageSize,
|
||||
freePages,
|
||||
tableCount,
|
||||
counts,
|
||||
databaseFile,
|
||||
removeBackground,
|
||||
] = await Promise.all([
|
||||
(await database.prepare('PRAGMA page_count')).get(),
|
||||
(await database.prepare('PRAGMA page_size')).get(),
|
||||
(await database.prepare('PRAGMA freelist_count')).get(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM users) AS users,
|
||||
(SELECT COUNT(*) FROM sessions WHERE expires_at>?) AS active_sessions,
|
||||
(SELECT COUNT(*) FROM builds) AS builds,
|
||||
(SELECT COUNT(*) FROM comments) AS comments,
|
||||
(SELECT COUNT(*) FROM audit_log) AS audit_events,
|
||||
(SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+
|
||||
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)).get(new Date().toISOString()),
|
||||
stat(databasePath).catch(() => null),
|
||||
refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus())
|
||||
]);
|
||||
const pages = numericPragma(pageCount);
|
||||
const bytesPerPage = numericPragma(pageSize);
|
||||
const unusedPages = numericPragma(freePages);
|
||||
const hostTotalMemory = totalmem();
|
||||
const hostFreeMemory = freemem();
|
||||
const coreCount = availableParallelism?.() ?? cpus().length;
|
||||
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)
|
||||
).get(new Date().toISOString()),
|
||||
stat(databasePath).catch(() => null),
|
||||
refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus()),
|
||||
]);
|
||||
const pages = numericPragma(pageCount);
|
||||
const bytesPerPage = numericPragma(pageSize);
|
||||
const unusedPages = numericPragma(freePages);
|
||||
const hostTotalMemory = totalmem();
|
||||
const hostFreeMemory = freemem();
|
||||
const coreCount = availableParallelism?.() ?? cpus().length;
|
||||
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
collectionMs: Number((performance.now() - startedAt).toFixed(2)),
|
||||
process: {
|
||||
uptimeSeconds: process.uptime(),
|
||||
cpuPercent: Number((((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2)),
|
||||
rss: memory.rss,
|
||||
heapUsed: memory.heapUsed,
|
||||
heapTotal: memory.heapTotal,
|
||||
external: memory.external,
|
||||
eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)),
|
||||
eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2))
|
||||
},
|
||||
host: {
|
||||
platform: platform(), release: release(), architecture: arch(), cores: coreCount,
|
||||
uptimeSeconds: uptime(), loadAverage: loadavg(), totalMemory: hostTotalMemory,
|
||||
freeMemory: hostFreeMemory, usedMemoryPercent: Number((((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(2))
|
||||
},
|
||||
runtime: {
|
||||
node: process.version,
|
||||
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
||||
pid: process.pid
|
||||
},
|
||||
database: {
|
||||
filename: basename(databasePath),
|
||||
fileSize: databaseFile?.size ?? pages * bytesPerPage,
|
||||
modifiedAt: databaseFile?.mtime.toISOString() ?? null,
|
||||
pageCount: pages,
|
||||
pageSize: bytesPerPage,
|
||||
freePages: unusedPages,
|
||||
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
|
||||
tableCount: Number(tableCount?.count ?? 0)
|
||||
},
|
||||
counts,
|
||||
services: { removeBackground }
|
||||
};
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
collectionMs: Number((performance.now() - startedAt).toFixed(2)),
|
||||
process: {
|
||||
uptimeSeconds: process.uptime(),
|
||||
cpuPercent: Number(
|
||||
(((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2),
|
||||
),
|
||||
rss: memory.rss,
|
||||
heapUsed: memory.heapUsed,
|
||||
heapTotal: memory.heapTotal,
|
||||
external: memory.external,
|
||||
eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)),
|
||||
eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2)),
|
||||
},
|
||||
host: {
|
||||
platform: platform(),
|
||||
release: release(),
|
||||
architecture: arch(),
|
||||
cores: coreCount,
|
||||
uptimeSeconds: uptime(),
|
||||
loadAverage: loadavg(),
|
||||
totalMemory: hostTotalMemory,
|
||||
freeMemory: hostFreeMemory,
|
||||
usedMemoryPercent: Number(
|
||||
(((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(
|
||||
2,
|
||||
),
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
node: process.version,
|
||||
environment:
|
||||
process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
||||
pid: process.pid,
|
||||
},
|
||||
database: {
|
||||
filename: basename(databasePath),
|
||||
fileSize: databaseFile?.size ?? pages * bytesPerPage,
|
||||
modifiedAt: databaseFile?.mtime.toISOString() ?? null,
|
||||
pageCount: pages,
|
||||
pageSize: bytesPerPage,
|
||||
freePages: unusedPages,
|
||||
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
|
||||
tableCount: Number(tableCount?.count ?? 0),
|
||||
},
|
||||
counts,
|
||||
services: { removeBackground },
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||
@@ -31,13 +32,15 @@
|
||||
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||
const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
||||
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
||||
let editing = $state<any | null>(null);
|
||||
let profileEditing = $state<any | null>(null);
|
||||
let talentUpgrade = $state(0);
|
||||
let protocolLevel = $state(1);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
|
||||
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
|
||||
@@ -93,13 +96,21 @@
|
||||
protocolLevel = 1;
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.protocols.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.profileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
profileEditing = null;
|
||||
}
|
||||
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.protocolMode === 'add') iconResetToken += 1;
|
||||
if (form.protocolMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
editing = form.protocolMode === 'add' ? blank() : null;
|
||||
talentUpgrade = 0;
|
||||
protocolLevel = 1;
|
||||
@@ -153,7 +164,7 @@
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
||||
<div class="protocol-identity-grid">
|
||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||
<div class="protocol-classification-stack">
|
||||
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
|
||||
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
getPermissions,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -9,206 +16,357 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
||||
const seededRoleIds = new Set([
|
||||
'role-data-editor',
|
||||
'role-user-operator',
|
||||
'role-access-manager',
|
||||
'role-admin',
|
||||
'role-moderator',
|
||||
'role-data-viewer',
|
||||
'role-data-contributor',
|
||||
'role-data-approver',
|
||||
'role-auditor'
|
||||
'role-data-editor',
|
||||
'role-user-operator',
|
||||
'role-access-manager',
|
||||
'role-admin',
|
||||
'role-moderator',
|
||||
'role-data-viewer',
|
||||
'role-data-contributor',
|
||||
'role-data-approver',
|
||||
'role-auditor',
|
||||
]);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const database = await getDatabase();
|
||||
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'roles.name COLLATE NOCASE ASC', desc: 'roles.name COLLATE NOCASE DESC' },
|
||||
users: { asc: 'user_count ASC', desc: 'user_count DESC', defaultDirection: 'desc' },
|
||||
permissions: {
|
||||
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
|
||||
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
|
||||
defaultDirection: 'desc'
|
||||
},
|
||||
state: {
|
||||
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
|
||||
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC"
|
||||
}
|
||||
}, 'name');
|
||||
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
|
||||
const relatedPermissionFilter = filter === 'data'
|
||||
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
|
||||
: filter === 'system'
|
||||
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
|
||||
: '';
|
||||
const conditions = [
|
||||
...(permissionPrefix ? [`EXISTS (
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const database = await getDatabase();
|
||||
const filter = filters.includes(url.searchParams.get('filter') ?? '')
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'roles.name COLLATE NOCASE ASC',
|
||||
desc: 'roles.name COLLATE NOCASE DESC',
|
||||
},
|
||||
users: {
|
||||
asc: 'user_count ASC',
|
||||
desc: 'user_count DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
permissions: {
|
||||
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
|
||||
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
state: {
|
||||
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
|
||||
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC",
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const permissionPrefix =
|
||||
filter === 'isac'
|
||||
? 'isac.%'
|
||||
: filter === 'data'
|
||||
? 'data.%'
|
||||
: filter === 'users'
|
||||
? 'users.%'
|
||||
: filter === 'audit'
|
||||
? 'audit.%'
|
||||
: filter === 'system'
|
||||
? 'system.%'
|
||||
: '';
|
||||
const relatedPermissionFilter =
|
||||
filter === 'data'
|
||||
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
|
||||
: filter === 'system'
|
||||
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
|
||||
: '';
|
||||
const conditions = [
|
||||
...(permissionPrefix
|
||||
? [
|
||||
`EXISTS (
|
||||
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||
WHERE rp.role_id=roles.id AND (p.name LIKE ?${relatedPermissionFilter})
|
||||
)`] : []),
|
||||
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const filterArgs = [...(permissionPrefix ? [permissionPrefix] : []), ...(search ? [pattern] : [])];
|
||||
const [summary, filtered, permissions] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
)`,
|
||||
]
|
||||
: []),
|
||||
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const filterArgs = [
|
||||
...(permissionPrefix ? [permissionPrefix] : []),
|
||||
...(search ? [pattern] : []),
|
||||
];
|
||||
const [summary, filtered, permissions] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%')) THEN 1 ELSE 0 END) AS data,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
|
||||
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system
|
||||
FROM roles`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
|
||||
(await database.prepare(`SELECT id,name,description FROM permissions
|
||||
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)).all()
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const roles = await (await database.prepare(`SELECT roles.*,
|
||||
FROM roles`)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)
|
||||
).get(...filterArgs),
|
||||
(
|
||||
await database.prepare(`SELECT id,name,description FROM permissions
|
||||
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)
|
||||
).all(),
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const roles = await (
|
||||
await database.prepare(`SELECT roles.*,
|
||||
(SELECT COUNT(*) FROM user_roles WHERE role_id=roles.id) AS user_count
|
||||
FROM roles ${where} ORDER BY ${sort.orderBy},roles.name COLLATE NOCASE,roles.id
|
||||
LIMIT ? OFFSET ?`)).all(...filterArgs, pagination.perPage, pagination.offset);
|
||||
const roleIds = roles.map((role) => String(role.id));
|
||||
const links = roleIds.length ? await (await database.prepare(`SELECT role_id,permission_id FROM role_permissions
|
||||
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)).all(...roleIds) : [];
|
||||
return {
|
||||
roles: roles.map((role) => ({
|
||||
...role,
|
||||
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
|
||||
systemRole: seededRoleIds.has(String(role.id)),
|
||||
protected: role.id === 'role-admin',
|
||||
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
|
||||
canDelete: role.id !== 'role-admin'
|
||||
&& (canManageSystemAdmin || !seededRoleIds.has(String(role.id)))
|
||||
})),
|
||||
permissions,
|
||||
summary,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
pagination,
|
||||
canManageSystemAdmin
|
||||
};
|
||||
LIMIT ? OFFSET ?`)
|
||||
).all(...filterArgs, pagination.perPage, pagination.offset);
|
||||
const roleIds = roles.map((role) => String(role.id));
|
||||
const links = roleIds.length
|
||||
? await (
|
||||
await database.prepare(`SELECT role_id,permission_id FROM role_permissions
|
||||
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)
|
||||
).all(...roleIds)
|
||||
: [];
|
||||
return {
|
||||
roles: roles.map((role) => ({
|
||||
...role,
|
||||
permissionIds: links
|
||||
.filter((link) => link.role_id === role.id)
|
||||
.map((link) => String(link.permission_id)),
|
||||
systemRole: seededRoleIds.has(String(role.id)),
|
||||
protected: role.id === 'role-admin',
|
||||
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
|
||||
canDelete:
|
||||
role.id !== 'role-admin' &&
|
||||
(canManageSystemAdmin || !seededRoleIds.has(String(role.id))),
|
||||
})),
|
||||
permissions,
|
||||
summary,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
pagination,
|
||||
canManageSystemAdmin,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
|
||||
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
|
||||
if (name.length < 2 || name.length > 60) return fail(400, { roleError: 'Role names must be 2–60 characters.' });
|
||||
if (description.length > 240) return fail(400, { roleError: 'Descriptions must be 240 characters or fewer.' });
|
||||
const database = await getDatabase();
|
||||
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
|
||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
||||
return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.' });
|
||||
}
|
||||
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { roleError: 'A role with that name already exists.' });
|
||||
}
|
||||
const validPermissions = permissionIds.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).all(...permissionIds) : [];
|
||||
if (validPermissions.length !== permissionIds.length) return fail(400, { roleError: 'One or more permissions are invalid.' });
|
||||
if (!canManageSystemAdmin && validPermissions.some((permission) => permission.name === 'system.admin')) {
|
||||
return fail(403, { roleError: 'Only a full administrator can grant unrestricted system access.' });
|
||||
}
|
||||
const roleId = id || randomUUID();
|
||||
const beforeLinks = id ? await (await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(id) : [];
|
||||
if (id) await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(name, description || null, id);
|
||||
else await (await database.prepare('INSERT INTO roles(id,name,description) VALUES(?,?,?)')).run(roleId, name, description || null);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId);
|
||||
for (const permissionId of permissionIds) {
|
||||
await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(roleId, permissionId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'role_management', action: id ? 'role.updated' : 'role.created', entityId: roleId,
|
||||
before: id ? { name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) } : null,
|
||||
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
|
||||
});
|
||||
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||||
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
|
||||
if (id === 'role-admin') {
|
||||
return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.' });
|
||||
}
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
|
||||
return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const role = await (await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?'
|
||||
)).get(id);
|
||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name)) {
|
||||
return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.' });
|
||||
}
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const description = String(form.get('description') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const permissionIds = [
|
||||
...new Set(form.getAll('permissionIds').map(String)),
|
||||
];
|
||||
if (name.length < 2 || name.length > 60)
|
||||
return fail(400, { roleError: 'Role names must be 2–60 characters.' });
|
||||
if (description.length > 240)
|
||||
return fail(400, {
|
||||
roleError: 'Descriptions must be 240 characters or fewer.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const existing = id
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(id)
|
||||
: null;
|
||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
||||
return fail(403, {
|
||||
roleError: 'Only a full administrator can modify the core Admin role.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { roleError: 'A role with that name already exists.' });
|
||||
}
|
||||
const validPermissions = permissionIds.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionIds)
|
||||
: [];
|
||||
if (validPermissions.length !== permissionIds.length)
|
||||
return fail(400, { roleError: 'One or more permissions are invalid.' });
|
||||
if (
|
||||
!canManageSystemAdmin &&
|
||||
validPermissions.some((permission) => permission.name === 'system.admin')
|
||||
) {
|
||||
return fail(403, {
|
||||
roleError:
|
||||
'Only a full administrator can grant unrestricted system access.',
|
||||
});
|
||||
}
|
||||
const roleId = id || randomUUID();
|
||||
const beforeLinks = id
|
||||
? await (
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)
|
||||
).all(id)
|
||||
: [];
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE roles SET name=?,description=? WHERE id=?',
|
||||
)
|
||||
).run(name, description || null, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
|
||||
)
|
||||
).run(roleId, name, description || null);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(roleId);
|
||||
for (const permissionId of permissionIds) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(roleId, permissionId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: id ? 'role.updated' : 'role.created',
|
||||
entityId: roleId,
|
||||
before: id
|
||||
? {
|
||||
name: existing?.name,
|
||||
description: existing?.description,
|
||||
permissions: beforeLinks.map((row) => row.name),
|
||||
}
|
||||
: null,
|
||||
after: {
|
||||
name,
|
||||
description: description || null,
|
||||
permissions: validPermissions.map((row) => row.name),
|
||||
},
|
||||
});
|
||||
return {
|
||||
roleSaved: true,
|
||||
roleMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const confirmation = String(form.get('confirmation') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
|
||||
if (id === 'role-admin') {
|
||||
return fail(403, {
|
||||
roleDeleteError: 'The core Admin role cannot be deleted.',
|
||||
});
|
||||
}
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
|
||||
return fail(403, {
|
||||
roleDeleteError:
|
||||
'Only a full administrator can delete a seeded system role.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const role = await (
|
||||
await database.prepare('SELECT id,name,description FROM roles WHERE id=?')
|
||||
).get(id);
|
||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name)) {
|
||||
return fail(400, {
|
||||
roleDeleteError: 'Enter the exact role name to confirm deletion.',
|
||||
});
|
||||
}
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)).all(id),
|
||||
(await database.prepare(`SELECT users.id,
|
||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
|
||||
).all(id),
|
||||
(
|
||||
await database.prepare(`SELECT users.id,
|
||||
COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||
FROM user_roles JOIN users ON users.id=user_roles.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
WHERE user_roles.role_id=?
|
||||
ORDER BY display_name COLLATE NOCASE,users.id`)).all(id)
|
||||
]);
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (seededRoleIds.has(id)) {
|
||||
await (await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||
ORDER BY display_name COLLATE NOCASE,users.id`)
|
||||
).all(id),
|
||||
]);
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (seededRoleIds.has(id)) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(role_id) DO UPDATE SET
|
||||
deleted_at=excluded.deleted_at,
|
||||
deleted_by=excluded.deleted_by`))
|
||||
.run(id, new Date().toISOString(), operator.id);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(id);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(id);
|
||||
const deleted = await (await database.prepare('DELETE FROM roles WHERE id=?')).run(id);
|
||||
if (!deleted.changes) throw new Error('The role changed before it could be deleted.');
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: 'role.deleted',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: String(role.name),
|
||||
description: role.description ? String(role.description) : null,
|
||||
permissions: permissions.map((row) => String(row.name)),
|
||||
assignedUsers: assignedUsers.map((row) => ({
|
||||
id: String(row.id),
|
||||
displayName: String(row.display_name)
|
||||
}))
|
||||
},
|
||||
after: {
|
||||
deleted: true,
|
||||
systemRole: seededRoleIds.has(id),
|
||||
removedAssignmentCount: assignedUsers.length
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { roleDeleted: true, resultToken: randomUUID() };
|
||||
}
|
||||
deleted_by=excluded.deleted_by`)
|
||||
).run(id, new Date().toISOString(), operator.id);
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE role_id=?')
|
||||
).run(id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(id);
|
||||
const deleted = await (
|
||||
await database.prepare('DELETE FROM roles WHERE id=?')
|
||||
).run(id);
|
||||
if (!deleted.changes)
|
||||
throw new Error('The role changed before it could be deleted.');
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: 'role.deleted',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: String(role.name),
|
||||
description: role.description ? String(role.description) : null,
|
||||
permissions: permissions.map((row) => String(row.name)),
|
||||
assignedUsers: assignedUsers.map((row) => ({
|
||||
id: String(row.id),
|
||||
displayName: String(row.display_name),
|
||||
})),
|
||||
},
|
||||
after: {
|
||||
deleted: true,
|
||||
systemRole: seededRoleIds.has(id),
|
||||
removedAssignmentCount: assignedUsers.length,
|
||||
},
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { roleDeleted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,223 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
executed: [] as string[],
|
||||
inserts: [] as unknown[][],
|
||||
invalidatePolicy: vi.fn(),
|
||||
invalidateSiteAccess: vi.fn(),
|
||||
siteSettings: {} as Record<string, unknown>,
|
||||
moderationSettings: {} as Record<string, unknown>
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
executed: [] as string[],
|
||||
inserts: [] as unknown[][],
|
||||
invalidatePolicy: vi.fn(),
|
||||
invalidateSiteAccess: vi.fn(),
|
||||
siteSettings: {} as Record<string, unknown>,
|
||||
moderationSettings: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' }))
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requirePermission: vi.fn(async (user) => user)
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: vi.fn(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
})
|
||||
recordIsacAudit: vi.fn(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/site-access', () => ({
|
||||
invalidateSiteAccessSettings: mocks.invalidateSiteAccess
|
||||
invalidateSiteAccessSettings: mocks.invalidateSiteAccess,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/community-moderation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('$lib/server/community-moderation')>();
|
||||
return { ...actual, invalidateCommunityModerationPolicy: mocks.invalidatePolicy };
|
||||
const actual =
|
||||
await importOriginal<typeof import('$lib/server/community-moderation')>();
|
||||
return {
|
||||
...actual,
|
||||
invalidateCommunityModerationPolicy: mocks.invalidatePolicy,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => ({
|
||||
exec: async (statement: string) => {
|
||||
mocks.executed.push(statement);
|
||||
},
|
||||
prepare: (statement: string) => ({
|
||||
all: async () => statement.includes('FROM community_moderation_terms') ? [] : [],
|
||||
get: async () => {
|
||||
if (statement.includes('FROM site_launch_settings')) return { ...mocks.siteSettings };
|
||||
if (statement.includes('FROM community_moderation_settings')) return { ...mocks.moderationSettings };
|
||||
return null;
|
||||
},
|
||||
run: async (...values: unknown[]) => {
|
||||
if (statement.includes('INSERT INTO community_moderation_terms')) mocks.inserts.push(values);
|
||||
if (statement.includes('SET coming_soon_enabled=')) {
|
||||
mocks.siteSettings.coming_soon_enabled = values[0];
|
||||
mocks.siteSettings.updated_at = values[1];
|
||||
mocks.siteSettings.updated_by = values[2];
|
||||
}
|
||||
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
|
||||
mocks.moderationSettings.automated_enabled = values[0];
|
||||
mocks.moderationSettings.provider_model = values[1];
|
||||
mocks.moderationSettings.policy_version = Number(mocks.moderationSettings.policy_version) + 1;
|
||||
mocks.moderationSettings.updated_at = values[2];
|
||||
mocks.moderationSettings.updated_by = values[3];
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
})
|
||||
}))
|
||||
getDatabase: vi.fn(async () => ({
|
||||
exec: async (statement: string) => {
|
||||
mocks.executed.push(statement);
|
||||
},
|
||||
prepare: (statement: string) => ({
|
||||
all: async () =>
|
||||
statement.includes('FROM community_moderation_terms') ? [] : [],
|
||||
get: async () => {
|
||||
if (statement.includes('FROM site_launch_settings'))
|
||||
return { ...mocks.siteSettings };
|
||||
if (statement.includes('FROM community_moderation_settings'))
|
||||
return { ...mocks.moderationSettings };
|
||||
return null;
|
||||
},
|
||||
run: async (...values: unknown[]) => {
|
||||
if (statement.includes('INSERT INTO community_moderation_terms'))
|
||||
mocks.inserts.push(values);
|
||||
if (statement.includes('SET coming_soon_enabled=')) {
|
||||
mocks.siteSettings.coming_soon_enabled = values[0];
|
||||
mocks.siteSettings.updated_at = values[1];
|
||||
mocks.siteSettings.updated_by = values[2];
|
||||
}
|
||||
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
|
||||
mocks.moderationSettings.automated_enabled = values[0];
|
||||
mocks.moderationSettings.provider_model = values[1];
|
||||
mocks.moderationSettings.policy_version =
|
||||
Number(mocks.moderationSettings.policy_version) + 1;
|
||||
mocks.moderationSettings.updated_at = values[2];
|
||||
mocks.moderationSettings.updated_by = values[3];
|
||||
}
|
||||
return { changes: 1 };
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
describe('bulk moderation term action', () => {
|
||||
beforeEach(() => {
|
||||
mocks.audits.length = 0;
|
||||
mocks.executed.length = 0;
|
||||
mocks.inserts.length = 0;
|
||||
mocks.invalidatePolicy.mockClear();
|
||||
mocks.invalidateSiteAccess.mockClear();
|
||||
mocks.siteSettings = {
|
||||
id: 'primary',
|
||||
coming_soon_enabled: 1,
|
||||
account_creation_enabled: 1,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null
|
||||
};
|
||||
mocks.moderationSettings = {
|
||||
id: 'primary',
|
||||
automated_enabled: 1,
|
||||
provider_model: 'omni-moderation-latest',
|
||||
policy_version: 4,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null
|
||||
};
|
||||
});
|
||||
beforeEach(() => {
|
||||
mocks.audits.length = 0;
|
||||
mocks.executed.length = 0;
|
||||
mocks.inserts.length = 0;
|
||||
mocks.invalidatePolicy.mockClear();
|
||||
mocks.invalidateSiteAccess.mockClear();
|
||||
mocks.siteSettings = {
|
||||
id: 'primary',
|
||||
coming_soon_enabled: 1,
|
||||
account_creation_enabled: 1,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null,
|
||||
};
|
||||
mocks.moderationSettings = {
|
||||
id: 'primary',
|
||||
automated_enabled: 1,
|
||||
provider_model: 'omni-moderation-latest',
|
||||
policy_version: 4,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null,
|
||||
};
|
||||
});
|
||||
|
||||
it('creates and audits each normalized rule in one transaction', async () => {
|
||||
const form = new FormData();
|
||||
form.set('listType', 'allow');
|
||||
form.set('enabled', '1');
|
||||
form.append('scopes', 'comment');
|
||||
form.append('scopes', 'guide');
|
||||
form.set('terms', 'Kill\nmachine gun\nkill');
|
||||
form.set('note', 'Common game vocabulary');
|
||||
const result = await actions.bulkAddModerationTerms?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/bulkAddModerationTerms', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
it('creates and audits each normalized rule in one transaction', async () => {
|
||||
const form = new FormData();
|
||||
form.set('listType', 'allow');
|
||||
form.set('enabled', '1');
|
||||
form.append('scopes', 'comment');
|
||||
form.append('scopes', 'guide');
|
||||
form.set('terms', 'Kill\nmachine gun\nkill');
|
||||
form.set('note', 'Common game vocabulary');
|
||||
const result = await actions.bulkAddModerationTerms?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/bulkAddModerationTerms',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
bulkModerationTermsSaved: true,
|
||||
bulkModerationTermsAdded: 2,
|
||||
bulkModerationTermsSkipped: 1
|
||||
});
|
||||
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
|
||||
expect(mocks.inserts).toHaveLength(2);
|
||||
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
|
||||
['allow', 'word', 'Kill', 'kill'],
|
||||
['allow', 'phrase', 'machine gun', 'machine gun']
|
||||
]);
|
||||
expect(mocks.audits).toHaveLength(2);
|
||||
expect(mocks.audits[0]).toMatchObject({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.term_created',
|
||||
entityId: expect.any(String),
|
||||
before: null,
|
||||
after: {
|
||||
listKind: 'allow',
|
||||
matchMode: 'word',
|
||||
value: 'Kill',
|
||||
normalizedValue: 'kill',
|
||||
scopes: ['comment', 'guide'],
|
||||
enabled: 1,
|
||||
note: 'Common game vocabulary'
|
||||
}
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
bulkModerationTermsSaved: true,
|
||||
bulkModerationTermsAdded: 2,
|
||||
bulkModerationTermsSkipped: 1,
|
||||
});
|
||||
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
|
||||
expect(mocks.inserts).toHaveLength(2);
|
||||
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
|
||||
['allow', 'word', 'Kill', 'kill'],
|
||||
['allow', 'phrase', 'machine gun', 'machine gun'],
|
||||
]);
|
||||
expect(mocks.audits).toHaveLength(2);
|
||||
expect(mocks.audits[0]).toMatchObject({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.term_created',
|
||||
entityId: expect.any(String),
|
||||
before: null,
|
||||
after: {
|
||||
listKind: 'allow',
|
||||
matchMode: 'word',
|
||||
value: 'Kill',
|
||||
normalizedValue: 'kill',
|
||||
scopes: ['comment', 'guide'],
|
||||
enabled: 1,
|
||||
note: 'Common game vocabulary',
|
||||
},
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('saves and audits the launch gate without changing unrelated site controls', async () => {
|
||||
const form = new FormData();
|
||||
form.set('comingSoonEnabled', '0');
|
||||
it('saves and audits the launch gate without changing unrelated site controls', async () => {
|
||||
const form = new FormData();
|
||||
form.set('comingSoonEnabled', '0');
|
||||
|
||||
const result = await actions.saveLaunchGate?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/saveLaunchGate', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
const result = await actions.saveLaunchGate?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/saveLaunchGate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ launchGateSaved: true });
|
||||
expect(mocks.siteSettings).toMatchObject({
|
||||
coming_soon_enabled: 0,
|
||||
account_creation_enabled: 1,
|
||||
updated_by: 'operator-1'
|
||||
});
|
||||
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.launch_gate_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ coming_soon_enabled: 1 }),
|
||||
after: expect.objectContaining({ coming_soon_enabled: 0 })
|
||||
})
|
||||
]);
|
||||
});
|
||||
expect(result).toMatchObject({ launchGateSaved: true });
|
||||
expect(mocks.siteSettings).toMatchObject({
|
||||
coming_soon_enabled: 0,
|
||||
account_creation_enabled: 1,
|
||||
updated_by: 'operator-1',
|
||||
});
|
||||
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.launch_gate_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ coming_soon_enabled: 1 }),
|
||||
after: expect.objectContaining({ coming_soon_enabled: 0 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', '0');
|
||||
form.set('providerModel', 'omni-moderation-2024-09-26');
|
||||
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', '0');
|
||||
form.set('providerModel', 'omni-moderation-2024-09-26');
|
||||
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/saveModerationScreening',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ moderationScreeningSaved: true });
|
||||
expect(mocks.moderationSettings).toMatchObject({
|
||||
automated_enabled: 0,
|
||||
provider_model: 'omni-moderation-2024-09-26',
|
||||
policy_version: 5,
|
||||
updated_by: 'operator-1'
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.screening_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ policy_version: 4 }),
|
||||
after: expect.objectContaining({ policy_version: 5 })
|
||||
})
|
||||
]);
|
||||
});
|
||||
expect(result).toMatchObject({ moderationScreeningSaved: true });
|
||||
expect(mocks.moderationSettings).toMatchObject({
|
||||
automated_enabled: 0,
|
||||
provider_model: 'omni-moderation-2024-09-26',
|
||||
policy_version: 5,
|
||||
updated_by: 'operator-1',
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.screening_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ policy_version: 4 }),
|
||||
after: expect.objectContaining({ policy_version: 5 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', 'sometimes');
|
||||
form.set('providerModel', 'omni-moderation-latest');
|
||||
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', 'sometimes');
|
||||
form.set('providerModel', 'omni-moderation-latest');
|
||||
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/saveModerationScreening',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
|
||||
expect(mocks.audits).toHaveLength(0);
|
||||
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
|
||||
expect(mocks.audits).toHaveLength(0);
|
||||
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
@@ -21,11 +22,13 @@
|
||||
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
||||
let editing = $state<any | null>(null);
|
||||
let highEndCount = $state('0');
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const qualitySteps = [
|
||||
{ value: '0', label: '0 HIGH-END' },
|
||||
{ value: '1', label: '1 HIGH-END' },
|
||||
@@ -69,9 +72,17 @@
|
||||
highEndCount = '0';
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.sets.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.skillChipMode === 'add') iconResetToken += 1;
|
||||
if (form.skillChipMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
editing = form.skillChipMode === 'add' ? blank() : null;
|
||||
highEndCount = '0';
|
||||
}
|
||||
@@ -96,7 +107,7 @@
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||||
<section class="skill-chip-ability-assignment">
|
||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
||||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
|
||||
@@ -14,7 +15,7 @@
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
|
||||
let { data, form } = $props();
|
||||
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string };
|
||||
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
|
||||
type FocusEditor = {
|
||||
id: string;
|
||||
specializationId: string;
|
||||
@@ -47,6 +48,7 @@
|
||||
effects: StructuredEffect[];
|
||||
active: number;
|
||||
iconUrl?: string;
|
||||
renderUrl?: string;
|
||||
};
|
||||
type CombatEffectEditor = {
|
||||
id: string;
|
||||
@@ -77,6 +79,7 @@
|
||||
ranks: RankEditor[];
|
||||
active: number;
|
||||
iconUrl?: string;
|
||||
renderUrl?: string;
|
||||
};
|
||||
|
||||
const primaryOptions = [
|
||||
@@ -89,7 +92,7 @@
|
||||
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
|
||||
const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }];
|
||||
const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }];
|
||||
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
|
||||
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '', renderUrl: '' });
|
||||
const blankFocus = (specializationId = ''): FocusEditor => ({
|
||||
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
||||
});
|
||||
@@ -100,7 +103,7 @@
|
||||
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
|
||||
castSeconds: 0, targetCap: 1, chargeCount: 1, chargePolicy: 'none'
|
||||
},
|
||||
effects: [], active: 1, iconUrl: ''
|
||||
effects: [], active: 1, iconUrl: '', renderUrl: ''
|
||||
});
|
||||
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
|
||||
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
|
||||
@@ -124,7 +127,7 @@
|
||||
});
|
||||
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
|
||||
id: '', focusId, name: '', descriptionTemplate: '', rowNumber,
|
||||
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: ''
|
||||
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: '', renderUrl: ''
|
||||
});
|
||||
const normalizedEffect = (effect: any): StructuredEffect => ({
|
||||
type: (effect.effect_type ?? (effect.stat_attribute_id ? 'Attribute' : 'Custom')) as StructuredEffect['type'],
|
||||
@@ -150,6 +153,8 @@
|
||||
let abilitySlots = $state<string[]>(['', '', '']);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state('');
|
||||
let renderResetToken = $state('');
|
||||
let openedRequestedItem = $state(false);
|
||||
|
||||
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
|
||||
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
|
||||
@@ -319,6 +324,14 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.specializations.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) {
|
||||
selectSpecialization(String(requested.id));
|
||||
editSpecialization(requested);
|
||||
}
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
const token = String(form?.resultToken ?? '');
|
||||
if (!token || token === handledResultToken) return;
|
||||
handledResultToken = token;
|
||||
@@ -327,6 +340,7 @@
|
||||
if (form.specializationMode === 'add') {
|
||||
specializationEditing = blankSpecialization();
|
||||
iconResetToken = token;
|
||||
renderResetToken = token;
|
||||
} else specializationEditing = null;
|
||||
}
|
||||
if (form?.focusSaved) focusEditing = form.focusMode === 'add' ? blankFocus(selectedSpecializationId) : null;
|
||||
@@ -334,6 +348,7 @@
|
||||
if (form.abilityMode === 'add') {
|
||||
abilityEditing = blankAbility(selectedSpecializationId);
|
||||
iconResetToken = token;
|
||||
renderResetToken = token;
|
||||
} else abilityEditing = null;
|
||||
}
|
||||
if (form?.combatEffectSaved) {
|
||||
@@ -344,6 +359,7 @@
|
||||
if (form.nodeMode === 'add') {
|
||||
nodeEditing = blankNode(selectedFocusId, Number(nodeEditing?.rowNumber ?? 1));
|
||||
iconResetToken = token;
|
||||
renderResetToken = token;
|
||||
} else nodeEditing = null;
|
||||
}
|
||||
if (form?.specializationArchived || form?.specializationUnarchived) specializationEditing = null;
|
||||
@@ -416,6 +432,7 @@
|
||||
<input type="hidden" name="id" value={specializationEditing.id}/>
|
||||
<div class="isac-record-identity specialization-record-identity">
|
||||
<IconUploadField currentUrl={specializationEditing.iconUrl} label="SPECIALIZATION ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={specializationEditing.renderUrl} label="SPECIALIZATION RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={specializationEditing.name} maxlength="80" required/></label>
|
||||
<label class="field">DESCRIPTION<textarea name="description" bind:value={specializationEditing.description} maxlength="1200" placeholder="Describe the specialization's class identity and purpose."></textarea></label>
|
||||
@@ -464,6 +481,7 @@
|
||||
<input type="hidden" name="attackJson" value={JSON.stringify(abilityEditing.attack)}/>
|
||||
<div class="isac-record-identity specialization-record-identity">
|
||||
<IconUploadField currentUrl={abilityEditing.iconUrl} label="ABILITY ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={abilityEditing.renderUrl} label="ABILITY RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={abilityEditing.name} maxlength="100" required/></label>
|
||||
{#if abilityEditing.id}
|
||||
@@ -540,6 +558,7 @@
|
||||
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
|
||||
<div class="isac-record-identity specialization-record-identity node-record-identity">
|
||||
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={nodeEditing.renderUrl} label="NODE RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={nodeEditing.name} maxlength="100" required/></label>
|
||||
{#if nodeEditing.id}
|
||||
|
||||
@@ -7,72 +7,150 @@ import { AUTOMATED_MESSAGE_EVENTS } from '$lib/server/automated-messages';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const parseActions = (value: unknown) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
|
||||
const database = await getDatabase();
|
||||
const messages = await (await database.prepare(`SELECT message.*,
|
||||
await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'system.notifications.manage',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const messages = await (
|
||||
await database.prepare(`SELECT message.*,
|
||||
(SELECT COUNT(*) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS delivery_count,
|
||||
(SELECT MAX(delivered_at) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS last_delivered_at
|
||||
FROM automated_system_messages message ORDER BY name`)).all();
|
||||
return {
|
||||
events: AUTOMATED_MESSAGE_EVENTS,
|
||||
messages: messages.map((message) => ({ ...message, actions: parseActions(message.actions_json) }))
|
||||
};
|
||||
FROM automated_system_messages message ORDER BY name`)
|
||||
).all();
|
||||
return {
|
||||
events: AUTOMATED_MESSAGE_EVENTS,
|
||||
messages: messages.map((message) => ({
|
||||
...message,
|
||||
actions: parseActions(message.actions_json),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '').normalize('NFKC').trim();
|
||||
const title = String(form.get('title') ?? '').normalize('NFKC').trim();
|
||||
const body = String(form.get('body') ?? '').normalize('NFKC').trim();
|
||||
const link = String(form.get('link') ?? '').trim();
|
||||
const delay = Number(form.get('sendDelayMinutes') ?? 0);
|
||||
const enabled = form.get('enabled') === 'on';
|
||||
if (!id) return fail(400, { messageError: 'Choose an automated message to edit.' });
|
||||
if (source.length < 2 || source.length > 80) return fail(400, { messageError: 'Source must be 2–80 characters.' });
|
||||
if (title.length < 2 || title.length > 140) return fail(400, { messageError: 'Title must be 2–140 characters.' });
|
||||
if (body.length < 20 || body.length > 2000) return fail(400, { messageError: 'Message content must be 20–2,000 characters.' });
|
||||
if (!Number.isInteger(delay) || delay < 0 || delay > 10080) return fail(400, { messageError: 'Delay must be between 0 and 10,080 minutes.' });
|
||||
const validHref = (href: string) => href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
|
||||
if (link && !validHref(link)) return fail(400, { messageError: 'Primary destinations must use an internal path or secure HTTPS URL.' });
|
||||
const actions = form.getAll('actionLabel').map((label, index) => ({
|
||||
label: String(label).normalize('NFKC').trim(),
|
||||
href: String(form.getAll('actionHref')[index] ?? '').trim()
|
||||
})).filter((action) => action.label || action.href);
|
||||
if (actions.length > 8) return fail(400, { messageError: 'Use no more than eight action callouts.' });
|
||||
if (actions.some((action) => action.label.length < 2 || action.label.length > 60 || !validHref(action.href))) {
|
||||
return fail(400, { messageError: 'Each action needs a 2–60 character label and a valid internal or HTTPS destination.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM automated_system_messages WHERE id=?')).get(id);
|
||||
if (!before) return fail(404, { messageError: 'Automated message not found.' });
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
...before, source, title, body, link: link || null, actions_json: JSON.stringify(actions),
|
||||
enabled: enabled ? 1 : 0, send_delay_minutes: delay, updated_at: now, updated_by: operator.id
|
||||
};
|
||||
await (await database.prepare(`UPDATE automated_system_messages SET
|
||||
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`))
|
||||
.run(enabled ? 1 : 0, delay, source, title, body, link || null, JSON.stringify(actions), now, operator.id, id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'automated_message.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { messageSaved: true, editorMode: 'edit', resultToken: crypto.randomUUID() };
|
||||
}
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'system.notifications.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const title = String(form.get('title') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const body = String(form.get('body') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const link = String(form.get('link') ?? '').trim();
|
||||
const delay = Number(form.get('sendDelayMinutes') ?? 0);
|
||||
const enabled = form.get('enabled') === 'on';
|
||||
if (!id)
|
||||
return fail(400, {
|
||||
messageError: 'Choose an automated message to edit.',
|
||||
});
|
||||
if (source.length < 2 || source.length > 80)
|
||||
return fail(400, { messageError: 'Source must be 2–80 characters.' });
|
||||
if (title.length < 2 || title.length > 140)
|
||||
return fail(400, { messageError: 'Title must be 2–140 characters.' });
|
||||
if (body.length < 20 || body.length > 2000)
|
||||
return fail(400, {
|
||||
messageError: 'Message content must be 20–2,000 characters.',
|
||||
});
|
||||
if (!Number.isInteger(delay) || delay < 0 || delay > 10080)
|
||||
return fail(400, {
|
||||
messageError: 'Delay must be between 0 and 10,080 minutes.',
|
||||
});
|
||||
const validHref = (href: string) =>
|
||||
href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
|
||||
if (link && !validHref(link))
|
||||
return fail(400, {
|
||||
messageError:
|
||||
'Primary destinations must use an internal path or secure HTTPS URL.',
|
||||
});
|
||||
const actions = form
|
||||
.getAll('actionLabel')
|
||||
.map((label, index) => ({
|
||||
label: String(label).normalize('NFKC').trim(),
|
||||
href: String(form.getAll('actionHref')[index] ?? '').trim(),
|
||||
}))
|
||||
.filter((action) => action.label || action.href);
|
||||
if (actions.length > 8)
|
||||
return fail(400, {
|
||||
messageError: 'Use no more than eight action callouts.',
|
||||
});
|
||||
if (
|
||||
actions.some(
|
||||
(action) =>
|
||||
action.label.length < 2 ||
|
||||
action.label.length > 60 ||
|
||||
!validHref(action.href),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
messageError:
|
||||
'Each action needs a 2–60 character label and a valid internal or HTTPS destination.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM automated_system_messages WHERE id=?',
|
||||
)
|
||||
).get(id);
|
||||
if (!before)
|
||||
return fail(404, { messageError: 'Automated message not found.' });
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
...before,
|
||||
source,
|
||||
title,
|
||||
body,
|
||||
link: link || null,
|
||||
actions_json: JSON.stringify(actions),
|
||||
enabled: enabled ? 1 : 0,
|
||||
send_delay_minutes: delay,
|
||||
updated_at: now,
|
||||
updated_by: operator.id,
|
||||
};
|
||||
await (
|
||||
await database.prepare(`UPDATE automated_system_messages SET
|
||||
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`)
|
||||
).run(
|
||||
enabled ? 1 : 0,
|
||||
delay,
|
||||
source,
|
||||
title,
|
||||
body,
|
||||
link || null,
|
||||
JSON.stringify(actions),
|
||||
now,
|
||||
operator.id,
|
||||
id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'automated_message.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
return {
|
||||
messageSaved: true,
|
||||
editorMode: 'edit',
|
||||
resultToken: crypto.randomUUID(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
@@ -19,6 +20,8 @@
|
||||
let talent = $state<any>(null);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
let validationSummary = $state<HTMLElement | null>(null);
|
||||
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
|
||||
type TalentValidationError = { area: TalentErrorArea; message: string };
|
||||
@@ -48,7 +51,7 @@
|
||||
pools: [],
|
||||
clauses: []
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
|
||||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
|
||||
const editTalent = (row: any) => {
|
||||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
||||
const one = tier('I'), two = tier('II');
|
||||
@@ -132,10 +135,18 @@
|
||||
});
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) editTalent(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (!form?.resultToken || form.resultToken === handledResultToken) return;
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.talentSaved) {
|
||||
if (form.talentMode === 'add') iconResetToken += 1;
|
||||
if (form.talentMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
talent = form.talentMode === 'add' ? blankTalent() : null;
|
||||
} else if (talentErrors.length) {
|
||||
if (hasErrors(['mechanic', 'outcomes'])) talent.modelingOpen = true;
|
||||
@@ -168,6 +179,7 @@
|
||||
<div class="talent-icon-identity">
|
||||
<div id="talent-icon" class:validation-area-error={hasErrors(['icon'])}>
|
||||
<IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={talent.renderUrl} label="TALENT RENDER" resetToken={renderResetToken}/>
|
||||
{#each errorsFor('icon') as error}<small class="field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
|
||||
|
||||
@@ -5,19 +5,37 @@ import type { PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const sort = getRegistrySort(url, {
|
||||
user: { asc: 'display_name ASC', desc: 'display_name DESC' },
|
||||
access: { asc: 'access_state ASC', desc: 'access_state DESC' },
|
||||
roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' },
|
||||
lastLogin: { asc: 'last_login_at ASC', desc: 'last_login_at DESC', defaultDirection: 'desc' },
|
||||
lastActive: { asc: 'last_active_at ASC', desc: 'last_active_at DESC', defaultDirection: 'desc' },
|
||||
content: { asc: 'content_count ASC', desc: 'content_count DESC', defaultDirection: 'desc' }
|
||||
}, 'lastActive', 'desc');
|
||||
const database = await getDatabase();
|
||||
const summaryRows = await (await database.prepare(`
|
||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
user: { asc: 'display_name ASC', desc: 'display_name DESC' },
|
||||
access: { asc: 'access_state ASC', desc: 'access_state DESC' },
|
||||
roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' },
|
||||
lastLogin: {
|
||||
asc: 'last_login_at ASC',
|
||||
desc: 'last_login_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
lastActive: {
|
||||
asc: 'last_active_at ASC',
|
||||
desc: 'last_active_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
content: {
|
||||
asc: 'content_count ASC',
|
||||
desc: 'content_count DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'lastActive',
|
||||
'desc',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const summaryRows = await (
|
||||
await database.prepare(`
|
||||
SELECT users.id,users.login_username,
|
||||
COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
||||
@@ -32,41 +50,88 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
`)).all(pattern, pattern);
|
||||
const now = Date.now();
|
||||
const state = (user: Record<string, unknown>) => {
|
||||
const activity = user.last_active_at ?? user.last_login_at;
|
||||
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
|
||||
return { inactive, accessState };
|
||||
};
|
||||
const summaryStates = summaryRows.map((user) => ({ ...user, ...state(user) }));
|
||||
const filter = ['active', 'inactive', 'restricted'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive
|
||||
: filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true);
|
||||
const comparisonValue = (user: Record<string, any>) => {
|
||||
if (sort.key === 'user') return String(user.display_name ?? user.login_username ?? '').toLocaleLowerCase();
|
||||
if (sort.key === 'access') return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`;
|
||||
if (sort.key === 'roles') return String(user.assigned_roles ?? user.role ?? 'User').toLocaleLowerCase();
|
||||
if (sort.key === 'lastLogin') return user.last_login_at ? new Date(String(user.last_login_at)).getTime() : -1;
|
||||
if (sort.key === 'lastActive') return user.last_active_at ? new Date(String(user.last_active_at)).getTime() : -1;
|
||||
return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0);
|
||||
};
|
||||
const direction = sort.direction === 'asc' ? 1 : -1;
|
||||
filteredStates.sort((left, right) => {
|
||||
const leftValue = comparisonValue(left);
|
||||
const rightValue = comparisonValue(right);
|
||||
const comparison = typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue));
|
||||
return comparison * direction
|
||||
|| String(left.display_name ?? left.login_username ?? '').localeCompare(String(right.display_name ?? right.login_username ?? ''))
|
||||
|| String(left.id).localeCompare(String(right.id));
|
||||
});
|
||||
const pagination = getPagination(url, filteredStates.length);
|
||||
const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id));
|
||||
const userRows = pageIds.length ? await (await database.prepare(`
|
||||
`)
|
||||
).all(pattern, pattern);
|
||||
const now = Date.now();
|
||||
const state = (user: Record<string, unknown>) => {
|
||||
const activity = user.last_active_at ?? user.last_login_at;
|
||||
const inactive =
|
||||
!activity ||
|
||||
now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||
const banned =
|
||||
typeof user.banned_until === 'string' &&
|
||||
new Date(user.banned_until) > new Date();
|
||||
const accessState = user.deleted_at
|
||||
? 'DELETED'
|
||||
: user.blocked_at
|
||||
? 'BLOCKED'
|
||||
: banned
|
||||
? 'BANNED'
|
||||
: 'ACTIVE';
|
||||
return { inactive, accessState };
|
||||
};
|
||||
const summaryStates = summaryRows.map((user) => ({
|
||||
...user,
|
||||
...state(user),
|
||||
}));
|
||||
const filter = ['active', 'inactive', 'restricted'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const filteredStates = summaryStates.filter((user) =>
|
||||
filter === 'active'
|
||||
? user.accessState === 'ACTIVE' && !user.inactive
|
||||
: filter === 'inactive'
|
||||
? user.inactive
|
||||
: filter === 'restricted'
|
||||
? user.accessState !== 'ACTIVE'
|
||||
: true,
|
||||
);
|
||||
const comparisonValue = (user: Record<string, any>) => {
|
||||
if (sort.key === 'user')
|
||||
return String(
|
||||
user.display_name ?? user.login_username ?? '',
|
||||
).toLocaleLowerCase();
|
||||
if (sort.key === 'access')
|
||||
return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`;
|
||||
if (sort.key === 'roles')
|
||||
return String(
|
||||
user.assigned_roles ?? user.role ?? 'User',
|
||||
).toLocaleLowerCase();
|
||||
if (sort.key === 'lastLogin')
|
||||
return user.last_login_at
|
||||
? new Date(String(user.last_login_at)).getTime()
|
||||
: -1;
|
||||
if (sort.key === 'lastActive')
|
||||
return user.last_active_at
|
||||
? new Date(String(user.last_active_at)).getTime()
|
||||
: -1;
|
||||
return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0);
|
||||
};
|
||||
const direction = sort.direction === 'asc' ? 1 : -1;
|
||||
filteredStates.sort((left, right) => {
|
||||
const leftValue = comparisonValue(left);
|
||||
const rightValue = comparisonValue(right);
|
||||
const comparison =
|
||||
typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue));
|
||||
return (
|
||||
comparison * direction ||
|
||||
String(left.display_name ?? left.login_username ?? '').localeCompare(
|
||||
String(right.display_name ?? right.login_username ?? ''),
|
||||
) ||
|
||||
String(left.id).localeCompare(String(right.id))
|
||||
);
|
||||
});
|
||||
const pagination = getPagination(url, filteredStates.length);
|
||||
const pageIds = filteredStates
|
||||
.slice(pagination.offset, pagination.offset + pagination.perPage)
|
||||
.map((user) => String(user.id));
|
||||
const userRows = pageIds.length
|
||||
? await (
|
||||
await database.prepare(`
|
||||
SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
||||
@@ -81,22 +146,27 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
|
||||
`)).all(...pageIds) : [];
|
||||
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
|
||||
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
|
||||
return {
|
||||
users: users.map((user) => {
|
||||
return { ...user, ...state(user) };
|
||||
}),
|
||||
query,
|
||||
filter,
|
||||
sort,
|
||||
summary: {
|
||||
results: summaryRows.length,
|
||||
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
|
||||
inactive: summaryStates.filter((user) => user.inactive).length,
|
||||
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE').length
|
||||
},
|
||||
pagination
|
||||
};
|
||||
`)
|
||||
).all(...pageIds)
|
||||
: [];
|
||||
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
|
||||
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
|
||||
return {
|
||||
users: users.map((user) => {
|
||||
return { ...user, ...state(user) };
|
||||
}),
|
||||
query,
|
||||
filter,
|
||||
sort,
|
||||
summary: {
|
||||
results: summaryRows.length,
|
||||
active: summaryStates.filter(
|
||||
(user) => user.accessState === 'ACTIVE' && !user.inactive,
|
||||
).length,
|
||||
inactive: summaryStates.filter((user) => user.inactive).length,
|
||||
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE')
|
||||
.length,
|
||||
},
|
||||
pagination,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
getPermissions,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||
@@ -7,46 +10,113 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||
import {
|
||||
canRemoveAuthenticationMethod,
|
||||
getAuthenticationMethods,
|
||||
} from '$lib/server/auth/methods';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const database = await getDatabase();
|
||||
await purgeExpiredLoginMetadata();
|
||||
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
|
||||
const loginStatus = ['success', 'failed'].includes(url.searchParams.get('loginStatus') ?? '') ? String(url.searchParams.get('loginStatus')) : '';
|
||||
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
|
||||
const buildVisibility = ['draft', 'private', 'public'].includes(url.searchParams.get('buildVisibility') ?? '') ? String(url.searchParams.get('buildVisibility')) : '';
|
||||
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
|
||||
const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.view',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
await purgeExpiredLoginMetadata();
|
||||
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
|
||||
const loginStatus = ['success', 'failed'].includes(
|
||||
url.searchParams.get('loginStatus') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('loginStatus'))
|
||||
: '';
|
||||
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
|
||||
const buildVisibility = ['draft', 'private', 'public'].includes(
|
||||
url.searchParams.get('buildVisibility') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('buildVisibility'))
|
||||
: '';
|
||||
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
|
||||
const pattern = (value: string) =>
|
||||
`%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`;
|
||||
const loginPattern = pattern(loginQuery);
|
||||
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
|
||||
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
|
||||
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
|
||||
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
const loginPattern = pattern(loginQuery);
|
||||
const loginArgs: unknown[] = [
|
||||
params.id,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : []),
|
||||
];
|
||||
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
|
||||
const buildArgs: unknown[] = [
|
||||
params.id,
|
||||
pattern(buildQuery),
|
||||
pattern(buildQuery),
|
||||
...(buildVisibility ? [buildVisibility] : []),
|
||||
];
|
||||
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
||||
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
|
||||
const [loginCount, buildCount, commentCount] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
const commentArgs: unknown[] = [
|
||||
params.id,
|
||||
pattern(commentQuery),
|
||||
pattern(commentQuery),
|
||||
];
|
||||
const [loginCount, buildCount, commentCount] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`,
|
||||
)
|
||||
).get(...loginArgs),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`,
|
||||
)
|
||||
).get(...buildArgs),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
|
||||
WHERE ${commentWhere}`)).get(...commentArgs)
|
||||
]);
|
||||
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
|
||||
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
|
||||
const commentPagination = getPagination(url, Number(commentCount?.count ?? 0), 'comment');
|
||||
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods, emails, passkeys] = await Promise.all([
|
||||
(await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
WHERE ${commentWhere}`)
|
||||
).get(...commentArgs),
|
||||
]);
|
||||
const loginPagination = getPagination(
|
||||
url,
|
||||
Number(loginCount?.count ?? 0),
|
||||
'login',
|
||||
);
|
||||
const buildPagination = getPagination(
|
||||
url,
|
||||
Number(buildCount?.count ?? 0),
|
||||
'build',
|
||||
);
|
||||
const commentPagination = getPagination(
|
||||
url,
|
||||
Number(commentCount?.count ?? 0),
|
||||
'comment',
|
||||
);
|
||||
const [
|
||||
profile,
|
||||
roles,
|
||||
permissions,
|
||||
assignedRoles,
|
||||
directPermissions,
|
||||
logins,
|
||||
builds,
|
||||
comments,
|
||||
authenticationMethods,
|
||||
emails,
|
||||
passkeys,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
|
||||
discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name,
|
||||
@@ -58,190 +128,521 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
|
||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(params.id),
|
||||
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
|
||||
(await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id),
|
||||
(await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)
|
||||
).get(params.id),
|
||||
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
(
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).all(params.id),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=?',
|
||||
)
|
||||
).all(params.id),
|
||||
(
|
||||
await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
|
||||
login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system,
|
||||
login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city,
|
||||
login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id,
|
||||
login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at,
|
||||
sessions.expires_at AS session_expires_at
|
||||
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
|
||||
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
|
||||
(await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
|
||||
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
|
||||
(
|
||||
await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
|
||||
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
|
||||
FROM comments
|
||||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
|
||||
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
|
||||
,getAuthenticationMethods(database, params.id)
|
||||
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.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(params.id)
|
||||
]);
|
||||
if (!profile) error(404, 'User not found');
|
||||
return {
|
||||
profile: {
|
||||
...profile,
|
||||
avatar_url: avatarUrl(profile.avatar_source, profile.avatar_value, String(profile.id), profile.avatar_updated_at)
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
|
||||
directPermissionIds: directPermissions.map((row) => String(row.permission_id)),
|
||||
logins, builds, comments, emails, passkeys,
|
||||
historyFilters: { loginQuery, loginStatus, buildQuery, buildVisibility, commentQuery },
|
||||
historyPagination: { login: loginPagination, build: buildPagination, comment: commentPagination },
|
||||
authenticationMethods,
|
||||
operatorPermissions: await getPermissions(operator),
|
||||
isSelf: operator.id === params.id
|
||||
};
|
||||
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...commentArgs, commentPagination.perPage, commentPagination.offset),
|
||||
getAuthenticationMethods(database, params.id),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?',
|
||||
)
|
||||
).all(params.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(params.id),
|
||||
]);
|
||||
if (!profile) error(404, 'User not found');
|
||||
return {
|
||||
profile: {
|
||||
...profile,
|
||||
avatar_url: avatarUrl(
|
||||
profile.avatar_source,
|
||||
profile.avatar_value,
|
||||
String(profile.id),
|
||||
profile.avatar_updated_at,
|
||||
),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
|
||||
directPermissionIds: directPermissions.map((row) =>
|
||||
String(row.permission_id),
|
||||
),
|
||||
logins,
|
||||
builds,
|
||||
comments,
|
||||
emails,
|
||||
passkeys,
|
||||
historyFilters: {
|
||||
loginQuery,
|
||||
loginStatus,
|
||||
buildQuery,
|
||||
buildVisibility,
|
||||
commentQuery,
|
||||
},
|
||||
historyPagination: {
|
||||
login: loginPagination,
|
||||
build: buildPagination,
|
||||
comment: commentPagination,
|
||||
},
|
||||
authenticationMethods,
|
||||
operatorPermissions: await getPermissions(operator),
|
||||
isSelf: operator.id === params.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
removeCustomAvatar: async ({ cookies, params, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const reason = String((await request.formData()).get('reason') ?? '').trim().slice(0, 500);
|
||||
if (reason.length < 3) return fail(400, { avatarModerationError: 'Provide a reason for removing this avatar.' });
|
||||
const database = await getDatabase();
|
||||
const avatar = await (await database.prepare(
|
||||
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?'
|
||||
)).get(params.id);
|
||||
if (avatar?.source !== 'upload') {
|
||||
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
|
||||
}
|
||||
await deleteUploadedAvatar(database, params.id);
|
||||
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
|
||||
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
|
||||
.run(new Date().toISOString(), operator.id, reason, params.id, avatar.updated_at);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: params.id,
|
||||
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
|
||||
after: { source: 'default', reviewSource: 'agent_operations', reason }
|
||||
});
|
||||
await createNotification({ userId: params.id, category: 'isac', source: 'ISAC USER OPERATIONS', title: 'Custom avatar removed', body: `Your custom avatar was removed by Oversight. Reason: ${reason}`, link: '/account#avatar', mandatory: true });
|
||||
return { avatarRemoved: true };
|
||||
},
|
||||
setAccess: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const form = await request.formData();
|
||||
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
|
||||
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { accessError: 'User not found.' });
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole);
|
||||
if ((target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*')) {
|
||||
return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.' });
|
||||
}
|
||||
const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
|
||||
const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
|
||||
if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' });
|
||||
const before = {
|
||||
roleIds: (await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(params.id)).map((row) => row.role_id),
|
||||
permissionIds: (await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(params.id)).map((row) => row.permission_id)
|
||||
};
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id);
|
||||
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId);
|
||||
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'user_access', params.id, 'access.updated', JSON.stringify(before), JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
|
||||
return { accessSaved: true };
|
||||
},
|
||||
moderate: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate');
|
||||
if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.' });
|
||||
const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim();
|
||||
if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' });
|
||||
if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' });
|
||||
const untilRaw = String(form.get('bannedUntil') ?? '');
|
||||
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
|
||||
if (state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date())) return fail(400, { moderationError: 'Choose a future ban expiration.' });
|
||||
const now = new Date().toISOString(), database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { moderationError: 'User not found.' });
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||
return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' });
|
||||
}
|
||||
const before = await (await database.prepare('SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?')).get(params.id);
|
||||
await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`))
|
||||
.run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now);
|
||||
if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'user_moderation', params.id, `moderation.${state}`, JSON.stringify(before ?? null), JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
||||
return { moderationSaved: true };
|
||||
},
|
||||
deleteAccount: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.delete');
|
||||
if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.' });
|
||||
const reason = String((await request.formData()).get('reason') ?? '').trim();
|
||||
if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||
return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.' });
|
||||
}
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||
return fail(409, { adminDeleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
},
|
||||
unlinkDiscord: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { identityError: 'User not found.' });
|
||||
if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.' });
|
||||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'discord')) {
|
||||
return fail(400, { identityError: 'Discord cannot be unlinked because it is this user’s only sign-in method.' });
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
|
||||
return { identityUnlinked: true };
|
||||
},
|
||||
removeEmail: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const database = await getDatabase();
|
||||
const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(params.id);
|
||||
if (!email) return fail(409, { identityError: 'This user does not have a verified email sign-in.' });
|
||||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'email')) {
|
||||
return fail(400, { identityError: 'Email cannot be removed because it is this user’s only sign-in method.' });
|
||||
}
|
||||
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString());
|
||||
return { emailRemoved: true };
|
||||
},
|
||||
removePasskey: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const passkey = await (await database.prepare('SELECT id,name FROM passkeys WHERE id=? AND user_id=?')).get(passkeyId, params.id);
|
||||
if (!passkey) return fail(404, { identityError: 'That passkey was not found for this user.' });
|
||||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'passkey')) {
|
||||
return fail(400, { identityError: 'This passkey cannot be removed because it is the user’s only sign-in method.' });
|
||||
}
|
||||
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(passkeyId, params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString());
|
||||
return { passkeyRemoved: true };
|
||||
}
|
||||
removeCustomAvatar: async ({ cookies, params, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'avatars.review',
|
||||
);
|
||||
const reason = String((await request.formData()).get('reason') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
if (reason.length < 3)
|
||||
return fail(400, {
|
||||
avatarModerationError: 'Provide a reason for removing this avatar.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const avatar = await (
|
||||
await database.prepare(
|
||||
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?',
|
||||
)
|
||||
).get(params.id);
|
||||
if (avatar?.source !== 'upload') {
|
||||
return fail(409, {
|
||||
avatarModerationError:
|
||||
'This agent does not currently use a custom avatar.',
|
||||
});
|
||||
}
|
||||
await deleteUploadedAvatar(database, params.id);
|
||||
await (
|
||||
await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
|
||||
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`)
|
||||
).run(
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
reason,
|
||||
params.id,
|
||||
avatar.updated_at,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: params.id,
|
||||
before: {
|
||||
source: avatar.source,
|
||||
storageKey: avatar.value,
|
||||
byteSize: avatar.byte_size,
|
||||
},
|
||||
after: { source: 'default', reviewSource: 'agent_operations', reason },
|
||||
});
|
||||
await createNotification({
|
||||
userId: params.id,
|
||||
category: 'isac',
|
||||
source: 'ISAC USER OPERATIONS',
|
||||
title: 'Custom avatar removed',
|
||||
body: `Your custom avatar was removed by Oversight. Reason: ${reason}`,
|
||||
link: '/account#avatar',
|
||||
mandatory: true,
|
||||
});
|
||||
return { avatarRemoved: true };
|
||||
},
|
||||
setAccess: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
|
||||
const permissionIds = [
|
||||
...new Set(form.getAll('permissionIds').map(String)),
|
||||
];
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { accessError: 'User not found.' });
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
const changesAdminRole =
|
||||
roleIds.includes('role-admin') !== Boolean(targetAdminRole);
|
||||
if (
|
||||
(target.role === 'admin' || changesAdminRole) &&
|
||||
!operatorPermissions.includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
accessError:
|
||||
'Only an administrator can grant, remove, or modify administrator access.',
|
||||
});
|
||||
}
|
||||
const validRoles = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const validPermissions = permissionIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...permissionIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
validRoles !== roleIds.length ||
|
||||
validPermissions !== permissionIds.length
|
||||
)
|
||||
return fail(400, {
|
||||
accessError: 'One or more access selections are invalid.',
|
||||
});
|
||||
const before = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(params.id)
|
||||
).map((row) => row.role_id),
|
||||
permissionIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(params.id)
|
||||
).map((row) => row.permission_id),
|
||||
};
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(params.id);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(params.id, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(params.id, permissionId);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'user_access',
|
||||
params.id,
|
||||
'access.updated',
|
||||
JSON.stringify(before),
|
||||
JSON.stringify({ roleIds, permissionIds }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { accessSaved: true };
|
||||
},
|
||||
moderate: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.moderate',
|
||||
);
|
||||
if (operator.id === params.id)
|
||||
return fail(400, {
|
||||
moderationError: 'You cannot restrict your own account.',
|
||||
});
|
||||
const form = await request.formData(),
|
||||
state = String(form.get('state') ?? ''),
|
||||
reason = String(form.get('reason') ?? '').trim();
|
||||
if (!['active', 'blocked', 'banned'].includes(state))
|
||||
return fail(400, { moderationError: 'Choose a valid access state.' });
|
||||
if (state !== 'active' && reason.length < 3)
|
||||
return fail(400, { moderationError: 'Provide a moderation reason.' });
|
||||
const untilRaw = String(form.get('bannedUntil') ?? '');
|
||||
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
|
||||
if (
|
||||
state === 'banned' &&
|
||||
(!untilRaw ||
|
||||
Number.isNaN(bannedUntil?.getTime()) ||
|
||||
bannedUntil! <= new Date())
|
||||
)
|
||||
return fail(400, { moderationError: 'Choose a future ban expiration.' });
|
||||
const now = new Date().toISOString(),
|
||||
database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { moderationError: 'User not found.' });
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
if (
|
||||
(target.role === 'admin' || targetAdminRole) &&
|
||||
!(await getPermissions(operator)).includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
moderationError:
|
||||
'Only an administrator can moderate another administrator.',
|
||||
});
|
||||
}
|
||||
const before = await (
|
||||
await database.prepare(
|
||||
'SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?',
|
||||
)
|
||||
).get(params.id);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`)
|
||||
).run(
|
||||
params.id,
|
||||
state === 'blocked' ? now : null,
|
||||
state === 'active' ? null : operator.id,
|
||||
state === 'banned' ? bannedUntil!.toISOString() : null,
|
||||
state === 'active' ? null : reason,
|
||||
now,
|
||||
);
|
||||
if (state !== 'active')
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'user_moderation',
|
||||
params.id,
|
||||
`moderation.${state}`,
|
||||
JSON.stringify(before ?? null),
|
||||
JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }),
|
||||
now,
|
||||
);
|
||||
return { moderationSaved: true };
|
||||
},
|
||||
deleteAccount: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.delete',
|
||||
);
|
||||
if (operator.id === params.id)
|
||||
return fail(400, {
|
||||
adminDeleteError:
|
||||
'Delete your own account from the agent profile page.',
|
||||
});
|
||||
const reason = String(
|
||||
(await request.formData()).get('reason') ?? '',
|
||||
).trim();
|
||||
if (reason.length < 3)
|
||||
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
if (
|
||||
(target.role === 'admin' || targetAdminRole) &&
|
||||
!(await getPermissions(operator)).includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
adminDeleteError:
|
||||
'Only an administrator can delete another administrator.',
|
||||
});
|
||||
}
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||
return fail(409, {
|
||||
adminDeleteError: 'This account has already been deleted.',
|
||||
});
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
},
|
||||
unlinkDiscord: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.identities',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT discord_id FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { identityError: 'User not found.' });
|
||||
if (!target.discord_id)
|
||||
return fail(409, {
|
||||
identityError: 'This user does not have a linked Discord account.',
|
||||
});
|
||||
if (
|
||||
!canRemoveAuthenticationMethod(
|
||||
await getAuthenticationMethods(database, params.id),
|
||||
'discord',
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
identityError:
|
||||
'Discord cannot be unlinked because it is this user’s only sign-in method.',
|
||||
});
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'identity',
|
||||
params.id,
|
||||
'identity.discord_unlinked',
|
||||
JSON.stringify({ discordId: target.discord_id }),
|
||||
now,
|
||||
);
|
||||
return { identityUnlinked: true };
|
||||
},
|
||||
removeEmail: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.identities',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const email = await (
|
||||
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||
).get(params.id);
|
||||
if (!email)
|
||||
return fail(409, {
|
||||
identityError: 'This user does not have a verified email sign-in.',
|
||||
});
|
||||
if (
|
||||
!canRemoveAuthenticationMethod(
|
||||
await getAuthenticationMethods(database, params.id),
|
||||
'email',
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
identityError:
|
||||
'Email cannot be removed because it is this user’s only sign-in method.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'identity',
|
||||
params.id,
|
||||
'identity.email_removed',
|
||||
JSON.stringify({ email: email.email }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { emailRemoved: true };
|
||||
},
|
||||
removePasskey: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.identities',
|
||||
);
|
||||
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const passkey = await (
|
||||
await database.prepare(
|
||||
'SELECT id,name FROM passkeys WHERE id=? AND user_id=?',
|
||||
)
|
||||
).get(passkeyId, params.id);
|
||||
if (!passkey)
|
||||
return fail(404, {
|
||||
identityError: 'That passkey was not found for this user.',
|
||||
});
|
||||
if (
|
||||
!canRemoveAuthenticationMethod(
|
||||
await getAuthenticationMethods(database, params.id),
|
||||
'passkey',
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
identityError:
|
||||
'This passkey cannot be removed because it is the user’s only sign-in method.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')
|
||||
).run(passkeyId, params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'identity',
|
||||
params.id,
|
||||
'identity.passkey_removed',
|
||||
JSON.stringify({ id: passkey.id, name: passkey.name }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { passkeyRemoved: true };
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -18,7 +19,7 @@
|
||||
const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
|
||||
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
|
||||
const blank = () => ({
|
||||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '',
|
||||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '',
|
||||
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||||
});
|
||||
@@ -27,6 +28,8 @@
|
||||
let qualityPreview = $state('High-end');
|
||||
let isExotic = $state(false);
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
|
||||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
|
||||
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
|
||||
@@ -60,8 +63,14 @@
|
||||
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
|
||||
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.weaponModSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.weaponModMode === 'add') renderResetToken += 1;
|
||||
editing = form.weaponModMode === 'add' ? blank() : null;
|
||||
levelPreview = 1;
|
||||
isExotic = false;
|
||||
@@ -83,7 +92,7 @@
|
||||
{#snippet weaponModEditor()}
|
||||
<div class="dashboard-editor weapon-mod-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}</p><h3>{editing.id ? editing.name : 'WEAPON MOD DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save">
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="isExotic" value={isExotic ? '1' : '0'}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
@@ -96,6 +105,7 @@
|
||||
{#if isExotic}<div class="weapon-mod-exotic-quality"><GameIcon name="exotic" decorative/><span><b>EXOTIC QUALITY</b><small>UNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS</small></span></div>{:else}<div class="weapon-mod-primary-types"><span>PRIMARY ATTRIBUTE OPTIONS</span><div>{#each ['firepower','toughness','engineering'] as primary}<b><GameIcon name={primary as GameIconName} decorative/>{primary.toUpperCase()}</b>{/each}</div><small>Selected by the agent while configuring a build.</small></div>{/if}
|
||||
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required/>
|
||||
</div>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="WEAPON MOD RENDER" resetToken={renderResetToken}/>
|
||||
|
||||
<div class="weapon-mod-compatibility">
|
||||
<div>
|
||||
@@ -158,7 +168,7 @@
|
||||
<div class="dashboard-table weapon-mod-dashboard-table">
|
||||
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}
|
||||
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
|
||||
{@render weaponModEditor()}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import { listPublishedArticles, NEWS_CHANNELS } from '$lib/server/articles';
|
||||
import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
return {
|
||||
...(await listPublishedArticles({
|
||||
kind: 'news',
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
channel: url.searchParams.get('channel') ?? '',
|
||||
page: Number(url.searchParams.get('page') ?? 1),
|
||||
})),
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
channel: url.searchParams.get('channel') ?? '',
|
||||
channels: NEWS_CHANNELS,
|
||||
canManage: await hasPermission(user, 'news.manage'),
|
||||
};
|
||||
};
|
||||
@@ -1,26 +1,4 @@
|
||||
<svelte:head>
|
||||
<title>News & updates — SHD.build</title>
|
||||
<meta name="description" content="The Division Resurgence game news, update coverage, community reports, and SHD.build development notices."/>
|
||||
</svelte:head>
|
||||
|
||||
<main class="page community-index-page news-page">
|
||||
<header class="catalogue-hero community-index-hero">
|
||||
<div><p class="eyebrow"><span></span> SHD FIELD REPORTS</p><h1>NEWS &<br/><em>UPDATES.</em></h1><p>Game announcements, update intelligence, community reporting, and SHD.build development transmissions.</p></div>
|
||||
<div class="catalogue-count"><strong>FEED</strong><span>EDITORIAL CHANNEL</span><a class="primary" href="/contact?team=admins">SUBMIT A LEAD →</a></div>
|
||||
</header>
|
||||
<section class="news-channel-grid">
|
||||
<article class="news-primary">
|
||||
<div><span>CHANNEL STATUS</span><b>EDITORIAL SYSTEM IN DEVELOPMENT</b></div>
|
||||
<p class="eyebrow"><span></span> TRANSMISSION 00</p>
|
||||
<h2>THE COMMUNITY NEWSROOM IS COMING ONLINE.</h2>
|
||||
<p>SHD.build is expanding beyond loadouts into a complete Resurgence community resource. This channel will cover official announcements, game updates, database changes, community projects, and field reports without presenting speculation as confirmed information.</p>
|
||||
<footer><span>SOURCE POLICY <b>VERIFIABLE</b></span><span>COVERAGE <b>RESURGENCE</b></span><span>STATUS <b>QUEUED</b></span></footer>
|
||||
</article>
|
||||
<aside>
|
||||
<p class="eyebrow">PLANNED FEEDS</p>
|
||||
{#each ['OFFICIAL GAME NEWS','PATCH & DATA UPDATES','COMMUNITY REPORTS','SHD.BUILD DEVELOPMENT'] as channel,index}
|
||||
<div><small>{String(index + 1).padStart(2, '0')}</small><span>{channel}</span><b>QUEUED</b></div>
|
||||
{/each}
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
<script lang="ts">import ArticleCard from '$lib/components/ArticleCard.svelte';let { data }=$props();const lead=$derived(data.items[0]);const rest=$derived(data.items.slice(1));</script>
|
||||
<svelte:head><title>News — SHD.build</title><meta name="description" content="The Division Resurgence news, update coverage, community reports, and SHD.build development transmissions."/></svelte:head>
|
||||
<main class="page news-index"><header><div><p class="eyebrow"><span></span> SHD FIELD REPORTS</p><h1>NEWS</h1><p>Verified announcements, update intelligence, community reporting, and SHD.build development notes.</p></div>{#if data.canManage}<a class="primary" href="/news/new">NEW ARTICLE +</a>{/if}</header><form method="GET"><label>SEARCH NEWS<input name="q" value={data.query}/></label><label>CHANNEL<select name="channel"><option value="">ALL CHANNELS</option>{#each data.channels as channel}<option value={channel} selected={data.channel===channel}>{channel}</option>{/each}</select></label><button class="primary">FILTER</button></form>{#if lead}<section class="news-lead"><ArticleCard article={lead} kind="news" prominent/></section>{/if}<section class="news-grid">{#each rest as article}<ArticleCard {article} kind="news"/>{:else}{#if !lead}<div class="catalogue-empty"><span>◇</span><strong>NO TRANSMISSIONS PUBLISHED</strong><p>The newsroom has no public articles in this channel.</p></div>{/if}{/each}</section></main>
|
||||
<style>.news-index{padding-top:28px}.news-index>header{position:static;z-index:auto;width:auto;height:auto;min-height:0;display:flex;align-items:end;justify-content:space-between;gap:24px;padding:24px 0 30px;border-bottom:1px solid var(--shd-border);background:transparent}.news-index>header h1,.news-index>header p{margin:0}.news-index>header h1{max-width:10ch;font-size:clamp(3.5rem,7vw,6rem);line-height:.84;letter-spacing:-.035em}.news-index>header>div>p:last-child{max-width:62ch;margin-top:18px;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.1rem}.news-index>form{display:grid;grid-template-columns:minmax(250px,1fr) 260px auto;align-items:end;gap:8px;padding:10px;border:1px solid var(--shd-border);border-top:0;background:var(--shd-surface)}.news-index>form label{display:grid;gap:5px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.news-index>form button{min-height:40px}.news-lead{margin-top:24px}.news-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:10px}@media(max-width:950px){.news-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:650px){.news-index{padding-top:12px}.news-index>header{align-items:start;flex-direction:column;padding:20px 0 24px}.news-index>header>a{width:100%;text-align:center}.news-index>form,.news-grid{grid-template-columns:1fr}.news-index>form{padding:8px}.news-index>form button{min-height:48px}.news-index>header h1{font-size:clamp(3.25rem,17vw,4.5rem)}}</style>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { articlePageActions, loadArticlePage } from '$lib/server/article-page';
|
||||
import type { PageServerLoad } from './$types';
|
||||
export const load: PageServerLoad = async ({ params, cookies }) =>
|
||||
loadArticlePage('news', params.slug, cookies);
|
||||
export const actions = articlePageActions('news');
|
||||
@@ -0,0 +1,2 @@
|
||||
<script lang="ts">import ArticlePage from '$lib/components/ArticlePage.svelte';let {data,form}=$props();</script>
|
||||
<ArticlePage {data} {form}/>
|
||||
@@ -0,0 +1,59 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import {
|
||||
deleteArticle,
|
||||
loadArticleEditor,
|
||||
NEWS_CHANNELS,
|
||||
} from '$lib/server/articles';
|
||||
import { saveAndSubmitArticleForm } from '$lib/server/article-form';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function newsId(slug: string) {
|
||||
const row = await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare("SELECT id FROM articles WHERE kind='news' AND slug=?")
|
||||
).get(slug);
|
||||
if (!row) error(404, 'News article not found');
|
||||
return String(row.id);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user || !(await hasPermission(user, 'news.manage')))
|
||||
redirect(303, '/news');
|
||||
return {
|
||||
...(await loadArticleEditor(await newsId(params.slug), user.id, true)),
|
||||
newsChannels: NEWS_CHANNELS,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
publish: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user || !(await hasPermission(user, 'news.manage')))
|
||||
return fail(403, { articleError: 'News editor permission required.' });
|
||||
const result = await saveAndSubmitArticleForm({
|
||||
form: await request.formData(),
|
||||
user,
|
||||
canManageNews: true,
|
||||
});
|
||||
if (!result.ok)
|
||||
return fail(result.status, { articleError: result.message });
|
||||
return {
|
||||
articleMessage:
|
||||
result.state === 'private'
|
||||
? 'News article saved as private.'
|
||||
: 'News article published.',
|
||||
};
|
||||
},
|
||||
delete: async ({ params, cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user || !(await hasPermission(user, 'news.manage')))
|
||||
return fail(403, { articleError: 'News editor permission required.' });
|
||||
await deleteArticle(await newsId(params.slug), user.id, true);
|
||||
redirect(303, '/news');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
<script lang="ts">import ArticleComposer from '$lib/components/ArticleComposer.svelte';let { data, form } = $props();</script>
|
||||
<svelte:head><title>Edit {data.draft.title} — SHD.build</title></svelte:head>
|
||||
<ArticleComposer {data} {form}/>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { hasPermission } from '$lib/server/auth/authorization';
|
||||
import { createArticle } from '$lib/server/articles';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in?returnTo=%2Fnews%2Fnew');
|
||||
if (!(await hasPermission(user, 'news.manage'))) redirect(303, '/news');
|
||||
return {};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user || !(await hasPermission(user, 'news.manage')))
|
||||
return fail(403, { articleError: 'News editor permission required.' });
|
||||
const title = String((await request.formData()).get('title') ?? '').trim();
|
||||
if (title.length < 2 || title.length > 140)
|
||||
return fail(400, {
|
||||
articleError: 'News titles must be between 2 and 140 characters.',
|
||||
});
|
||||
const article = await createArticle({
|
||||
kind: 'news',
|
||||
ownerId: user.id,
|
||||
title,
|
||||
});
|
||||
redirect(303, `/news/${encodeURIComponent(article.slug)}/edit`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let { form } = $props();</script>
|
||||
<svelte:head><title>Create News — SHD.build</title></svelte:head>
|
||||
<main class="page article-start-page"><section><p class="eyebrow"><span></span> SHD NEWSROOM</p><h1>NEW<br/><em>TRANSMISSION.</em></h1><p>Create a private editorial workspace. News editors publish directly without community-content moderation.</p><PendingForm action="?/create"><label>WORKING TITLE<input name="title" minlength="2" maxlength="140" required/></label>{#if form?.articleError}<p class="error">{form.articleError}</p>{/if}<button class="primary">OPEN NEWSROOM →</button></PendingForm></section></main>
|
||||
<style>.article-start-page{min-height:calc(100vh - 160px);display:grid;place-items:start center;padding-top:clamp(48px,8vh,110px)}.article-start-page>section{width:min(760px,100%);display:grid;gap:16px;padding:clamp(24px,5vw,60px);border:1px solid var(--shd-border-strong);background:var(--shd-surface)}.article-start-page h1,.article-start-page p{margin:0}.article-start-page h1{font-size:clamp(3rem,8vw,6rem);line-height:.85}.article-start-page>section>p:not(.eyebrow){max-width:62ch;color:var(--shd-text-muted);font-family:var(--shd-font-editorial);font-size:1.1rem}.article-start-page :global(form),.article-start-page label{display:grid;gap:10px}.article-start-page label{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.article-start-page input{min-height:52px;font-size:1.1rem}@media(max-width:650px){.article-start-page{padding-top:40px}.article-start-page>section{padding:24px}.article-start-page :global(form button){width:100%;min-height:48px}}</style>
|
||||
@@ -2,5 +2,5 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = ({ url }) => {
|
||||
redirect(308, `/account/notifications${url.search}`);
|
||||
redirect(308, `/account/notifications${url.search}`);
|
||||
};
|
||||
|
||||
+269
-128
@@ -1,148 +1,289 @@
|
||||
import { createSession, destroySession, recordLogin } from '$lib/server/auth/session';
|
||||
import {
|
||||
createSession,
|
||||
destroySession,
|
||||
recordLogin,
|
||||
} from '$lib/server/auth/session';
|
||||
import { hashPassword, verifyPassword } from '$lib/server/auth/password';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { ensureBootstrapAdmin } from '$lib/server/auth/bootstrap';
|
||||
import { createMagicLinkToken } from '$lib/server/auth/magic-link';
|
||||
import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/auth/mail';
|
||||
import {
|
||||
emailDeliveryConfigured,
|
||||
sendAuthenticationLink,
|
||||
} from '$lib/server/auth/mail';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
|
||||
import { getSiteAccessSettings } from '$lib/server/site-access';
|
||||
import {
|
||||
displayNameIsAvailable,
|
||||
insertUserProfile
|
||||
displayNameIsAvailable,
|
||||
insertUserProfile,
|
||||
} from '$lib/server/agent-profiles';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const { accountCreationEnabled } = await getSiteAccessSettings();
|
||||
const oauthError = url.searchParams.get('oauth_error');
|
||||
return {
|
||||
accountCreationEnabled,
|
||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||
magicLinkSent: url.searchParams.has('magic_sent'),
|
||||
magicError: url.searchParams.get('magic_error') === 'unavailable'
|
||||
? 'This account is not permitted to sign in.'
|
||||
: url.searchParams.has('magic_error') ? 'That sign-in link is invalid or expired.' : null,
|
||||
oauthError: oauthError === 'registration_closed'
|
||||
? 'New account registration is currently closed. Existing Discord-linked agents can still sign in.'
|
||||
: oauthError ? 'Discord sign-in could not be completed.' : null
|
||||
};
|
||||
const { accountCreationEnabled } = await getSiteAccessSettings();
|
||||
const oauthError = url.searchParams.get('oauth_error');
|
||||
return {
|
||||
accountCreationEnabled,
|
||||
emailDeliveryConfigured: emailDeliveryConfigured(),
|
||||
magicLinkSent: url.searchParams.has('magic_sent'),
|
||||
magicError:
|
||||
url.searchParams.get('magic_error') === 'unavailable'
|
||||
? 'This account is not permitted to sign in.'
|
||||
: url.searchParams.has('magic_error')
|
||||
? 'That sign-in link is invalid or expired.'
|
||||
: null,
|
||||
oauthError:
|
||||
oauthError === 'registration_closed'
|
||||
? 'New account registration is currently closed. Existing Discord-linked agents can still sign in.'
|
||||
: oauthError
|
||||
? 'Discord sign-in could not be completed.'
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
magicLink: async ({ request }) => {
|
||||
const email = String((await request.formData()).get('email') ?? '').normalize('NFKC').trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
||||
return fail(400, { magicLinkError: 'Enter a valid email address.', email });
|
||||
}
|
||||
if (!emailDeliveryConfigured()) return fail(503, { magicLinkError: 'Email sign-in is not configured yet.', email });
|
||||
const database = await getDatabase();
|
||||
let identity = await (await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
|
||||
magicLink: async ({ request }) => {
|
||||
const email = String((await request.formData()).get('email') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
||||
return fail(400, {
|
||||
magicLinkError: 'Enter a valid email address.',
|
||||
email,
|
||||
});
|
||||
}
|
||||
if (!emailDeliveryConfigured())
|
||||
return fail(503, {
|
||||
magicLinkError: 'Email sign-in is not configured yet.',
|
||||
email,
|
||||
});
|
||||
const database = await getDatabase();
|
||||
let identity = await (
|
||||
await database.prepare(`SELECT user_emails.user_id,magic_link_tokens.created_at
|
||||
FROM user_emails LEFT JOIN magic_link_tokens ON magic_link_tokens.user_id=user_emails.user_id
|
||||
AND magic_link_tokens.purpose='login' AND magic_link_tokens.consumed_at IS NULL
|
||||
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)).get(email);
|
||||
let purpose: 'login' | 'verify_email' = 'login';
|
||||
if (!identity?.user_id) {
|
||||
identity = await (await database.prepare(`SELECT user_id,created_at FROM magic_link_tokens
|
||||
WHERE user_emails.email=? COLLATE NOCASE ORDER BY magic_link_tokens.created_at DESC LIMIT 1`)
|
||||
).get(email);
|
||||
let purpose: 'login' | 'verify_email' = 'login';
|
||||
if (!identity?.user_id) {
|
||||
identity = await (
|
||||
await database.prepare(`SELECT user_id,created_at FROM magic_link_tokens
|
||||
WHERE email=? COLLATE NOCASE AND purpose='verify_email' AND consumed_at IS NULL AND expires_at>?
|
||||
ORDER BY created_at DESC LIMIT 1`)).get(email, new Date().toISOString());
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
if (!identity?.user_id) {
|
||||
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
|
||||
return fail(403, {
|
||||
magicLinkError: 'New account registration is currently closed. Enter an email already linked to an agent account.',
|
||||
email
|
||||
});
|
||||
}
|
||||
const id = randomUUID(), now = new Date().toISOString();
|
||||
await (await database.prepare('INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)'))
|
||||
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
||||
await insertUserProfile(database, id, 'Agent', now, { placeholder: true });
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
identity = { user_id: id, created_at: null };
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
const recent = identity?.created_at && Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
|
||||
if (identity?.user_id && !recent) {
|
||||
const token = await createMagicLinkToken(database, String(identity.user_id), email, purpose);
|
||||
await sendAuthenticationLink(email, token, purpose);
|
||||
}
|
||||
return { magicLinkSent: true };
|
||||
},
|
||||
register: async ({ request, cookies, getClientAddress }) => {
|
||||
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
|
||||
return fail(403, {
|
||||
registerError: 'New account registration is currently closed.',
|
||||
registerDisplayName: '',
|
||||
registerUsername: ''
|
||||
});
|
||||
}
|
||||
const form = await request.formData();
|
||||
const displayName = String(form.get('displayName') ?? '').normalize('NFKC').trim();
|
||||
const username = String(form.get('username') ?? '').normalize('NFKC').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
const confirm = String(form.get('confirmPassword') ?? '');
|
||||
const displayLength = Array.from(displayName).length;
|
||||
const values = { registerDisplayName: displayName, registerUsername: username };
|
||||
if (displayLength < 2 || displayLength > 40 || /[\p{C}\r\n]/u.test(displayName)) {
|
||||
return fail(400, { registerError: 'Use a display name with 2–40 visible characters.', ...values });
|
||||
}
|
||||
if (username.length < 3 || username.length > 32 || !/^[A-Za-z0-9_-]+$/.test(username)) {
|
||||
return fail(400, { registerError: 'Use a login username with 3–32 ASCII letters, numbers, underscores, or hyphens.', ...values });
|
||||
}
|
||||
if (['admin', 'administrator', 'moderator', 'shd', 'support'].includes(username.toLowerCase())) {
|
||||
return fail(400, { registerError: 'That login username is reserved.', ...values });
|
||||
}
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
return fail(400, { registerError: 'Use a password between 12 and 128 characters.', ...values });
|
||||
}
|
||||
if (password !== confirm) return fail(400, { registerError: 'The passwords do not match.', ...values });
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM users WHERE login_username=? COLLATE NOCASE')).get(username)) {
|
||||
return fail(409, { registerError: 'That login username is already in use.', ...values });
|
||||
}
|
||||
if (!(await displayNameIsAvailable(database, displayName))) {
|
||||
return fail(409, { registerError: 'That display name is already in use.', ...values });
|
||||
}
|
||||
const id = randomUUID(), now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
|
||||
VALUES(?,?,?,?,?,?)`)).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, username, await hashPassword(password), 'user', now);
|
||||
await insertUserProfile(database, id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
|
||||
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
|
||||
const sessionId = await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, '/account');
|
||||
},
|
||||
login: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
const username = String(form.get('username') ?? '').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (!username || !password) return fail(400, { loginError: 'Enter your username and password.', username });
|
||||
const database = await getDatabase();
|
||||
const user = await (await database.prepare(`SELECT users.id,users.password_hash,user_status.blocked_at,user_status.banned_until
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)).get(username);
|
||||
const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password);
|
||||
if (!valid) {
|
||||
await recordLogin(user?.id ? String(user.id) : null, 'password', false, { request, getClientAddress, failureReason: 'invalid_credentials' });
|
||||
return fail(400, { loginError: 'Invalid username or password.', username });
|
||||
}
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||
if (user.blocked_at || banned) {
|
||||
await recordLogin(String(user.id), 'password', false, { request, getClientAddress, failureReason: 'account_restricted' });
|
||||
return fail(403, { loginError: 'This account is not permitted to sign in.', username });
|
||||
}
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'password', true, { request, getClientAddress, sessionId });
|
||||
redirect(303, '/account');
|
||||
},
|
||||
logout: async ({ cookies }) => {
|
||||
await destroySession(cookies);
|
||||
redirect(303, '/');
|
||||
}
|
||||
ORDER BY created_at DESC LIMIT 1`)
|
||||
).get(email, new Date().toISOString());
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
if (!identity?.user_id) {
|
||||
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
|
||||
return fail(403, {
|
||||
magicLinkError:
|
||||
'New account registration is currently closed. Enter an email already linked to an agent account.',
|
||||
email,
|
||||
});
|
||||
}
|
||||
const id = randomUUID(),
|
||||
now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO users(id,username,role,created_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
|
||||
await insertUserProfile(database, id, 'Agent', now, {
|
||||
placeholder: true,
|
||||
});
|
||||
await triggerAutomatedSystemMessages({
|
||||
eventKey: 'account.created',
|
||||
userId: id,
|
||||
eventId: id,
|
||||
}).catch((error) =>
|
||||
console.error('Unable to deliver new-agent welcome message', error),
|
||||
);
|
||||
identity = { user_id: id, created_at: null };
|
||||
purpose = 'verify_email';
|
||||
}
|
||||
const recent =
|
||||
identity?.created_at &&
|
||||
Date.now() - new Date(String(identity.created_at)).getTime() < 60_000;
|
||||
if (identity?.user_id && !recent) {
|
||||
const token = await createMagicLinkToken(
|
||||
database,
|
||||
String(identity.user_id),
|
||||
email,
|
||||
purpose,
|
||||
);
|
||||
await sendAuthenticationLink(email, token, purpose);
|
||||
}
|
||||
return { magicLinkSent: true };
|
||||
},
|
||||
register: async ({ request, cookies, getClientAddress }) => {
|
||||
if (!(await getSiteAccessSettings()).accountCreationEnabled) {
|
||||
return fail(403, {
|
||||
registerError: 'New account registration is currently closed.',
|
||||
registerDisplayName: '',
|
||||
registerUsername: '',
|
||||
});
|
||||
}
|
||||
const form = await request.formData();
|
||||
const displayName = String(form.get('displayName') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const username = String(form.get('username') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
const confirm = String(form.get('confirmPassword') ?? '');
|
||||
const displayLength = Array.from(displayName).length;
|
||||
const values = {
|
||||
registerDisplayName: displayName,
|
||||
registerUsername: username,
|
||||
};
|
||||
if (
|
||||
displayLength < 2 ||
|
||||
displayLength > 40 ||
|
||||
/[\p{C}\r\n]/u.test(displayName)
|
||||
) {
|
||||
return fail(400, {
|
||||
registerError: 'Use a display name with 2–40 visible characters.',
|
||||
...values,
|
||||
});
|
||||
}
|
||||
if (
|
||||
username.length < 3 ||
|
||||
username.length > 32 ||
|
||||
!/^[A-Za-z0-9_-]+$/.test(username)
|
||||
) {
|
||||
return fail(400, {
|
||||
registerError:
|
||||
'Use a login username with 3–32 ASCII letters, numbers, underscores, or hyphens.',
|
||||
...values,
|
||||
});
|
||||
}
|
||||
if (
|
||||
['admin', 'administrator', 'moderator', 'shd', 'support'].includes(
|
||||
username.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
registerError: 'That login username is reserved.',
|
||||
...values,
|
||||
});
|
||||
}
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
return fail(400, {
|
||||
registerError: 'Use a password between 12 and 128 characters.',
|
||||
...values,
|
||||
});
|
||||
}
|
||||
if (password !== confirm)
|
||||
return fail(400, {
|
||||
registerError: 'The passwords do not match.',
|
||||
...values,
|
||||
});
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM users WHERE login_username=? COLLATE NOCASE',
|
||||
)
|
||||
).get(username)
|
||||
) {
|
||||
return fail(409, {
|
||||
registerError: 'That login username is already in use.',
|
||||
...values,
|
||||
});
|
||||
}
|
||||
if (!(await displayNameIsAvailable(database, displayName))) {
|
||||
return fail(409, {
|
||||
registerError: 'That display name is already in use.',
|
||||
...values,
|
||||
});
|
||||
}
|
||||
const id = randomUUID(),
|
||||
now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO users(id,username,login_username,password_hash,role,created_at)
|
||||
VALUES(?,?,?,?,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
`agent_${id.replaceAll('-', '').slice(0, 16)}`,
|
||||
username,
|
||||
await hashPassword(password),
|
||||
'user',
|
||||
now,
|
||||
);
|
||||
await insertUserProfile(database, id, displayName, now);
|
||||
await ensureBootstrapAdmin(database, id);
|
||||
await triggerAutomatedSystemMessages({
|
||||
eventKey: 'account.created',
|
||||
userId: id,
|
||||
eventId: id,
|
||||
}).catch((error) =>
|
||||
console.error('Unable to deliver new-agent welcome message', error),
|
||||
);
|
||||
const sessionId = await createSession(cookies, id);
|
||||
await recordLogin(id, 'password', true, {
|
||||
request,
|
||||
getClientAddress,
|
||||
sessionId,
|
||||
});
|
||||
redirect(303, '/account');
|
||||
},
|
||||
login: async ({ request, cookies, getClientAddress }) => {
|
||||
const form = await request.formData();
|
||||
const username = String(form.get('username') ?? '').trim();
|
||||
const password = String(form.get('password') ?? '');
|
||||
if (!username || !password)
|
||||
return fail(400, {
|
||||
loginError: 'Enter your username and password.',
|
||||
username,
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const user = await (
|
||||
await database.prepare(`SELECT users.id,users.password_hash,user_status.blocked_at,user_status.banned_until
|
||||
FROM users LEFT JOIN user_status ON user_status.user_id=users.id WHERE login_username = ? COLLATE NOCASE`)
|
||||
).get(username);
|
||||
const valid =
|
||||
typeof user?.password_hash === 'string' &&
|
||||
(await verifyPassword(user.password_hash, password));
|
||||
if (!valid) {
|
||||
await recordLogin(user?.id ? String(user.id) : null, 'password', false, {
|
||||
request,
|
||||
getClientAddress,
|
||||
failureReason: 'invalid_credentials',
|
||||
});
|
||||
return fail(400, {
|
||||
loginError: 'Invalid username or password.',
|
||||
username,
|
||||
});
|
||||
}
|
||||
const banned =
|
||||
typeof user.banned_until === 'string' &&
|
||||
new Date(user.banned_until) > new Date();
|
||||
if (user.blocked_at || banned) {
|
||||
await recordLogin(String(user.id), 'password', false, {
|
||||
request,
|
||||
getClientAddress,
|
||||
failureReason: 'account_restricted',
|
||||
});
|
||||
return fail(403, {
|
||||
loginError: 'This account is not permitted to sign in.',
|
||||
username,
|
||||
});
|
||||
}
|
||||
await ensureBootstrapAdmin(database, String(user.id));
|
||||
const sessionId = await createSession(cookies, String(user.id));
|
||||
await recordLogin(String(user.id), 'password', true, {
|
||||
request,
|
||||
getClientAddress,
|
||||
sessionId,
|
||||
});
|
||||
redirect(303, '/account');
|
||||
},
|
||||
logout: async ({ cookies }) => {
|
||||
await destroySession(cookies);
|
||||
redirect(303, '/');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,8 +149,8 @@
|
||||
providers process information under their own policies.
|
||||
</p>
|
||||
<p>
|
||||
When automated content screening is enabled, SHD.build sends the submitted text
|
||||
or custom avatar image to OpenAI’s API for safety classification. SHD.build does
|
||||
When automated content screening is enabled, SHD.build sends submitted text,
|
||||
Guide images, or custom avatar images to OpenAI’s API for safety classification. SHD.build does
|
||||
not include your password, authentication tokens, or private sign-in credentials
|
||||
in that moderation request. We retain the resulting category scores, flags,
|
||||
request reference, content hash, local-rule matches, and review outcome as needed
|
||||
@@ -180,8 +180,8 @@
|
||||
<div>
|
||||
<h2>ESSENTIAL COOKIES</h2>
|
||||
<p>
|
||||
SHD.build currently uses only first-party cookies that are strictly necessary for
|
||||
authentication and sign-in protection. We do not currently use analytics,
|
||||
SHD.build currently uses only first-party cookies needed for authentication,
|
||||
sign-in protection, and anonymous Guide ratings or reports. We do not use analytics,
|
||||
advertising, behavioral-tracking, or personalization cookies. Because these cookies
|
||||
are essential, SHD.build does not offer a preference control for disabling them.
|
||||
You may block cookies through your browser, but account sign-in will not function
|
||||
@@ -198,6 +198,16 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>shd_visitor</code></td>
|
||||
<td>
|
||||
Provides a random pseudonymous browser identity for Guide ratings, duplicate
|
||||
report suppression, and abuse limits. The database stores only a
|
||||
domain-separated SHA-256 hash of the cookie value.
|
||||
</td>
|
||||
<td>Up to one year.</td>
|
||||
<td>HTTP-only, SameSite=Lax, Secure in production.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>shd_session</code></td>
|
||||
<td>
|
||||
@@ -222,6 +232,13 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
After repeated anonymous rating or reporting activity, SHD.build may display a
|
||||
Cloudflare Turnstile challenge. The server sends the challenge token and request
|
||||
address to Cloudflare for verification. Cloudflare processes that information
|
||||
under its own terms and privacy policy. SHD.build does not store the raw address
|
||||
as the rating or report identity.
|
||||
</p>
|
||||
<p>
|
||||
Your browser stores dismissal of the informational notice in local storage under
|
||||
<code>shd-essential-cookie-notice</code>. Your browser does not send this value with
|
||||
|
||||
Reference in New Issue
Block a user