feat: Initial commit

This commit is contained in:
Cyper committed 2026-03-30 23:33:32 -04:00
1 parent 16004f5a84
commit 04b9739853
21 files changed
+4163

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import path from 'path';
import ejs from 'ejs';
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import Autoload from '@fastify/autoload';
import Cookie from '@fastify/cookie';
import FormBody from '@fastify/formbody';
import Multipart from '@fastify/multipart';
import RateLimit from '@fastify/rate-limit';
import Session from '@fastify/session';
import Static from '@fastify/static';
import View from '@fastify/view';
import Environment from '#/util/Environment';
const fastify = Fastify({
logger:
Environment.APP_ENVIRONMENT === 'prod'
? true
: {
level: 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:HH:MM:ss',
ignore: 'pid,hostname',
},
},
},
disableRequestLogging: true,
});
fastify.register(FormBody);
fastify.register(Multipart);
fastify.register(Cookie);
fastify.register(Static, {
root: path.join(process.cwd(), 'public'),
});
fastify.register(View, {
engine: {
ejs,
},
root: path.join(process.cwd(), 'view'),
viewExt: 'ejs',
});
fastify.register(Session, {
secret: Environment.SESSION_SECRET,
cookie: {
secure: Environment.APP_ENVIRONMENT === 'prod',
maxAge: 1000 * 60 * 60 * 24, // 1 day
},
});
await fastify.register(RateLimit, {
max: 100,
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,
});
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);
}
}
done();
});
await instance.register(Autoload, {
dir: 'src/routes/private',
forceESM: true,
});
});
fastify.addHook('onRequest', (req: any, reply: any, done: (error?: Error) => void) => {
reply.locals = {
account: req.session.account,
environment: Environment.APP_ENVIRONMENT,
};
done();
});
fastify.listen(
{
port: Environment.APP_PORT,
host: '0.0.0.0',
},
(err: Error | null, address: string) => {
if (err) {
fastify.log.error(err);
process.exit(1);
}
fastify.log.warn(`[Web] Listening on port ${Environment.APP_PORT}`);
},
);
+310
View File
@@ -0,0 +1,310 @@
@import "tailwindcss";
:root {
--bg: #1a1714;
--surface: #2a2420;
--surface-2: #3d3228;
--border: #4a3f32;
--text: #d4c9b8;
--muted: #7a6e62;
--khaki: #a08c5e;
--grey: #8a8680;
--mint: #6ee7c7;
--mint-fg: #0d1f1a;
--radius: 0.5rem;
}
/* Base */
html,
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
font-family: ui-monospace, "Cascadia Code", "Fira Mono", monospace;
font-size: 0.9rem;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
}
/* Navbar */
nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border);
min-height: 3.5rem;
}
.nav-brand {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mint);
}
.nav-status {
display: flex;
align-items: center;
flex-direction: row;
gap: 0.75rem;
font-size: 0.78rem;
letter-spacing: 0.06em;
color: var(--muted);
}
.nav-status span {
color: var(--text);
}
.nav-status form {
display: contents;
}
/* Nav avatar & dropdown */
.nav-avatar-wrap {
position: relative;
}
.nav-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
transition: border-color 0.15s;
overflow: hidden;
}
.nav-avatar:hover {
border-color: var(--mint);
color: var(--mint);
}
.nav-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.nav-avatar svg {
width: 1.1rem;
height: 1.1rem;
}
.nav-dropdown {
display: none;
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 11rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
z-index: 50;
}
.nav-dropdown.open {
display: block;
}
.nav-dropdown-header {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border);
}
.nav-dropdown-username {
font-size: 0.78rem;
letter-spacing: 0.06em;
color: var(--muted);
}
.nav-dropdown-item {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
font-family: inherit;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
letter-spacing: 0.04em;
text-decoration: none;
transition:
background 0.1s,
color 0.1s;
}
.nav-dropdown-item:hover {
background: var(--surface-2);
color: var(--mint);
}
.nav-dropdown-divider {
border-top: 1px solid var(--border);
margin: 0.25rem 0;
}
.nav-dropdown-signout {
color: var(--muted);
}
/* Error pages */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 3rem 1rem;
}
.error-code {
font-size: 5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--border);
line-height: 1;
}
.error-title {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--khaki);
}
.error-message {
font-size: 0.85rem;
color: var(--muted);
max-width: 28rem;
}
.error-resource {
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) / 2);
padding: 0.1rem 0.4rem;
color: var(--mint);
font-size: 0.82rem;
}
.error-trace {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
font-size: 0.78rem;
color: var(--muted);
text-align: left;
max-width: 36rem;
width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Footer */
footer > div {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.9rem 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
letter-spacing: 0.06em;
color: var(--muted);
}
/* Card */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) * 1.5);
padding: 2rem;
box-shadow: 6px 6px 0px var(--border);
}
/* Form field */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.field label {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--khaki);
}
.field input {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 0.5rem 0.75rem;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
.field input:focus {
border-color: var(--mint);
}
/* Buttons */
.btn {
border-radius: var(--radius);
padding: 0.45rem 1.1rem;
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
transition: opacity 0.15s;
letter-spacing: 0.05em;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background: var(--mint);
color: var(--mint-fg);
border-color: var(--mint);
font-weight: 600;
}
.btn-secondary {
background: transparent;
color: var(--text);
border-color: var(--border);
}
/* Typography */
.page-title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--khaki);
border-bottom: 1px solid var(--border);
padding-bottom: 0.6rem;
margin-bottom: 1.5rem;
}
+11
View File
@@ -0,0 +1,11 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
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');
});
}
+33
View File
@@ -0,0 +1,33 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default function (app: FastifyInstance) {
app.get('/login', async (req: FastifyRequest, reply: FastifyReply) => {
if (req.session.account?.authenticated) {
return reply.redirect('/', 302);
}
return reply.view('login');
});
app.post('/login', async (req: FastifyRequest, reply: FastifyReply) => {
req.session.account = {
authenticated: true,
userId: 1,
username: 'Cyper',
};
const { loginRedirectUri } = req.session;
delete req.session.loginRedirectUri;
if (loginRedirectUri) {
return reply.redirect(loginRedirectUri, 302);
}
return reply.redirect('/', 302);
});
app.post('/logout', async (req: FastifyRequest, reply: FastifyReply) => {
req.session.destroy();
return reply.redirect('/login', 302);
});
}
+71
View File
@@ -0,0 +1,71 @@
import 'dotenv/config';
function tryParseBoolean(
value: string | boolean | undefined,
defaultValue: boolean,
): boolean {
if (value === 'true' || value === true) {
return true;
} else if (value === 'false' || value === false) {
return false;
} else {
return defaultValue;
}
}
function tryParseInt(
value: string | number | undefined,
defaultValue: number,
): number {
if (typeof value === 'number') {
return value;
}
if (typeof value !== 'string' && typeof value !== 'number') {
return defaultValue;
}
const intValue = parseInt(value);
if (!isNaN(intValue)) {
return intValue;
}
return defaultValue;
}
function tryParseString(
value: string | undefined,
defaultValue: string,
): string {
if (typeof value !== 'string') {
return defaultValue;
}
return value;
}
type Environment = {
APP_PORT: number;
APP_ENVIRONMENT: 'prod' | 'dev';
DB_HOST: string;
DB_PORT: number;
DB_USER: string;
DB_PASS: string;
DB_NAME: string;
SESSION_SECRET: string;
};
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, ''),
SESSION_SECRET: tryParseString(process.env.SESSION_SECRET, ''),
} as Environment;