Template
feat: Initial commit
This commit is contained in:
1 parent
16004f5a84
commit
04b9739853
21 files changed
+4163
No files matched your search
+140
@@ -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}`);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user