diff --git a/migrations/20240401033004-create-global-config.js b/migrations/20240401033004-create-global-config.js new file mode 100644 index 0000000..b46a0fe --- /dev/null +++ b/migrations/20240401033004-create-global-config.js @@ -0,0 +1,53 @@ +'use strict'; + +var dbm; +var type; +var seed; +var fs = require('fs'); +var path = require('path'); +var Promise; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function(options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; + Promise = options.Promise; +}; + +exports.up = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240401033004-create-global-config-up.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports.down = function(db) { + var filePath = path.join(__dirname, 'sqls', '20240401033004-create-global-config-down.sql'); + return new Promise( function( resolve, reject ) { + fs.readFile(filePath, {encoding: 'utf-8'}, function(err,data){ + if (err) return reject(err); + console.log('received data: ' + data); + + resolve(data); + }); + }) + .then(function(data) { + return db.runSql(data); + }); +}; + +exports._meta = { + "version": 1 +}; diff --git a/migrations/sqls/20240401033004-create-global-config-down.sql b/migrations/sqls/20240401033004-create-global-config-down.sql new file mode 100644 index 0000000..afc9b24 --- /dev/null +++ b/migrations/sqls/20240401033004-create-global-config-down.sql @@ -0,0 +1 @@ +DROP TABLE actaeon_global_config; diff --git a/migrations/sqls/20240401033004-create-global-config-up.sql b/migrations/sqls/20240401033004-create-global-config-up.sql new file mode 100644 index 0000000..ca1b46e --- /dev/null +++ b/migrations/sqls/20240401033004-create-global-config-up.sql @@ -0,0 +1,4 @@ +CREATE TABLE actaeon_global_config ( + `key` VARCHAR(255) NOT NULL PRIMARY KEY, + `value` MEDIUMTEXT NOT NULL +); diff --git a/src/actions/config.ts b/src/actions/config.ts new file mode 100644 index 0000000..3babecf --- /dev/null +++ b/src/actions/config.ts @@ -0,0 +1,5 @@ +'use server'; + +import { setGlobalConfig as _setGlobalConfig } from '@/config'; + +export const setGlobalConfig: typeof _setGlobalConfig = async (config) => _setGlobalConfig(config); diff --git a/src/app/(with-header)/admin/system-config/page.tsx b/src/app/(with-header)/admin/system-config/page.tsx new file mode 100644 index 0000000..83af563 --- /dev/null +++ b/src/app/(with-header)/admin/system-config/page.tsx @@ -0,0 +1,12 @@ +import { requireUser } from '@/actions/auth'; +import { getGlobalConfig } from '@/config'; +import { UserPermissions } from '@/types/permissions'; +import { SystemConfig } from './system-config'; + +export default async function SystemConfigPage() { + await requireUser({ permission: UserPermissions.SYSADMIN }); + + const config = getGlobalConfig(); + + return (); +}; diff --git a/src/app/(with-header)/admin/system-config/system-config.tsx b/src/app/(with-header)/admin/system-config/system-config.tsx new file mode 100644 index 0000000..21c84bf --- /dev/null +++ b/src/app/(with-header)/admin/system-config/system-config.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { setGlobalConfig } from '@/actions/config'; +import { useErrorModal } from '@/components/error-modal'; +import { GlobalConfig } from '@/config'; +import { USER_PERMISSION_NAMES, UserPermissions } from '@/types/permissions'; +import { Button, Checkbox, Divider, Input, Select, SelectItem } from '@nextui-org/react'; +import { useState } from 'react'; + +type SystemConfigProps = { + config: GlobalConfig +}; + +export const SystemConfig = ({ config: initialConfig }: SystemConfigProps) => { + const [config, setConfig] = useState(initialConfig); + const [loading, setLoading] = useState(false); + const [saved, setSaved] = useState(true); + const setError = useErrorModal(); + + const setConfigKey = (key: T, val: GlobalConfig[T]) => { + setSaved(false); + setConfig(c => ({ ...c, [key]: val })); + }; + + const save = () => { + setLoading(true); + setGlobalConfig(config) + .then(res => { + if (res?.error) + return setError(res.message); + setSaved(true); + }) + .finally(() => setLoading(false)); + }; + + return (
+
+ System Config + + {!saved && } +
+ + + + + +
Chunithm Config
+ + +
); +}; \ No newline at end of file diff --git a/src/app/(with-header)/header-sidebar.tsx b/src/app/(with-header)/header-sidebar.tsx index 82a0cec..b5e0ef4 100644 --- a/src/app/(with-header)/header-sidebar.tsx +++ b/src/app/(with-header)/header-sidebar.tsx @@ -96,7 +96,6 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => { {route.routes.filter(filter) .map(route => ( { - 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;