Update fun module configuration and enhance CSS styles

- Added 'enabled' property to FunConfigSchema with a default value of true for better configuration management.
- Refactored getFunConfig to return a complete default configuration when no data is found.
- Improved globals.css by expanding CSS variables for light and dark themes, enhancing styling consistency across the application.
This commit is contained in:
smueller
2026-07-22 13:56:33 +02:00
parent 7bc1684566
commit 04e04eb11d
39 changed files with 2394 additions and 14 deletions

View File

@@ -0,0 +1,38 @@
import type { Prisma } from '@prisma/client';
import type { DashboardAccessRule } from '@nexumi/shared';
import { prisma } from './prisma';
export async function listAccessRules(guildId: string): Promise<DashboardAccessRule[]> {
const rules = await prisma.dashboardAccessRule.findMany({
where: { guildId },
orderBy: { createdAt: 'asc' }
});
return rules.map((rule) => ({
id: rule.id,
roleId: rule.roleId,
modules: rule.modules as DashboardAccessRule['modules']
}));
}
export async function replaceAccessRules(
guildId: string,
rules: DashboardAccessRule[]
): Promise<DashboardAccessRule[]> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
await prisma.$transaction([
prisma.dashboardAccessRule.deleteMany({ where: { guildId } }),
...rules.map((rule) =>
prisma.dashboardAccessRule.create({
data: {
guildId,
roleId: rule.roleId,
modules: rule.modules as Prisma.InputJsonValue
}
})
)
]);
return listAccessRules(guildId);
}

View File

