feat: Overhauled base template

This commit is contained in:
Cyper committed 2026-04-17 00:18:05 -04:00
1 parent 04b9739853
commit dabfef1458
37 files changed
+2679 -144

No files matched your search

+108 -49
View File
@@ -1,7 +1,11 @@
import path from 'path';
import ejs from 'ejs';
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import Fastify, {
FastifyInstance,
FastifyReply,
FastifyRequest,
} from 'fastify';
import Autoload from '@fastify/autoload';
import Cookie from '@fastify/cookie';
import FormBody from '@fastify/formbody';
@@ -12,6 +16,7 @@ import Static from '@fastify/static';
import View from '@fastify/view';
import Environment from '#/util/Environment';
import { listUsers } from '#/services/users';
const fastify = Fastify({
logger:
@@ -31,6 +36,38 @@ const fastify = Fastify({
disableRequestLogging: true,
});
fastify.setNotFoundHandler((request, reply) => {
const accept = request.headers.accept || '';
if (accept.includes('application/json')) {
// Return JSON for APIs
return reply.status(404).send({
error: 'Not Found',
message: `Requested URL '${request.url}' does not exist`,
});
} else {
// Return HTML for browsers/general users
return reply.status(404).view('status/404', { resource: request.url });
}
});
fastify.setErrorHandler(function (error, request, reply) {
const accept = request.headers.accept || '';
const sanitizedError =
'There was an internal error in the application. Please report this issue and try again later.';
const isProd = Environment.APP_ENVIRONMENT === 'prod';
if (accept.includes('application/json')) {
return reply.status(500).send({
error: 'Internal Error',
message: isProd ? sanitizedError : error,
});
} else {
return reply.view('status/500', { error: isProd ? sanitizedError : error });
}
});
fastify.register(FormBody);
fastify.register(Multipart);
fastify.register(Cookie);
@@ -60,54 +97,31 @@ await fastify.register(RateLimit, {
timeWindow: '1 minute',
});
fastify.setNotFoundHandler((request, reply) => {
const accept = request.headers.accept || '';
if (accept.includes('application/json')) {
// Return JSON for APIs
return reply.status(404).send({
error: 'Not Found',
message: `Requested URL '${request.url}' does not exist`,
});
} else {
// Return HTML for browsers/general users
return reply.status(404).view('status/404', { resource: request.url });
}
});
fastify.setErrorHandler(function (error, request, reply) {
const accept = request.headers.accept || '';
const sanitizedError = 'There was an internal error in the application. Please report this issue and try again later.';
const isProd = Environment.APP_ENVIRONMENT === 'prod';
if (accept.includes('application/json')) {
return reply.status(500).send({
error: 'Internal Error',
message: isProd ? sanitizedError : error,
});
} else {
return reply.view('status/500', { error: isProd ? sanitizedError : error });
}
});
await fastify.register(Autoload, {
dir: 'src/routes/public',
forceESM: true,
});
// 401 based off if request wants web or API response
await fastify.register(async (instance: FastifyInstance) => {
instance.addHook('onRequest', (req: FastifyRequest, reply: FastifyReply, done: (error?: Error) => void) => {
if (!req.session.account?.authenticated) {
if (req.headers.accept === 'application/json') {
return reply.status(401).send({ error: 'Unauthorized' });
} else {
req.session.loginRedirectUri = encodeURI(req.url);
return reply.redirect('/login', 302);
instance.addHook(
'onRequest',
(
req: FastifyRequest,
reply: FastifyReply,
done: (error?: Error) => void,
) => {
if (!req.session.account) {
if (req.headers.accept === 'application/json') {
return reply.status(401).send({ error: 'Unauthorized' });
} else {
req.session.loginRedirectUri = encodeURI(req.url);
return reply.redirect('/login', 302);
}
}
}
done();
});
done();
},
);
await instance.register(Autoload, {
dir: 'src/routes/private',
@@ -115,13 +129,58 @@ await fastify.register(async (instance: FastifyInstance) => {
});
});
fastify.addHook('onRequest', (req: any, reply: any, done: (error?: Error) => void) => {
reply.locals = {
account: req.session.account,
environment: Environment.APP_ENVIRONMENT,
};
let setupComplete = false;
done();
// Register variables for every view to access
fastify.addHook(
'onRequest',
(req: FastifyRequest, reply: any, done: (error?: Error) => void) => {
// TODO: Figure out how to add `locals` to FastifyReply since it is a valid property
reply.locals = {
account: req.session.account,
environment: Environment.APP_ENVIRONMENT,
};
done();
},
);
const firstTimeSetup = async (req: FastifyRequest, reply: FastifyReply) => {
if (setupComplete) return;
const allowedUrls = ['/js', '/css', '/favicon.ico', '/setup'];
if (allowedUrls.some((prefix) => req.url.startsWith(prefix))) return;
const users = await listUsers('');
// database is seeded with system user
if (users.value.length <= 1) {
return reply.redirect('/setup', 302);
}
};
fastify.addHook('onRequest', firstTimeSetup);
await fastify.register(async (instance: FastifyInstance) => {
instance.addHook(
'onRequest',
async (req: FastifyRequest, reply: FastifyReply) => {
if (setupComplete)
return reply.status(404).view('status/404', { resource: req.url });
// TODO: System config table to check for setup
const users = await listUsers('');
// database is seeded with system user
if (users.value.length > 1) {
setupComplete = true;
return reply.status(404).view('status/404', { resource: req.url });
}
},
);
await instance.register(Autoload, {
dir: 'src/routes/setup',
forceESM: true,
});
});
fastify.listen(
@@ -135,6 +194,6 @@ fastify.listen(
process.exit(1);
}
fastify.log.warn(`[Web] Listening on port ${Environment.APP_PORT}`);
fastify.log.info(`[Web] Listening on port ${Environment.APP_PORT}`);
},
);
+62 -2
View File
@@ -1,4 +1,4 @@
@import "tailwindcss";
@import 'tailwindcss';
:root {
--bg: #1a1714;
@@ -14,13 +14,25 @@
--radius: 0.5rem;
}
[data-theme='light'] {
--bg: #d4c9b8;
--surface: #c4b08a;
--surface-2: #a08c5e;
--border: #7a6e62;
--text: #1a1714;
--muted: #3d3228;
--khaki: #2a2420;
--mint: #1f9278;
--mint-fg: #d4c9b8;
}
/* Base */
html,
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
font-family: ui-monospace, "Cascadia Code", "Fira Mono", monospace;
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
font-size: 0.9rem;
}
@@ -161,6 +173,54 @@ nav {
color: var(--muted);
}
/* Theme toggle */
.nav-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.nav-theme-toggle {
background: transparent;
border: 1px solid var(--border);
border-radius: 50%;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
cursor: pointer;
color: var(--muted);
transition:
border-color 0.15s,
color 0.15s;
padding: 0;
}
.nav-theme-toggle:hover {
border-color: var(--mint);
color: var(--mint);
}
.nav-theme-toggle svg {
width: 0.9rem;
height: 0.9rem;
}
/* show moon in dark mode, sun in light mode */
[data-theme='light'] .icon-moon {
display: none;
}
[data-theme='light'] .icon-sun {
display: block;
}
:not([data-theme='light']) .icon-sun {
display: none;
}
:not([data-theme='light']) .icon-moon {
display: block;
}
/* Error pages */
.error-page {
display: flex;
+18
View File
@@ -0,0 +1,18 @@
import Environment from '#/util/Environment';
import type Database from '#/db/types';
import { CamelCasePlugin, Kysely, MysqlDialect } from 'kysely';
import { createPool } from 'mysql2';
const dialect = new MysqlDialect({
pool: createPool({
database: Environment.DB_NAME,
host: Environment.DB_HOST,
user: Environment.DB_USER,
password: Environment.DB_PASS,
port: Environment.DB_PORT,
connectionLimit: 10,
}),
});
const db = new Kysely<Database>({ dialect, plugins: [new CamelCasePlugin()] });
export default db;
+82
View File
@@ -0,0 +1,82 @@
import db from '#/db/database';
import { NewAudit } from '#/db/types/audit';
export default class AuditRepository {
/**
* Return an optionally filtered list of audits
* @param filter Optional query to filter down text results from list
* @param auditTypes Optional audit types to filter by
* @param pageSize The amount of results to return
* @param page The page offset to return
* @returns List of optionally filtered audits
*/
static async listAudits(
filter: string | undefined,
auditTypes: number[] | undefined,
pageSize: number = 25,
page: number = 1,
) {
let audits = db
.selectFrom('audit')
.leftJoin('auditType', 'audit.typeId', 'auditType.id')
.leftJoin('user', 'audit.accountId', 'user.id');
if (filter) {
const filterPattern = `%${filter}%`;
audits = audits.where((eb) =>
eb.or([
eb('user.username', 'like', filterPattern),
eb('user.email', 'like', filterPattern),
eb('user.displayName', 'like', filterPattern),
]),
);
}
if (auditTypes) {
audits = audits.where('audit.typeId', 'in', auditTypes);
}
return await audits
.select([
'audit.id as auditId',
'audit.typeId',
'audit.accountId', // user.id
'audit.targetId',
'audit.data',
'audit.ip',
'audit.timestamp',
'auditType.id as auditTypeId',
'auditType.name as auditType',
'user.username',
'user.displayName',
'user.email',
])
.limit(pageSize)
.offset((page - 1) * pageSize)
.execute();
}
/**
* Return a list of audit event types
* @returns A list of all registered audit types
*/
static async listAuditTypes() {
return await db.selectFrom('auditType').execute();
}
/**
* Creates a new audit log in the database
* @param audit New audit information to be createAudit
* @returns ID of the newly created audit
*/
static async createAudit(audit: NewAudit) {
const result = await db
.insertInto('audit')
.values(audit)
.executeTakeFirst();
return Number(result!.insertId);
}
}
+100
View File
@@ -0,0 +1,100 @@
import db from '#/db/database';
import { NewUser, UpdateUser, User } from '#/db/types/users';
export default class UserRepository {
/**
* Gets a user's data by searching using their internal ID.
* @param id ID of user to search for.
* @returns Sanitized user with matching ID, otherwise undefined.
*/
static async getUserById(id: number): Promise<User | undefined> {
const user = await db
.selectFrom('user')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
if (!user) return undefined;
return user;
}
static async getUserByUsername(username: string): Promise<User | undefined> {
const user = await db
.selectFrom('user')
.selectAll()
.where('username', '=', username)
.executeTakeFirst();
if (!user) return undefined;
return user;
}
/**
* Return a paginated list of optionally filtered users from the database.
* @param filter The query to filter on. This will match any of the following: username, displayName, email.
* @param pageSize The amount of results to return.
* @param page The amount of pages to skip in the filtered dataset.
* @returns A paginateed list of optionally filtered users from the database.
*/
static async listUsers(
filter: string | undefined,
pageSize: number = 25,
page: number = 1,
): Promise<User[]> {
const criteria = `%${filter ?? ''}%`;
let query = db.selectFrom('user');
if (filter) {
query = query.where((eb) =>
eb.or([
eb('username', 'like', criteria),
eb('displayName', 'like', criteria),
eb('email', 'like', criteria),
]),
);
}
const resultset = await query
.selectAll()
.limit(pageSize)
.offset((page - 1) * pageSize)
.execute();
if (!resultset || resultset.length === 0) return [];
return resultset;
}
/**
* Inserts a user with the given information and returns the new user's ID
* @param user The new user's information to be added to the database
* @returns The ID of the newly created user
*/
static async createUser(user: NewUser): Promise<number> {
// prettier-ignore
const result = await db.insertInto("user")
.values(user)
.executeTakeFirst();
return Number(result!.insertId);
}
/**
* Updates a given user based off the supplied `id` value. If no `id` is supplied,
* the update statement will not execute. Only fields being updated are required
* to be passed in the model - any omitted fields will be ignored.
* @param user The user to update with only the information to be updated.
* @returns Truthiness of if the update occurred
*/
static async updateUser(user: Partial<UpdateUser>): Promise<boolean> {
const { id, ...updateUser } = user;
if (!id) return false;
await db
.updateTable('user')
.set(updateUser)
.where('id', '=', id)
.executeTakeFirst();
return true;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { ColumnType, Generated, Insertable, Selectable } from 'kysely';
export interface AuditTypesTable {
id: Generated<number>;
name: string;
}
export type AuditType = Selectable<AuditTypesTable>;
export enum AuditTypes {
USER_CREATED = 1,
USER_MODIFIED,
USER_DELETED,
USER_LOGIN = 100,
USER_LOGOUT,
}
export interface AuditsTable {
id: Generated<number>;
typeId: number;
accountId: number;
targetId?: number;
timestamp: ColumnType<Date, never, never>; // not acutally nullable, b
data?: string;
ip?: string;
}
export type Audit = Selectable<AuditsTable>;
export type NewAudit = Insertable<AuditsTable>;
+8
View File
@@ -0,0 +1,8 @@
import { UsersTable } from '#/db/types/users';
import { AuditsTable, AuditTypesTable } from '#/db/types/audit';
export default interface Database {
user: UsersTable;
audit: AuditsTable;
auditType: AuditTypesTable;
}
+34
View File
@@ -0,0 +1,34 @@
import {
ColumnType,
Generated,
Insertable,
Selectable,
Updateable,
} from 'kysely';
export interface UsersTable {
id: Generated<number>;
username: string;
password: string;
email?: string;
displayName?: string;
createdAt?: ColumnType<Date, never, never>;
updatedAt?: ColumnType<Date, string, never>;
active: boolean;
}
export type User = Selectable<UsersTable>;
export type NewUser = Insertable<UsersTable>;
export type UpdateUser = Updateable<UsersTable>;
export type SanitizedUser = Omit<User, 'password'>;
/**
* Sanitizes a given user object by omitting sensitive fields
* @param user User to be SanitizedUser
* @returns User without sensitive fields
*/
export function sanitizeUser(user: User): SanitizedUser {
const { password: _, ...sanitizedUser } = user;
return sanitizedUser;
}
-4
View File
@@ -4,8 +4,4 @@ export default function (app: FastifyInstance) {
app.get('/', async (req: FastifyRequest, reply: FastifyReply) => {
return reply.view('home');
});
app.get('/test/url', async (req: FastifyRequest, reply: FastifyReply) => {
return reply.view('test');
});
}
+32 -8
View File
@@ -1,20 +1,44 @@
import { validateLogin } from '#/services/users';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
interface LoginRequest {
username: string;
password: string;
}
export default function (app: FastifyInstance) {
app.get('/login', async (req: FastifyRequest, reply: FastifyReply) => {
if (req.session.account?.authenticated) {
if (!!req.session.account) {
return reply.redirect('/', 302);
}
return reply.view('login');
const { error } = req.session;
delete req.session.error;
return reply.view('login', { error });
});
app.post('/login', async (req: FastifyRequest, reply: FastifyReply) => {
req.session.account = {
authenticated: true,
userId: 1,
username: 'Cyper',
};
// prettier-ignore
app.post('/login', async (req: FastifyRequest<{ Body: LoginRequest }>, reply: FastifyReply) => {
const { username, password } = req.body;
const user = await validateLogin(username, password);
if (!username) {
req.session.error = 'Please enter a username';
return reply.redirect('/login', 302);
}
if (!password) {
req.session.error = 'Please enter a password';
return reply.redirect('/login', 302);
}
if (!user.ok) {
req.session.error = user.error;
return reply.redirect('/login', 302);
}
req.session.account = user.value;
const { loginRedirectUri } = req.session;
delete req.session.loginRedirectUri;
+46
View File
@@ -0,0 +1,46 @@
import { AuditTypes } from '#/db/types/audit';
import { createAudit } from '#/services/audits';
import { createUser } from '#/services/users';
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
interface SetupRequest {
username: string;
password: string;
displayName?: string;
}
export default function (app: FastifyInstance) {
app.get('/setup', async (req: FastifyRequest, reply: FastifyReply) => {
const { error } = req.session;
req.session.destroy();
return reply.view('setup/index', { error });
});
// prettier-ignore
app.post('/setup', async (req: FastifyRequest<{ Body: SetupRequest }>, reply: FastifyReply) => {
const { username, password, displayName } = req.body;
const userInfo = await createUser({
username,
password,
displayName,
});
if (!userInfo.ok) {
req.session.error = userInfo.error.message;
app.log.error(userInfo.error.message + '\n' + userInfo.error.stack);
return reply.redirect('/setup', 302);
}
app.log.info(`Successfully created admin user '${userInfo.value.username}'.`);
req.session.account = userInfo.value;
await createAudit({
typeId: AuditTypes.USER_CREATED,
accountId: userInfo.value.id,
})
return reply.redirect('/', 302);
});
}
+36
View File
@@ -0,0 +1,36 @@
import AuditRepository from '#/db/repositories/audits';
import { NewAudit } from '#/db/types/audit';
import { isIPv4, isIPv6 } from 'net';
export async function listAudits(
filter: string | undefined,
auditTypes: number[] | undefined,
pageSize: number = 25,
page: number = 1,
) {
return await AuditRepository.listAudits(filter, auditTypes, pageSize, page);
}
export async function listAuditTypes() {
return await AuditRepository.listAuditTypes();
}
export async function createAudit(audit: NewAudit) {
if (audit.ip) {
if (!isIPv4(audit.ip) && !isIPv6(audit.ip)) {
throw 'Invalid IP given';
}
}
if (audit.data) {
let isValidJson = false;
try {
JSON.parse(audit.data);
isValidJson = true;
} catch (ex) {
throw 'Invalid data given - must be JSON';
}
}
return await AuditRepository.createAudit(audit);
}
+109
View File
@@ -0,0 +1,109 @@
import UserRepository from '#/db/repositories/users';
import { SanitizedUser, sanitizeUser, NewUser } from '#/db/types/users';
import { AppError } from '#/util/Error';
import { Result } from '#/util/Helpers';
import { UserConstants, isPasswordValid, isUsernameValid } from '#/util/User';
import argon2 from 'argon2';
export async function getUserById(id: number) {
const user = await UserRepository.getUserById(id);
return {
ok: true,
value: user ? sanitizeUser(user) : undefined,
};
}
export async function getUserByUsername(username: string) {
const user = await UserRepository.getUserByUsername(username);
return {
ok: true,
value: user ? sanitizeUser(user) : undefined,
};
}
export async function validateLogin(
username: string,
password: string,
): Promise<Result<SanitizedUser, string>> {
const user = await UserRepository.getUserByUsername(username);
if (!user || user.username === 'admin') {
return {
ok: false,
error: UserConstants.INVALID_LOGIN,
};
}
if (!user.active) {
return {
ok: false,
error: UserConstants.DISABLED_LOGIN,
};
}
const isValidLogin = await argon2.verify(user.password, password);
if (isValidLogin) {
return {
ok: true,
value: sanitizeUser(user),
};
}
return {
ok: false,
error: UserConstants.INVALID_LOGIN,
};
}
export async function listUsers(
filter: string | undefined,
pageSize: number = 25,
page: number = 1,
) {
return {
ok: true,
value: await UserRepository.listUsers(filter, pageSize, page),
};
}
export async function createUser(
user: NewUser,
): Promise<Result<SanitizedUser, AppError>> {
const usernameValidation = isUsernameValid(user.username);
if (!usernameValidation.valid) {
return {
ok: false,
error: { message: usernameValidation.rule! } as AppError,
};
}
const passwordValidation = isPasswordValid(user.password);
if (!passwordValidation.valid) {
return {
ok: false,
error: { message: passwordValidation.rule! } as AppError,
};
}
const hashedPassword = await argon2.hash(user.password);
user.password = hashedPassword;
// TODO: Validate email if present?
try {
const userId = await UserRepository.createUser(user);
const newUser = await UserRepository.getUserById(userId);
return {
ok: true,
value: newUser!,
};
} catch (ex) {
return {
ok: false,
error: {
message: 'Unable to create new user',
stack: ex,
} as AppError,
};
}
}
+20 -5
View File
@@ -53,19 +53,34 @@ type Environment = {
DB_USER: string;
DB_PASS: string;
DB_NAME: string;
DB_URL: string;
SESSION_SECRET: string;
PASSWORD_PEPPER: string;
};
const DB_HOST = tryParseString(process.env.DB_HOST, '');
const DB_PORT = tryParseInt(process.env.DB_PORT, 3306);
const DB_USER = tryParseString(process.env.DB_USER, '');
const DB_PASS = tryParseString(process.env.DB_PASS, '');
const DB_NAME = tryParseString(process.env.DB_NAME, '');
const DB_URL = tryParseString(process.env.DB_URL, '');
export default {
APP_PORT: tryParseInt(process.env.APP_PORT, 80),
APP_ENVIRONMENT: tryParseString(process.env.APP_ENVIRONMENT, 'prod'),
DB_HOST: tryParseString(process.env.DB_HOST, ''),
DB_PORT: tryParseInt(process.env.DB_PORT, 3306),
DB_USER: tryParseString(process.env.DB_USER, ''),
DB_PASS: tryParseString(process.env.DB_PASS, ''),
DB_NAME: tryParseString(process.env.DB_NAME, ''),
DB_HOST,
DB_PORT,
DB_USER,
DB_PASS,
DB_NAME,
// Build DB URL string if none supplied, otherwise use config-specified value
DB_URL:
DB_URL === ''
? `mysql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}`
: DB_URL,
SESSION_SECRET: tryParseString(process.env.SESSION_SECRET, ''),
PASSWORD_PEPPER: tryParseString(process.env.PASSWORD_PEPPER, ''),
} as Environment;
+4
View File
@@ -0,0 +1,4 @@
export interface AppError {
message: string;
stack?: string;
}
+4
View File
@@ -0,0 +1,4 @@
// prettier-ignore
export type Result<T, E> =
| { ok: true, value: T }
| { ok: false, error: E };
+73
View File
@@ -0,0 +1,73 @@
interface ValidationResult {
valid: boolean;
rule?: string;
}
/**
* Validates if a given username is valid. To be considered valid, the following conditions need to be met:
*
* 1. Must be between 3-24 characters.
* 2. Must only contain alphanumeric characters or the symbol `_`.
*
* If these conditions are met, the username is valid.
* @param username The username to validate
* @returns Truthiness determining if application username requirements are met
*/
export function isUsernameValid(username: string): ValidationResult {
if (!username || username.length < 3 || username.length > 24) {
return {
valid: false,
rule: 'Username must be between 3 and 24 characters.',
};
}
const invalidUsernameRegex = /[^A-Za-z0-9_]/;
if (invalidUsernameRegex.exec(username)) {
return {
valid: false,
rule: 'Username must consist of only alphanumeric and underscore characters.',
};
}
return { valid: true };
}
/**
* Validates if a given password is valid. To be considered valid, a password must be:
*
* 1. Between 8-64 characters.
* 2. Must contain at least one letter, number, and symbol (`_` not considered a symbol)
*
* If these conditions are met, a password is considered valid.
* @param password The password to validate
* @returns Truthiness determining if application password requirements are met
*/
export function isPasswordValid(password: string): ValidationResult {
if (!password || password.length < 8 || password.length > 64) {
return {
valid: false,
rule: 'Password must be between 8 and 64 characters.',
};
}
const oneLetterCheck = /[A-Za-z]/;
const oneNumberCheck = /[0-9]/;
const oneSymbolCheck = /[^A-Za-z0-9]/;
if (
!oneLetterCheck.exec(password) ||
!oneNumberCheck.exec(password) ||
!oneSymbolCheck.exec(password)
) {
return {
valid: false,
rule: 'Password must contain at least one letter, number, and symbol.',
};
}
return { valid: true };
}
export const UserConstants = {
INVALID_LOGIN: 'The username or password you entered is incorrect.',
DISABLED_LOGIN: 'Your account has been disabled.',
};