{
- router.push(route.url);
cookies.set('actaeon-navigated-from', routeGroup.title);
}}>
{
className={`text-xl`}>
{subroute.name}
- {subroute.routes.filter(filter).map(route => ( ( setMenuOpen(false)}
className={`text-[1.075rem] transition ${path?.startsWith(route.url) ? 'font-semibold text-primary' : 'hover:text-secondary'}`}>
{route.name}
))}
diff --git a/src/config.ts b/src/config.ts
new file mode 100644
index 0000000..56ed7c4
--- /dev/null
+++ b/src/config.ts
@@ -0,0 +1,140 @@
+import { db } from './db';
+
+export type GlobalConfig = {
+ chuni_allow_equip_unearned: number,
+ allow_user_add_card: boolean,
+ user_max_card: number | null
+};
+
+type ConfigEntry = {
+ defaultValue: GlobalConfig[T],
+ validate: (val: any) => ({ error: true, message: string; } | { error?: false, value?: GlobalConfig[T] } | undefined | void)
+};
+
+const CONFIG_ENTRIES: { [K in keyof GlobalConfig]: ConfigEntry } = {
+ chuni_allow_equip_unearned: {
+ validate: val => {
+ if (!Number.isInteger(val))
+ return { error: true, message: 'Invalid permission mask' };
+ },
+ defaultValue: 0
+ },
+ allow_user_add_card: {
+ validate: val => {
+ if (![0, 1, true, false].includes(val))
+ return { error: true, message: 'Invalid boolean value' };
+ return { value: !!val };
+ },
+ defaultValue: false
+ },
+ user_max_card: {
+ validate: val => {
+ if (val === null)
+ return;
+
+ if (!Number.isInteger(val) || val < 1)
+ return { error: true, message: 'Invalid max card count' };
+ },
+ defaultValue: 4
+ }
+} as const;
+
+let CONFIG = {} as GlobalConfig;
+
+if ((globalThis as any).CONFIG) CONFIG = (globalThis as any).CONFIG;
+
+type GetConfig = {
+ (key: T): GlobalConfig[T],
+ (): GlobalConfig
+};
+
+export const getGlobalConfig: GetConfig = (key?: T) => key ? CONFIG[key] : CONFIG;
+
+export const setGlobalConfig = async (update: Partial) => {
+ for (const [key, value] of Object.entries(update)) {
+ if (!Object.hasOwn(CONFIG, key))
+ return { error: true, message: `Unknown key ${key}` };
+
+ const res = CONFIG_ENTRIES[key as keyof typeof CONFIG].validate(value);
+ if (res?.error)
+ return res;
+
+ const val = res?.value ?? value;
+ if (val === (CONFIG as any)[key])
+ delete update[key as keyof typeof update];
+ else
+ (CONFIG as any)[key] = res?.value ?? value;
+ }
+
+ await db.transaction().execute(async trx => {
+ for (const [key, value] of Object.entries(update)) {
+ await trx.updateTable('actaeon_global_config')
+ .where('key', '=', key)
+ .set({ value: JSON.stringify((CONFIG as any)[key]) })
+ .executeTakeFirst();
+ }
+ });
+};
+
+export const loadConfig = async () => {
+ const entries = await db.selectFrom('actaeon_global_config')
+ .selectAll()
+ .execute();
+
+ const updates: { key: string, value: string }[] = [];
+ const inserts: { key: string, value: string; }[] = [];
+
+ if (!entries.length) {
+ console.log('[INFO] first startup detected, loading global config default values');
+ CONFIG = Object.fromEntries(Object.entries(CONFIG_ENTRIES).map(([k, { defaultValue }]) => {
+ inserts.push({ key: k, value: JSON.stringify(defaultValue) });
+
+ return [k, defaultValue];
+ })) as GlobalConfig;
+ } else {
+ CONFIG = Object.fromEntries(Object.entries(CONFIG_ENTRIES).map(([k, { defaultValue, validate }]) => {
+ const index = entries.findIndex(({ key }) => key === k);
+ if (index === -1) {
+ console.log(`[INFO] config key ${k} not found, loading default`);
+ inserts.push({ key: k, value: JSON.stringify(defaultValue) });
+ return [k, defaultValue];
+ }
+
+ const { value } = entries.splice(index, 1)[0];
+ let parsed: any;
+
+ try {
+ parsed = JSON.parse(value);
+ } catch {
+ console.warn(`[WARN] failed to parse config value for ${k}, falling back to default`);
+ updates.push({ key: k, value: JSON.stringify(defaultValue) });
+ return [k, defaultValue];
+ }
+
+ const res = validate(parsed);
+ if (res?.error) {
+ console.warn(`[WARN] failed to parse config value for ${k}: ${res.message ?? 'unknown error'}; falling back to default`);
+ updates.push({ key: k, value: JSON.stringify(defaultValue) });
+ return [k, defaultValue];
+ }
+
+ return [k, res?.value ?? parsed];
+ })) as GlobalConfig;
+ }
+
+ await db.transaction().execute(async trx => {
+ if (inserts.length)
+ await trx.insertInto('actaeon_global_config')
+ .values(inserts)
+ .execute();
+
+ for (const update of updates) {
+ await trx.updateTable('actaeon_global_config')
+ .where('key', '=', update.key)
+ .set({ value: update.value })
+ .executeTakeFirst();
+ }
+ });
+
+ (globalThis as any).CONFIG = CONFIG;
+};
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
index 0298e02..559f046 100644
--- a/src/instrumentation.ts
+++ b/src/instrumentation.ts
@@ -1,22 +1,62 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
- if (['true', 'yes', '1'].includes(process.env.AUTOMIGRATE?.toLowerCase()!)) {
- const url = new URL(process.env.DATABASE_URL!);
+ if (process.env.NODE_ENV === 'production') {
+ const secret = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET;
+
+ if (!secret) {
+ console.error('[FATAL] secret is required, please specify it by setting the NEXTAUTH_SECRET variable to a random string');
+ process.exit(1);
+ }
+
+ if (/secret|password|random/i.test(secret)) {
+ console.error('[FATAL] insecure secret detected, please set NEXTAUTH_SECRET variable to a random string');
+ process.exit(1);
+ }
+ }
+
+ let url: URL;
+ try {
+ url = new URL(process.env.DATABASE_URL!);
url.searchParams.set('multipleStatements', 'true');
process.env.DATABASE_URL = url.toString();
+
+ const { db } = await import('@/db');
+ const { sql } = await import('kysely');
+
+ await sql`select 1`.execute(db);
+ } catch (e) {
+ console.error('[FATAL] database connection failed! Please check that the DATABASE_URL variable is correct');
+ console.error(e);
+ process.exit(1);
+ }
+
+ if (['true', 'yes', '1'].includes(process.env.AUTOMIGRATE?.toLowerCase()!)) {
+ process.env.DATABASE_URL = url.toString();
// using require here increases build times to like 10 minutes for some reason
const DBMigrate = await eval('imp' + 'ort("db-migrate")');
const dbmigrate = DBMigrate.getInstance(true);
await dbmigrate.up();
- const { createActaeonTeamsFromExistingTeams } = await import('./data/team');
- const { createActaeonFriendsFromExistingFriends } = await import('./data/friend');
-
- await Promise.all([
- createActaeonTeamsFromExistingTeams().catch(console.error),
- createActaeonFriendsFromExistingFriends().catch(console.error)
- ]);
+ if (process.env.NODE_ENV === 'production')
+ delete process.env.DATABASE_URL;
}
+
+ const { loadConfig } = await import('./config');
+ try {
+ await loadConfig();
+ } catch (e) {
+ console.error('[FATAL] failed to load config');
+ console.error(e);
+ process.exit(1);
+ }
+
+ const { createActaeonTeamsFromExistingTeams } = await import('./data/team');
+ const { createActaeonFriendsFromExistingFriends } = await import('./data/friend');
+
+ await Promise.all([
+ createActaeonTeamsFromExistingTeams().catch(console.error),
+ createActaeonFriendsFromExistingFriends().catch(console.error)
+ ]);
} else if (process.env.NEXT_RUNTIME === 'edge') {
(globalThis as any).bcrypt = {};
(globalThis as any).mysql2 = {};
diff --git a/src/types/db.d.ts b/src/types/db.d.ts
index 0822bdf..1a96fb1 100644
--- a/src/types/db.d.ts
+++ b/src/types/db.d.ts
@@ -50,6 +50,11 @@ export interface ActaeonFriendRequests {
uuid: string;
}
+export interface ActaeonGlobalConfig {
+ key: string;
+ value: string;
+}
+
export interface ActaeonTeamJoinKeys {
id: string;
remainingUses: number | null;
@@ -3341,6 +3346,7 @@ export interface DB {
actaeon_chuni_static_system_voice: ActaeonChuniStaticSystemVoice;
actaeon_chuni_static_trophies: ActaeonChuniStaticTrophies;
actaeon_friend_requests: ActaeonFriendRequests;
+ actaeon_global_config: ActaeonGlobalConfig;
actaeon_team_join_keys: ActaeonTeamJoinKeys;
actaeon_teams: ActaeonTeams;
actaeon_user_ext: ActaeonUserExt;