@@ -0,0 +1,55 @@
import { Prisma, type PrismaClient } from '@prisma/client';
import type { DashboardAuditEntry } from '@nexumi/shared';
import { prisma } from './prisma';
export interface WriteDashboardAuditInput {
guildId?: string | null;
actorUserId: string;
action: string;
path?: string | null;
before?: unknown;
after?: unknown;
}
function toJsonInput(value: unknown): Prisma.InputJsonValue | undefined {
if (value === undefined) {
return undefined;
}
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
export async function writeDashboardAudit(
prisma: PrismaClient,
input: WriteDashboardAuditInput
): Promise<void> {
await prisma.dashboardAuditLog.create({
data: {
guildId: input.guildId ?? null,
actorUserId: input.actorUserId,
action: input.action,
path: input.path ?? null,
before: toJsonInput(input.before) ?? Prisma.JsonNull,
after: toJsonInput(input.after) ?? Prisma.JsonNull
}
});
}
export async function listRecentDashboardAudit(
guildId: string,
limit = 10
): Promise<DashboardAuditEntry[]> {
const entries = await prisma.dashboardAuditLog.findMany({
where: { guildId },
orderBy: { createdAt: 'desc' },
take: limit
});
return entries.map((entry) => ({
id: entry.id,
guildId: entry.guildId,
actorUserId: entry.actorUserId,
action: entry.action,
path: entry.path,
createdAt: entry.createdAt.toISOString()
}));
}

View File

@@ -0,0 +1,99 @@
import { redirect } from 'next/navigation';
import { NextResponse } from 'next/server';
import { ZodError } from 'zod';
import { hasManageGuildPermission } from '@nexumi/shared';
import { fetchDiscordUserGuildsCached } from './discord-oauth';
import { prisma } from './prisma';
import { getSession, type SessionPayload } from './session';
export class UnauthorizedError extends Error {
constructor(message = 'Authentication required') {
super(message);
this.name = 'UnauthorizedError';
}
}
export class ForbiddenError extends Error {
constructor(message = 'You do not have access to this resource') {
super(message);
this.name = 'ForbiddenError';
}
}
/**
* Use in API routes. Throws {@link UnauthorizedError} when no session exists.
*/
export async function requireAuth(): Promise<SessionPayload> {
const session = await getSession();
if (!session) {
throw new UnauthorizedError();
}
return session;
}
/**
* Use in Server Components / layouts. Redirects to /login instead of throwing,
* since there is no JSON response to return.
*/
export async function requireAuthOrRedirect(): Promise<SessionPayload> {
const session = await getSession();
if (!session) {
redirect('/login');
}
return session;
}
/**
* Ensures the current session user has Manage Guild permission on the given
* guild (via Discord OAuth guild list) and that the bot is actually installed
* there (tracked via the `Guild` table). Use in API routes.
*/
export async function requireGuildAccess(guildId: string): Promise<SessionPayload> {
const session = await requireAuth();
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) {
throw new ForbiddenError('Missing Manage Guild permission for this server');
}
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
throw new ForbiddenError('Nexumi is not installed on this server');
}
return session;
}
/**
* Same as {@link requireGuildAccess} but for Server Components: redirects
* instead of throwing.
*/
export async function requireGuildAccessOrRedirect(guildId: string): Promise<SessionPayload> {
const session = await requireAuthOrRedirect();
const guilds = await fetchDiscordUserGuildsCached(session.accessToken);
const guild = guilds.find((entry) => entry.id === guildId);
if (!guild || !hasManageGuildPermission(guild.permissions)) {
redirect('/dashboard');
}
const dbGuild = await prisma.guild.findUnique({ where: { id: guildId } });
if (!dbGuild) {
redirect('/dashboard');
}
return session;
}
export function toApiErrorResponse(error: unknown): NextResponse {
if (error instanceof UnauthorizedError) {
return NextResponse.json({ error: error.message }, { status: 401 });
}
if (error instanceof ForbiddenError) {
return NextResponse.json({ error: error.message }, { status: 403 });
}
if (error instanceof ZodError) {
return NextResponse.json(
{ error: 'Validation failed', issues: error.issues },
{ status: 400 }
);
}
// eslint-disable-next-line no-console
console.error('[api]', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}

View File

@@ -0,0 +1,115 @@
import { createHash } from 'crypto';
import { env } from './env';
import { redis } from './redis';
const DISCORD_API_BASE = 'https://discord.com/api/v10';
const OAUTH_SCOPES = ['identify', 'guilds'] as const;
const USER_GUILDS_CACHE_TTL_SECONDS = 30;
export function getRedirectUri(): string {
return `${env.WEBUI_URL}/api/auth/callback`;
}
export function buildAuthorizeUrl(state: string): string {
const url = new URL('https://discord.com/api/oauth2/authorize');
url.searchParams.set('client_id', env.BOT_CLIENT_ID);
url.searchParams.set('redirect_uri', getRedirectUri());
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', OAUTH_SCOPES.join(' '));
url.searchParams.set('state', state);
url.searchParams.set('prompt', 'consent');
return url.toString();
}
export interface DiscordTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
}
export async function exchangeCodeForToken(code: string): Promise<DiscordTokenResponse> {
const body = new URLSearchParams({
client_id: env.BOT_CLIENT_ID,
client_secret: env.BOT_CLIENT_SECRET,
grant_type: 'authorization_code',
code,
redirect_uri: getRedirectUri()
});
const response = await fetch(`${DISCORD_API_BASE}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`Discord token exchange failed with status ${response.status}`);
}
return (await response.json()) as DiscordTokenResponse;
}
export interface DiscordUser {
id: string;
username: string;
global_name: string | null;
avatar: string | null;
locale?: string;
}
export async function fetchDiscordUser(accessToken: string): Promise<DiscordUser> {
const response = await fetch(`${DISCORD_API_BASE}/users/@me`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`Failed to fetch Discord user with status ${response.status}`);
}
return (await response.json()) as DiscordUser;
}
export interface DiscordUserGuild {
id: string;
name: string;
icon: string | null;
owner: boolean;
permissions: string;
}
export async function fetchDiscordUserGuilds(accessToken: string): Promise<DiscordUserGuild[]> {
const response = await fetch(`${DISCORD_API_BASE}/users/@me/guilds`, {
headers: { Authorization: `Bearer ${accessToken}` },
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`Failed to fetch Discord guilds with status ${response.status}`);
}
return (await response.json()) as DiscordUserGuild[];
}
function guildsCacheKey(accessToken: string): string {
return `webui:discordguilds:${createHash('sha256').update(accessToken).digest('hex')}`;
}
/**
* Cached variant of {@link fetchDiscordUserGuilds} to avoid hitting Discord's
* rate limits when a single dashboard page load needs the guild list more
* than once (e.g. layout + page + server switcher).
*/
export async function fetchDiscordUserGuildsCached(accessToken: string): Promise<DiscordUserGuild[]> {
const cacheKey = guildsCacheKey(accessToken);
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached) as DiscordUserGuild[];
}
const guilds = await fetchDiscordUserGuilds(accessToken);
await redis.set(cacheKey, JSON.stringify(guilds), 'EX', USER_GUILDS_CACHE_TTL_SECONDS);
return guilds;
}

19
apps/webui/src/lib/env.ts Normal file
View File

@@ -0,0 +1,19 @@
import { config } from 'dotenv';
import { z } from 'zod';
config();
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url(),
BOT_CLIENT_ID: z.string().min(1),
BOT_CLIENT_SECRET: z.string().min(1),
WEBUI_URL: z.string().url().default('http://localhost:3000'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
SENTRY_DSN: z.string().optional(),
DEFAULT_LOCALE: z.enum(['de', 'en']).default('de')
});
export const env = EnvSchema.parse(process.env);
export type Env = typeof env;

View File

@@ -0,0 +1,41 @@
import type { GuildGeneralSettings, GuildGeneralSettingsPatch } from '@nexumi/shared';
import { prisma } from './prisma';
async function ensureGuildSettings(guildId: string) {
await prisma.guild.upsert({
where: { id: guildId },
update: {},
create: { id: guildId }
});
return prisma.guildSettings.upsert({
where: { guildId },
update: {},
create: { guildId }
});
}
function toGeneralSettings(settings: { locale: string; timezone: string; moderationEnabled: boolean }): GuildGeneralSettings {
return {
locale: settings.locale === 'en' ? 'en' : 'de',
timezone: settings.timezone,
moderationEnabled: settings.moderationEnabled
};
}
export async function getGuildGeneralSettings(guildId: string): Promise<GuildGeneralSettings> {
const settings = await ensureGuildSettings(guildId);
return toGeneralSettings(settings);
}
export async function updateGuildGeneralSettings(
guildId: string,
patch: GuildGeneralSettingsPatch
): Promise<GuildGeneralSettings> {
await ensureGuildSettings(guildId);
const updated = await prisma.guildSettings.update({
where: { guildId },
data: patch
});
return toGeneralSettings(updated);
}

View File

@@ -0,0 +1,51 @@
import { hasManageGuildPermission, type DashboardGuild } from '@nexumi/shared';
import { fetchDiscordUserGuildsCached } from './discord-oauth';
import { prisma } from './prisma';
import type { SessionPayload } from './session';
/**
* Returns the guilds the current session user can manage (Manage Guild
* permission), enriched with whether Nexumi is already installed there.
* Guilds where the bot is present are sorted first.
*/
export async function getManageableGuilds(session: SessionPayload): Promise<DashboardGuild[]> {
const discordGuilds = await fetchDiscordUserGuildsCached(session.accessToken);
const manageable = discordGuilds.filter((guild) => hasManageGuildPermission(guild.permissions));
if (manageable.length === 0) {
return [];
}
const presentGuilds = await prisma.guild.findMany({
where: { id: { in: manageable.map((guild) => guild.id) } },
select: { id: true }
});
const presentIds = new Set(presentGuilds.map((guild) => guild.id));
return manageable
.map<DashboardGuild>((guild) => ({
id: guild.id,
name: guild.name,
icon: guild.icon,
botPresent: presentIds.has(guild.id),
permissions: guild.permissions
}))
.sort((a, b) => {
if (a.botPresent !== b.botPresent) {
return a.botPresent ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
}
/**
* Looks up a single manageable guild by id, or null when the session user
* does not manage it.
*/
export async function getManageableGuild(
session: SessionPayload,
guildId: string
): Promise<DashboardGuild | null> {
const guilds = await getManageableGuilds(session);
return guilds.find((guild) => guild.id === guildId) ?? null;
}

View File

@@ -0,0 +1,54 @@
import { cookies } from 'next/headers';
import deMessages from '@/messages/de.json';
import enMessages from '@/messages/en.json';
import { env } from './env';
export type Locale = 'de' | 'en';
export const LOCALE_COOKIE_NAME = 'nexumi_locale';
export type Messages = typeof enMessages;
export const MESSAGES: Record<Locale, Messages> = {
de: deMessages,
en: enMessages
};
function resolve(messages: Messages, key: string): string | undefined {
const value = key.split('.').reduce<unknown>((acc, part) => {
if (acc && typeof acc === 'object' && part in (acc as Record<string, unknown>)) {
return (acc as Record<string, unknown>)[part];
}
return undefined;
}, messages);
return typeof value === 'string' ? value : undefined;
}
function interpolate(template: string, vars?: Record<string, string | number>): string {
if (!vars) {
return template;
}
return Object.entries(vars).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
template
);
}
export function t(locale: Locale, key: string, vars?: Record<string, string | number>): string {
const dict = MESSAGES[locale] ?? MESSAGES[env.DEFAULT_LOCALE];
return interpolate(resolve(dict, key) ?? key, vars);
}
function isLocale(value: string | undefined): value is Locale {
return value === 'de' || value === 'en';
}
/**
* Resolves the active locale for the current request from the
* `nexumi_locale` cookie, falling back to the server's configured default.
*/
export async function getLocale(): Promise<Locale> {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get(LOCALE_COOKIE_NAME)?.value;
return isLocale(cookieLocale) ? cookieLocale : env.DEFAULT_LOCALE;
}

View File

@@ -0,0 +1,253 @@
import { DASHBOARD_MODULES, type DashboardModuleId, type ModuleStatus } from '@nexumi/shared';
import { prisma } from './prisma';
import { redis } from './redis';
/**
* Modules that have no dedicated "enabled" column on any Prisma model.
* Their dashboard toggle state is tracked in a Redis hash instead.
*/
const REDIS_ONLY_MODULES = new Set<DashboardModuleId>([
'giveaways',
'tags',
'selfroles',
'feeds',
'scheduler',
'guildbackup'
]);
function redisModulesKey(guildId: string): string {
return `dashboard:modules:${guildId}`;
}
function funConfigKey(guildId: string): string {
return `fun:config:${guildId}`;
}
interface FunConfigLike {
enabled: boolean;
mediaEnabled: boolean;
}
async function getRedisModuleFlags(guildId: string): Promise<Partial<Record<DashboardModuleId, boolean>>> {
const raw = await redis.hgetall(redisModulesKey(guildId));
const result: Partial<Record<DashboardModuleId, boolean>> = {};
for (const [moduleId, value] of Object.entries(raw)) {
result[moduleId as DashboardModuleId] = value === 'true';
}
return result;
}
async function setRedisModuleFlag(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise<void> {
await redis.hset(redisModulesKey(guildId), moduleId, enabled ? 'true' : 'false');
}
async function getFunConfig(guildId: string): Promise<FunConfigLike> {
const raw = await redis.get(funConfigKey(guildId));
if (!raw) {
return { enabled: true, mediaEnabled: true };
}
try {
const parsed = JSON.parse(raw) as { enabled?: boolean; mediaEnabled?: boolean };
return { enabled: parsed.enabled ?? true, mediaEnabled: parsed.mediaEnabled ?? true };
} catch {
return { enabled: true, mediaEnabled: true };
}
}
async function setFunConfigEnabled(guildId: string, enabled: boolean): Promise<void> {
const current = await getFunConfig(guildId);
await redis.set(funConfigKey(guildId), JSON.stringify({ ...current, enabled }));
}
/**
* Aggregates the enabled/disabled status of every dashboard module for a
* guild, reading from each module's Prisma config table (when it has an
* `enabled` column), the Fun module's Redis config, or the generic
* `dashboard:modules:{guildId}` Redis hash for modules without any
* dedicated storage yet.
*/
export async function getModuleStatuses(guildId: string): Promise<ModuleStatus[]> {
const [
guildSettings,
autoModConfig,
loggingConfig,
welcomeConfig,
verificationConfig,
levelingConfig,
economyConfig,
funConfig,
ticketConfig,
starboardConfig,
suggestionConfig,
birthdayConfig,
tempVoiceConfig,
statsConfig,
redisFlags
] = await Promise.all([
prisma.guildSettings.findUnique({ where: { guildId } }),
prisma.autoModConfig.findUnique({ where: { guildId } }),
prisma.loggingConfig.findUnique({ where: { guildId } }),
prisma.welcomeConfig.findUnique({ where: { guildId } }),
prisma.verificationConfig.findUnique({ where: { guildId } }),
prisma.levelingConfig.findUnique({ where: { guildId } }),
prisma.economyConfig.findUnique({ where: { guildId } }),
getFunConfig(guildId),
prisma.ticketConfig.findUnique({ where: { guildId } }),
prisma.starboardConfig.findUnique({ where: { guildId } }),
prisma.suggestionConfig.findUnique({ where: { guildId } }),
prisma.birthdayConfig.findUnique({ where: { guildId } }),
prisma.tempVoiceConfig.findUnique({ where: { guildId } }),
prisma.statsConfig.findUnique({ where: { guildId } }),
getRedisModuleFlags(guildId)
]);
const enabledById: Record<DashboardModuleId, boolean> = {
moderation: guildSettings?.moderationEnabled ?? true,
automod: autoModConfig?.enabled ?? true,
logging: loggingConfig?.enabled ?? true,
welcome: Boolean(welcomeConfig?.welcomeEnabled || welcomeConfig?.leaveEnabled),
verification: verificationConfig?.enabled ?? false,
leveling: levelingConfig?.enabled ?? true,
economy: economyConfig?.enabled ?? true,
fun: funConfig.enabled,
giveaways: redisFlags.giveaways ?? true,
tickets: ticketConfig?.enabled ?? true,
selfroles: redisFlags.selfroles ?? true,
tags: redisFlags.tags ?? true,
starboard: starboardConfig?.enabled ?? false,
suggestions: suggestionConfig?.enabled ?? true,
birthdays: birthdayConfig?.enabled ?? true,
tempvoice: tempVoiceConfig?.enabled ?? false,
stats: statsConfig?.enabled ?? false,
feeds: redisFlags.feeds ?? true,
scheduler: redisFlags.scheduler ?? true,
guildbackup: redisFlags.guildbackup ?? true
};
return DASHBOARD_MODULES.map((module) => ({
id: module.id,
group: module.group,
href: module.href,
enabled: enabledById[module.id]
}));
}
async function setModuleEnabled(guildId: string, moduleId: DashboardModuleId, enabled: boolean): Promise<void> {
if (REDIS_ONLY_MODULES.has(moduleId)) {
await setRedisModuleFlag(guildId, moduleId, enabled);
return;
}
switch (moduleId) {
case 'moderation':
await prisma.guildSettings.upsert({
where: { guildId },
update: { moderationEnabled: enabled },
create: { guildId, moderationEnabled: enabled }
});
return;
case 'automod':
await prisma.autoModConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'logging':
await prisma.loggingConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'welcome':
await prisma.welcomeConfig.upsert({
where: { guildId },
update: { welcomeEnabled: enabled, leaveEnabled: enabled },
create: { guildId, welcomeEnabled: enabled, leaveEnabled: enabled }
});
return;
case 'verification':
await prisma.verificationConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'leveling':
await prisma.levelingConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'economy':
await prisma.economyConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'fun':
await setFunConfigEnabled(guildId, enabled);
return;
case 'tickets':
await prisma.ticketConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'starboard':
await prisma.starboardConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'suggestions':
await prisma.suggestionConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'birthdays':
await prisma.birthdayConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'tempvoice':
await prisma.tempVoiceConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
case 'stats':
await prisma.statsConfig.upsert({
where: { guildId },
update: { enabled },
create: { guildId, enabled }
});
return;
default:
throw new Error(`Unknown or unsupported module: ${String(moduleId)}`);
}
}
export async function updateModuleStatuses(
guildId: string,
updates: Partial<Record<DashboardModuleId, boolean>>
): Promise<ModuleStatus[]> {
await prisma.guild.upsert({ where: { id: guildId }, update: {}, create: { id: guildId } });
for (const [moduleId, enabled] of Object.entries(updates)) {
if (enabled === undefined) continue;
await setModuleEnabled(guildId, moduleId as DashboardModuleId, enabled);
}
return getModuleStatuses(guildId);
}

View File

@@ -0,0 +1,10 @@
import { PrismaClient } from '@prisma/client';
import { env } from './env';
const globalForPrisma = globalThis as unknown as { __nexumiPrisma?: PrismaClient };
export const prisma: PrismaClient = globalForPrisma.__nexumiPrisma ?? new PrismaClient();
if (env.NODE_ENV !== 'production') {
globalForPrisma.__nexumiPrisma = prisma;
}

View File

@@ -0,0 +1,12 @@
import Redis from 'ioredis';
import { env } from './env';
const globalForRedis = globalThis as unknown as { __nexumiRedis?: Redis };
export const redis: Redis = globalForRedis.__nexumiRedis ?? new Redis(env.REDIS_URL, {
maxRetriesPerRequest: 3
});
if (env.NODE_ENV !== 'production') {
globalForRedis.__nexumiRedis = redis;
}

View File

@@ -0,0 +1,105 @@
import { createHmac, randomBytes, timingSafeEqual } from 'crypto';
import { cookies } from 'next/headers';
import { z } from 'zod';
import { SessionUserSchema } from '@nexumi/shared';
import { env } from './env';
import { redis } from './redis';
export const SESSION_COOKIE_NAME = 'nexumi_session';
const SESSION_KEY_PREFIX = 'webui:session:';
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
const SessionPayloadSchema = z.object({
user: SessionUserSchema,
accessToken: z.string().min(1),
tokenExpiresAt: z.number().optional()
});
export type SessionPayload = z.infer<typeof SessionPayloadSchema>;
export class SessionRequiredError extends Error {
constructor() {
super('Session required');
this.name = 'SessionRequiredError';
}
}
function sign(value: string): string {
return createHmac('sha256', env.SESSION_SECRET).update(value).digest('hex');
}
function buildCookieValue(sessionId: string): string {
return `${sessionId}.${sign(sessionId)}`;
}
function parseCookieValue(cookieValue: string): string | null {
const separatorIndex = cookieValue.lastIndexOf('.');
if (separatorIndex <= 0) {
return null;
}
const sessionId = cookieValue.slice(0, separatorIndex);
const signature = cookieValue.slice(separatorIndex + 1);
const expected = sign(sessionId);
const provided = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (provided.length !== expectedBuffer.length || !timingSafeEqual(provided, expectedBuffer)) {
return null;
}
return sessionId;
}
function sessionRedisKey(sessionId: string): string {
return `${SESSION_KEY_PREFIX}${sessionId}`;
}
export async function createSession(payload: SessionPayload): Promise<void> {
const sessionId = randomBytes(32).toString('hex');
await redis.set(sessionRedisKey(sessionId), JSON.stringify(payload), 'EX', SESSION_TTL_SECONDS);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE_NAME, buildCookieValue(sessionId), {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/',
maxAge: SESSION_TTL_SECONDS
});
}
export async function getSession(): Promise<SessionPayload | null> {
const cookieStore = await cookies();
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (!raw) {
return null;
}
const sessionId = parseCookieValue(raw);
if (!sessionId) {
return null;
}
const stored = await redis.get(sessionRedisKey(sessionId));
if (!stored) {
return null;
}
const parsed = SessionPayloadSchema.safeParse(JSON.parse(stored));
return parsed.success ? parsed.data : null;
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const raw = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (raw) {
const sessionId = parseCookieValue(raw);
if (sessionId) {
await redis.del(sessionRedisKey(sessionId));
}
}
cookieStore.delete(SESSION_COOKIE_NAME);
}
export async function requireSession(): Promise<SessionPayload> {
const session = await getSession();
if (!session) {
throw new SessionRequiredError();
}
return session;
}

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { cn } from './utils';
describe('cn', () => {
it('merges class names', () => {
expect(cn('flex', 'items-center')).toBe('flex items-center');
});
it('resolves conflicting tailwind classes in favor of the last one', () => {
expect(cn('px-2', 'px-4')).toBe('px-4');
});
it('drops falsy values', () => {
expect(cn('base', false && 'hidden', undefined, null, 'visible')).toBe('base visible');
});
});

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}