Template
feat: Initial commit
This commit is contained in:
1 parent
16004f5a84
commit
04b9739853
21 files changed
+4163
No files matched your search
@@ -0,0 +1,13 @@
|
||||
# Application settings
|
||||
APP_PORT=
|
||||
APP_ENVIRONMENT=dev
|
||||
|
||||
# Database settings
|
||||
DB_HOST=
|
||||
DB_PORT=3306
|
||||
DB_USER=
|
||||
DB_PASS=
|
||||
DB_NAME=
|
||||
|
||||
# Security settings
|
||||
SESSION_SECRET=
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
# Generated application files
|
||||
public/css/site.css
|
||||
|
||||
# Stinky MacOS files
|
||||
.DS_Store
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
import "fastify";
|
||||
|
||||
declare module "fastify" {
|
||||
interface Session {
|
||||
account: { // TODO: Type
|
||||
userId: number;
|
||||
username: string;
|
||||
authenticated: boolean;
|
||||
};
|
||||
|
||||
success?: string;
|
||||
error?: string;
|
||||
|
||||
loginRedirectUri?: string;
|
||||
}
|
||||
}
|
||||
Generated
+3114
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "recypies",
|
||||
"version": "1.0.0",
|
||||
"description": "A simple recipe manager with no BS",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.cyper.cc/Cyper/Recypies"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/ejs": "^3.1.5",
|
||||
"@types/node": "^25.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"pino-print": "^1.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/autoload": "^6.3.1",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/session": "^11.1.1",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@fastify/view": "^11.1.1",
|
||||
"@tailwindcss/cli": "^4.2.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"ejs": "^5.0.1",
|
||||
"fastify": "^5.8.4",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"imports": {
|
||||
"#/*": "./src/*.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
const btn = document.getElementById('navAvatarBtn');
|
||||
const dropdown = document.getElementById('navDropdown');
|
||||
|
||||
if (btn && dropdown) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = dropdown.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', isOpen);
|
||||
dropdown.setAttribute('aria-hidden', !isOpen);
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => {
|
||||
dropdown.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', false);
|
||||
dropdown.setAttribute('aria-hidden', true);
|
||||
});
|
||||
|
||||
dropdown.addEventListener('click', (e) => e.stopPropagation());
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.remove('open');
|
||||
btn.setAttribute('aria-expanded', false);
|
||||
btn.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
+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;
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "out",
|
||||
"target": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"paths": {
|
||||
"#/*": ["./src/*"],
|
||||
},
|
||||
"types": ["node"],
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowUnreachableCode": true,
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.js", "@types"],
|
||||
"exclude": ["node_modules", "<node_internals>/**"],
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<div>
|
||||
<span>© <%= (new Date()).getFullYear() %> Cyper</span>
|
||||
<span style="color: var(--border)">|</span>
|
||||
<span>All rights reserved</span>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title><%= typeof(title) !== "undefined" ? title + ' - ' : "" %>Template</title>
|
||||
@@ -0,0 +1,29 @@
|
||||
<nav>
|
||||
<a href="/" class="nav-brand">Cyper</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-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>
|
||||
</div>
|
||||
<% } %>
|
||||
</nav>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!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>
|
||||
<%- include("_partials/navbar"); %>
|
||||
</header>
|
||||
<main class="px-6 py-10">
|
||||
<h1 class="page-title">Home</h1>
|
||||
</main>
|
||||
<footer>
|
||||
<%- include("_partials/footer"); %>
|
||||
</footer>
|
||||
|
||||
<script src="/js/navbar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<%- include('_partials/header', { title: "Login" }); %>
|
||||
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<%- include("_partials/navbar"); %>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
<form method="post" class="flex flex-col gap-4">
|
||||
<div class="field">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button class="btn btn-primary" type="submit">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<%- include("_partials/footer"); %>
|
||||
</footer>
|
||||
|
||||
<script src="/js/navbar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<%- include('_partials/header', { title: "401 Unauthorized" }); %>
|
||||
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<%- include("_partials/navbar"); %>
|
||||
</header>
|
||||
<main class="grid place-items-center px-4">
|
||||
<div class="error-page">
|
||||
<span class="error-code">401</span>
|
||||
<h1 class="error-title">Unauthorized</h1>
|
||||
<p class="error-message">You need to be signed in to access this page.</p>
|
||||
<a href="/login" class="btn btn-primary">Sign in</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<%- include("_partials/footer"); %>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<%- include('_partials/header', { title: "404 Not Found" }); %>
|
||||
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<%- include("_partials/navbar"); %>
|
||||
</header>
|
||||
<main class="grid place-items-center px-4">
|
||||
<div class="error-page">
|
||||
<span class="error-code">404</span>
|
||||
<h1 class="error-title">Not Found</h1>
|
||||
<p class="error-message">
|
||||
The resource <code class="error-resource"><%= resource %></code> does not exist.
|
||||
</p>
|
||||
<a href="/" class="btn btn-secondary">Go home</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<%- include("_partials/footer"); %>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<%- include('_partials/header', { title: "500 Internal Server Error" }); %>
|
||||
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
|
||||
<link rel="stylesheet" href="/css/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<%- include("_partials/navbar"); %>
|
||||
</header>
|
||||
<main class="grid place-items-center px-4">
|
||||
<div class="error-page">
|
||||
<span class="error-code">500</span>
|
||||
<h1 class="error-title">Internal Server Error</h1>
|
||||
<p class="error-message">Something went wrong on our end. Please try again later.</p>
|
||||
<% if (environment !== 'prod' && typeof(error) !== 'undefined') { %>
|
||||
<pre class="error-trace"><%= error %></pre>
|
||||
<% } %>
|
||||
<a href="/" class="btn btn-secondary">Go home</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<%- include("_partials/footer"); %>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!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 class="bg-red-700">
|
||||
<%- include("_partials/navbar"); %>
|
||||
</header>
|
||||
<main class="bg-green-700">
|
||||
<h1>Test Page</h1>
|
||||
<form method='post' action='/logout'>
|
||||
<button class="btn m-2 p-1" type="submit">Logout</button>
|
||||
</form>
|
||||
</main>
|
||||
<footer class="bg-blue-700">
|
||||
<%- include("_partials/footer"); %>
|
||||
</footer>
|
||||
|
||||
<script src="/js/navbar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user