forked from sk1982/actaeon
add system config
This commit is contained in:
parent
038969f41d
commit
13d8eaa51a
53
migrations/20240401033004-create-global-config.js
Normal file
53
migrations/20240401033004-create-global-config.js
Normal 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
|
||||
};
|
@ -0,0 +1 @@
|
||||
DROP TABLE actaeon_global_config;
|
@ -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
5
src/actions/config.ts
Normal file
@ -0,0 +1,5 @@
|
||||
'use server';
|
||||
|
||||
import { setGlobalConfig as _setGlobalConfig } from '@/config';
|
||||
|
||||
export const setGlobalConfig: typeof _setGlobalConfig = async (config) => _setGlobalConfig(config);
|
12
src/app/(with-header)/admin/system-config/page.tsx
Normal file
12
src/app/(with-header)/admin/system-config/page.tsx
Normal 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} />);
|
||||
};
|
90
src/app/(with-header)/admin/system-config/system-config.tsx
Normal file
90
src/app/(with-header)/admin/system-config/system-config.tsx
Normal 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>);
|
||||
};
|
@ -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
140
src/config.ts
Normal 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;
|
||||
};
|
@ -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 = {};
|
||||
|
6
src/types/db.d.ts
vendored
6
src/types/db.d.ts
vendored
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user