Template
feat: Overhauled base template
This commit is contained in:
1 parent
04b9739853
commit
dabfef1458
37 files changed
+2679
-144
No files matched your search
+108
-49
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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');
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface AppError {
|
||||
message: string;
|
||||
stack?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// prettier-ignore
|
||||
export type Result<T, E> =
|
||||
| { ok: true, value: T }
|
||||
| { ok: false, error: E };
|
||||
@@ -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.',
|
||||
};
|
||||
Reference in new issue
Block a user