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
+7
-3
@@ -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=
|
||||
@@ -137,6 +137,7 @@ dist
|
||||
|
||||
# Generated application files
|
||||
public/css/site.css
|
||||
prisma/generated/
|
||||
|
||||
# Stinky MacOS files
|
||||
.DS_Store
|
||||
Vendored
+2
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Generated
+1508
-26
File diff suppressed because it is too large.
Load diff
+17
-5
@@ -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;
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
(function () {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
}
|
||||
})();
|
||||
+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.',
|
||||
};
|
||||
+3
-3
@@ -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,5 +1,5 @@
|
||||
<div>
|
||||
<span>© <%= (new Date()).getFullYear() %> Cyper</span>
|
||||
<span>© <%= (new Date()).getFullYear() %> AppName</span>
|
||||
<span style="color: var(--border)">|</span>
|
||||
<span>All rights reserved</span>
|
||||
</div>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user