add system config

This commit is contained in:
sk1982 2024-04-01 06:36:49 -04:00
parent 038969f41d
commit 13d8eaa51a
10 changed files with 361 additions and 11 deletions

View File

@ -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
};

View File

@ -0,0 +1 @@
DROP TABLE actaeon_global_config;

View File

@ -0,0 +1,4 @@
CREATE TABLE actaeon_global_config (
`key` VARCHAR(255) NOT NULL PRIMARY KEY,
`value` MEDIUMTEXT NOT NULL
);

5
src/actions/config.ts Normal file
View File

@ -0,0 +1,5 @@
'use server';
import { setGlobalConfig as _setGlobalConfig } from '@/config';
export const setGlobalConfig: typeof _setGlobalConfig = async (config) => _setGlobalConfig(config);

View File

@ -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 (<SystemConfig config={config} />);
};

View File

@ -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 = <T extends keyof GlobalConfig>(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 (<main className="flex flex-col max-w-3xl w-full mx-auto">
<header className="px-4 font-semibold text-2xl flex items-center h-16">
System Config
{!saved && <Button className="ml-auto" color="primary" isDisabled={loading} onPress={save}>Save</Button>}
</header>
<Divider />
<label className="p-3 w-full flex flex-wrap items-center text-sm md:text-base">
Allow users to add cards
<Checkbox size="lg" className="ml-auto" isDisabled={loading}
checked={config.allow_user_add_card}
onValueChange={v => setConfigKey('allow_user_add_card', v)} />
<span className="w-full mt-2 text-xs sm:text-sm text-gray-500">
Normally, only user moderators can add cards to users. By enabling this, normal users can add cards.
</span>
</label>
<Divider className="bg-divider/5" />
<label className={`p-3 w-full flex flex-wrap items-center text-sm md:text-base ${config.allow_user_add_card ? '' : 'text-gray-500'}`}>
Max card count per user
<Input type="number" min={1} className="w-28 ml-auto" size="sm" placeholder="Unlimited"
isDisabled={loading || !config.allow_user_add_card}
value={config.user_max_card?.toString() ?? ''}
onValueChange={v => setConfigKey('user_max_card', (!v || +v < 1) ? null : +v)} />
<span className="w-full mt-2 text-xs sm:text-sm text-gray-500">
If "Allow users to add cards" is enabled, this controls the max card count per user. Note that user moderators can exceed this count.
</span>
</label>
<Divider className="bg-divider/5" />
<header className="p-4 font-semibold text-xl">Chunithm Config</header>
<Divider className="bg-divider/5" />
<label className="p-3 w-full flex flex-wrap items-center text-sm md:text-base">
Allow equip unearned
<Select selectionMode="multiple" className="w-48 ml-auto" size="sm" placeholder="None" isDisabled={loading}
selectedKeys={new Set([UserPermissions.USER, ...USER_PERMISSION_NAMES.keys()]
.filter(p => config.chuni_allow_equip_unearned & (1 << p))
.map(p => p.toString()))}
onSelectionChange={s => typeof s !== 'string' && setConfigKey('chuni_allow_equip_unearned',
([...s] as number[]).reduce((t, x) => +t | (1 << +x), 0))}>
{[[UserPermissions.USER, { title: 'User' }] as const, ...USER_PERMISSION_NAMES]
.map(([permission, { title }]) => (<SelectItem key={permission?.toString()}>
{title}
</SelectItem>)) }
</Select>
<span className="w-full mt-2 text-xs sm:text-sm text-gray-500">
Allow these user roles to equip userbox items that they have not earned.
</span>
</label>
</main>);
};

View File

@ -96,7 +96,6 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
{route.routes.filter(filter)
.map(route => (<DropdownItem key={route.url} className="[&:hover_*]:text-secondary p-0"
onPress={() => {
router.push(route.url);
cookies.set('actaeon-navigated-from', routeGroup.title);
}}>
<Link href={route.url}
@ -191,7 +190,7 @@ export const HeaderSidebar = ({ children }: HeaderSidebarProps) => {
className={`text-xl`}>
{subroute.name}
<div className="flex flex-col ml-1.5 pl-3 border-l border-gray-500/25 mt-0.5">
{subroute.routes.filter(filter).map(route => (<Link href={route.url} key={route.url}
{subroute.routes.filter(filter).map(route => (<Link href={route.url} key={route.url} onClick={() => setMenuOpen(false)}
className={`text-[1.075rem] transition ${path?.startsWith(route.url) ? 'font-semibold text-primary' : 'hover:text-secondary'}`}>
{route.name}
</Link>))}

140
src/config.ts Normal file
View File

@ -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<T extends keyof GlobalConfig> = {
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<K> } = {
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 = {
<T extends keyof GlobalConfig>(key: T): GlobalConfig[T],
(): GlobalConfig
};
export const getGlobalConfig: GetConfig = <T extends keyof GlobalConfig>(key?: T) => key ? CONFIG[key] : CONFIG;
export const setGlobalConfig = async (update: Partial<GlobalConfig>) => {
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;
};

View File

@ -1,14 +1,55 @@
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();
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');
@ -16,7 +57,6 @@ export async function register() {
createActaeonTeamsFromExistingTeams().catch(console.error),
createActaeonFriendsFromExistingFriends().catch(console.error)
]);
}
} else if (process.env.NEXT_RUNTIME === 'edge') {
(globalThis as any).bcrypt = {};
(globalThis as any).mysql2 = {};

6
src/types/db.d.ts vendored
View File

@ -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;