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

+7 -3
View File
@@ -1,13 +1,17 @@
# Application settings
APP_PORT=
APP_ENVIRONMENT=dev
APP_PORT=8080
# Environments - dev, prod
APP_ENVIRONMENT=prod
# Database settings
DB_HOST=
DB_PORT=3306
DB_NAME=
DB_USER=
DB_PASS=
DB_NAME=
# This field is optional - if not supplied, it will be built from the above fields.
# If you need the string to be a specific format, you may override it with this field.
DB_URL=
# Security settings
SESSION_SECRET=
+1
View File
@@ -137,6 +137,7 @@ dist
# Generated application files
public/css/site.css
prisma/generated/
# Stinky MacOS files
.DS_Store
+2 -7
View File
@@ -1,16 +1,11 @@
import { SanitizedUser } from "#/db/types/users";
import "fastify";
declare module "fastify" {
interface Session {
account: { // TODO: Type
userId: number;
username: string;
authenticated: boolean;
};
account: SanitizedUser;
success?: string;
error?: string;
loginRedirectUri?: string;
}
}
+109 -1
View File
@@ -1,3 +1,111 @@
# TypeScriptTemplate
Template TypeScript, EJS, Tailwind app
This is a starting template for a TypeScript web app that has the following tech stack:
* Node.js
* MySQL/MariaDB
* Fastify
* Prisma
* Kysely
* EJS
* Tailwind
I created this to save myself the time of having to spend hours initializing and configuring a
project every time I have an idea since this is already a pretty good starting point.
# Setup
Setting up the application will consist of the following:
1. Install MySQL/MariaDB server
2. Create a new database for the application
3. Create a new user with full access to the database
4. Configure environment file with database connection information and other optional changes
5. Installing node packages
6. Running application
## Database Setup
This project was created using MariaDB and uses `@prisma/adapter-mariadb`. While MariaDB is compliant with
MySQL and will probably have no issues working with MySQL, it is highly advised you use MariaDB for your
database.
Once your database server is installed, you can run the following commands:
```sql
-- Create the database
CREATE DATABASE `foobar`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
-- Create the user (pls do not use the default password)
CREATE USER 'foo'@'%' IDENTIFIED BY '<PASSWORD>';
-- if you prefer a more locked down account that can only connect locally:
--CREATE USER 'foo'@'192.168.%' IDENTIFIED BY '<PASSWORD>';
-- Grant all privileges on the recipes database
GRANT ALL PRIVILEGES ON `foobar`.* TO 'foo'@'%';
-- Apply the privilege changes
FLUSH PRIVILEGES;
```
The names of the database and account are not important, nor is the host value for
the account. Feel free to configure the database name, user name, and host. If you
decide to do so, please remember to use the updated values later in the config.
## Configuring Environment File
Once you've confirmed your database is fully configured and you can connect using
the newly created account, you will need to populate the `.env` config file. You
may rename the `.env.example` file to `.env` to have all required values pre-populated
in the config.
The only required values to be updated are the keys prefixed with `DB_`, as these
values are used to build the database connection string, and all the values under
the `# Security settings` comment. For the `SESSION_SECRET` value, it is advised
you generate a cryptographically random string of at least 32 characters.
```env
# Application settings
APP_PORT=8080
# Environments - dev, prod
APP_ENVIRONMENT=prod
# Database settings
DB_HOST=<server IP>
DB_PORT=3306
DB_NAME=foobar
DB_USER=foo
DB_PASS=<PASSWORD>
# Security settings
SESSION_SECRET=a2VwdG9mZmljaWFsYXJyYW5nZW1lbnRpbmRlcGVuZGVudGxha2VjbGVhcmx5b2xkZXJibG9vZHRpcHNwZWVjaA==
```
If for whatever reason you need to override the default format of
`mysql://user:password@host:port/name` for your database connection string, you my
optionally include a `DB_URL=...` value in the config to override how the connection
string is generated, which will ignore all other `DB_*` values and use your exact
inputted value.
## Running Application
Once the `.env` file has been configured, you may now run the application. This application
was developed with Node.js v25.9.0 and npm v11.12.1. Please see [Node.js](https://nodejs.org/en/download/)'s download
page for installation.
If/when you have Node.js installed, open a shell in the root of this project and
run the following commands:
```bash
# Install Node.js packages
npm i
# Deploy database using provided connection information
npm run db:deploy
# Run application
npm start
```
If successful, you will see the IP of the server in the console output.
+1508 -26
View File
File diff suppressed because it is too large. Load diff
+17 -5
View File
@@ -1,5 +1,5 @@
{
"name": "recypies",
"name": "typescript-template",
"version": "1.0.0",
"description": "A simple recipe manager with no BS",
"main": "index.js",
@@ -7,11 +7,16 @@
"clean": "rm -rf dist/",
"build": "npm run clean && tailwindcss -i src/css/site.css -o public/css/site.css --minify",
"dev": "concurrently \"tailwindcss -i src/css/site.css -o public/css/site.css --watch\" \"tsx watch src/app.js\"",
"start": "npm i && npm run build && tsx src/app.js"
"start": "npm i && npm run build && tsx src/app.js",
"db:deploy": "prisma migrate deploy --config prisma/prisma.config.ts",
"db:reset": "prisma migrate reset --force --config prisma/prisma.config.ts",
"db:schema": "prisma migrate dev --config prisma/prisma.config.ts",
"db:seed": "npm run db:generate && prisma db seed --config prisma/prisma.config.ts",
"db:generate": "npx prisma generate --config prisma/prisma.config.ts"
},
"repository": {
"type": "git",
"url": "https://git.cyper.cc/Cyper/Recypies"
"url": "https://git.cyper.cc/Cyper/TypeScriptTemplate"
},
"keywords": [],
"author": "",
@@ -19,10 +24,12 @@
"type": "module",
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/node": "^25.5.0",
"@types/node": "^25.5.2",
"concurrently": "^9.2.1",
"pino-pretty": "^13.1.3",
"pino-print": "^1.0.0",
"prettier": "^3.8.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^7.6.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
},
@@ -35,10 +42,15 @@
"@fastify/session": "^11.1.1",
"@fastify/static": "^9.0.0",
"@fastify/view": "^11.1.1",
"@prisma/adapter-mariadb": "^7.7.0",
"@prisma/client": "^7.6.0",
"@tailwindcss/cli": "^4.2.2",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"ejs": "^5.0.1",
"fastify": "^5.8.4",
"kysely": "^0.28.15",
"mysql2": "^3.20.0",
"tailwindcss": "^4.2.2"
},
"imports": {
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE `user` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`username` VARCHAR(191) NOT NULL,
`password` VARCHAR(191) NOT NULL,
`email` VARCHAR(191) NULL,
`display_name` VARCHAR(191) NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`active` BOOLEAN NOT NULL DEFAULT true,
UNIQUE INDEX `user_username_key`(`username`),
UNIQUE INDEX `user_email_key`(`email`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `audit_type` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(191) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- CreateTable
CREATE TABLE `audit` (
`id` INTEGER NOT NULL AUTO_INCREMENT,
`type_id` INTEGER NOT NULL,
`account_id` INTEGER NOT NULL,
`target_id` INTEGER NULL,
`data` VARCHAR(191) NULL,
`ip` VARCHAR(191) NULL,
`timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-- AddForeignKey
ALTER TABLE `audit` ADD CONSTRAINT `audit_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `user`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE `audit` ADD CONSTRAINT `audit_type_id_fkey` FOREIGN KEY (`type_id`) REFERENCES `audit_type`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "mysql"
+17
View File
@@ -0,0 +1,17 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import 'dotenv/config';
import { defineConfig, env } from 'prisma/config';
const dbUrl = `mysql://${env('DB_USER')}:${env('DB_PASS')}@${env('DB_HOST')}:${env('DB_PORT')}/${env('DB_NAME')}`;
export default defineConfig({
schema: 'schema.prisma',
migrations: {
path: 'migrations',
seed: 'tsx ./prisma/seed.ts',
},
datasource: {
url: dbUrl,
},
});
+48
View File
@@ -0,0 +1,48 @@
generator client {
provider = "prisma-client"
output = "./generated"
}
datasource db {
provider = "mysql"
}
model User {
id Int @id @default(autoincrement())
username String @unique
password String
email String? @unique
displayName String? @map("display_name")
createdAt DateTime @db.Timestamp @map("created_at") @default(now())
updatedAt DateTime @db.Timestamp @updatedAt @map("updated_at") @default(now())
active Boolean @default(true)
audits Audit[]
@@map("user")
}
model AuditType {
id Int @id @default(autoincrement())
name String
audits Audit[]
@@map("audit_type")
}
model Audit {
id Int @id @default(autoincrement())
account User @relation(fields: [accountId], references: [id])
type AuditType @relation(fields: [typeId], references: [id])
typeId Int @map("type_id")
accountId Int @map("account_id")
targetId Int? @map("target_id")
data String?
ip String?
timestamp DateTime @db.Timestamp @default(now())
@@map("audit")
}
+60
View File
@@ -0,0 +1,60 @@
import 'dotenv/config';
import { AuditType } from '#/db/types/audit';
import Environment from '#/util/Environment';
import { PrismaMariaDb } from '@prisma/adapter-mariadb';
import { PrismaClient } from './generated/client';
const adapter = new PrismaMariaDb({
host: Environment.DB_HOST,
port: Environment.DB_PORT,
user: Environment.DB_USER,
password: Environment.DB_PASS,
database: Environment.DB_NAME,
});
const client = new PrismaClient({ adapter });
async function main() {
const auditTypes: AuditType[] = [
{ id: 1, name: 'Create User' },
{ id: 2, name: 'Modify User' },
{ id: 3, name: 'Delete User' },
{ id: 100, name: 'User Login' },
{ id: 101, name: 'User Logout' },
];
for (const auditType of auditTypes) {
await client.auditType.upsert({
where: { id: auditType.id },
create: { id: auditType.id, name: auditType.name },
update: { name: auditType.name },
});
}
await client.user.upsert({
where: { id: 1 },
create: {
username: 'admin',
displayName: 'System',
password: '',
active: false,
},
update: {
username: 'admin',
displayName: 'System',
password: '',
active: false,
email: null,
},
});
}
main()
.then(async () => {
await client.$disconnect();
})
.catch(async (e) => {
console.error(e);
await client.$disconnect();
process.exit(1);
});
+13
View File
@@ -1,3 +1,4 @@
// Dropdown
const btn = document.getElementById('navAvatarBtn');
const dropdown = document.getElementById('navDropdown');
@@ -25,3 +26,15 @@ if (btn && dropdown) {
}
});
}
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
});
}
+6
View File
@@ -0,0 +1,6 @@
(function () {
const stored = localStorage.getItem('theme');
if (stored) {
document.documentElement.setAttribute('data-theme', stored);
}
})();
+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.',
};
+3 -3
View File
@@ -4,8 +4,8 @@
"outDir": "out",
"target": "ESNext",
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"#/*": ["./src/*"],
},
@@ -19,6 +19,6 @@
"esModuleInterop": true,
"allowUnreachableCode": true,
},
"include": ["src/**/*.ts", "src/**/*.js", "@types"],
"include": ["src/**/*.ts", "src/**/*.js", "@types", "*.ts", "prisma/**/*.ts"],
"exclude": ["node_modules", "<node_internals>/**"],
}
+1 -1
View File
@@ -1,5 +1,5 @@
<div>
<span>&copy; <%= (new Date()).getFullYear() %> Cyper</span>
<span>&copy; <%= (new Date()).getFullYear() %> AppName</span>
<span style="color: var(--border)">|</span>
<span>All rights reserved</span>
</div>
+4 -1
View File
@@ -1,4 +1,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="/js/theme.js"></script> <!-- Load before CSS to prevent page flashing -->
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
<title><%= typeof(title) !== "undefined" ? title + ' - ' : "" %>Template</title>
<title><%= typeof(title) !== "undefined" ? title + ' - ' : "" %>AppName</title>
+34 -23
View File
@@ -1,29 +1,40 @@
<nav>
<a href="/" class="nav-brand">Cyper</a>
<a href="/" class="nav-brand">AppName</a>
<% if (typeof(account) !== "undefined" && account.authenticated) { %>
<div class="nav-avatar-wrap">
<button class="nav-avatar" id="navAvatarBtn" aria-haspopup="true" aria-expanded="false">
<% if (account.avatarUrl) { %>
<img src="<%= account.avatarUrl %>" alt="<%= account.username %>" />
<% } else { %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
<% } %>
</button>
<div class="nav-right">
<button class="nav-theme-toggle" id="themeToggle" aria-label="Toggle theme">
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/>
</svg>
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 18a6 6 0 1 1 0-12 6 6 0 0 1 0 12zm0-2a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85l1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"/>
</svg>
</button>
<div class="nav-dropdown" id="navDropdown" aria-hidden="true">
<div class="nav-dropdown-header">
<span class="nav-dropdown-username"><%= account.username %></span>
<% if (typeof(account) !== "undefined") { %>
<div class="nav-avatar-wrap">
<button class="nav-avatar" id="navAvatarBtn" aria-haspopup="true" aria-expanded="false">
<% if (account.avatarUrl) { %>
<img src="<%= account.avatarUrl %>" alt="<%= account.username %>" />
<% } else { %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
<% } %>
</button>
<div class="nav-dropdown" id="navDropdown" aria-hidden="true">
<div class="nav-dropdown-header">
<span class="nav-dropdown-username"><%= account.username %></span>
</div>
<a href="/profile" class="nav-dropdown-item">Profile</a>
<a href="/settings" class="nav-dropdown-item">Settings</a>
<div class="nav-dropdown-divider"></div>
<form method="post" action="/logout">
<button class="nav-dropdown-item nav-dropdown-signout" type="submit">Sign out</button>
</form>
</div>
<a href="/profile" class="nav-dropdown-item">Profile</a>
<a href="/settings" class="nav-dropdown-item">Settings</a>
<div class="nav-dropdown-divider"></div>
<form method="post" action="/logout">
<button class="nav-dropdown-item nav-dropdown-signout" type="submit">Sign out</button>
</form>
</div>
</div>
<% } %>
<% } %>
</div>
</nav>
-5
View File
@@ -1,12 +1,7 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "Home" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header>
+3 -1
View File
@@ -15,7 +15,9 @@
<main class="grid place-items-center px-4">
<div class="card w-full max-w-sm">
<h1 class="text-lg font-semibold tracking-wide mb-6" style="color: var(--khaki)">Sign in</h1>
<% if (error) { %>
<h2 class="mb-6" style="color: var(--mint)"><%= error %></h1>
<% } %>
<form method="post" class="flex flex-col gap-4">
<div class="field">
<label for="username">Username</label>
+35
View File
@@ -0,0 +1,35 @@
<!doctype html>
<html>
<head>
<%- include('_partials/header', { title: 'Setup' }); %>
</head>
<body>
<header>
<%- include("_partials/navbar"); %>
</header>
<main class="grid place-items-center px-4">
<div class="card w-full max-w-lg">
<h1 class="text-3xl text-center pb-6">First Time Setup</h1>
<% if (error) { %>
<p class="pb-6 text-center" style="color: var(--mint)"><%= error %></p>
<% } %>
<form method="post" class="form flex flex-col gap-4">
<div class="field">
<label for="username">Username</label>
<input type="text" id="username" name="username" />
</div>
<div class="field">
<label for="password">Password</label>
<input type="password" id="password" name="password" />
</div>
<button class="btn btn-primary" type="submit">Create Admin Account</button>
</form>
</div>
</main>
<footer>
<%- include("_partials/footer"); %>
</footer>
<script src="/js/navbar.js"></script>
</body>
</html>