WIP: Request Logging

This commit is contained in:
Holly Stubbs 2025-01-03 03:11:00 +00:00
parent c4cd41c03c
commit d343acc9e5
Signed by: tgpholly
GPG key ID: B8583C4B7D18119E
23 changed files with 505 additions and 24 deletions

2
.gitignore vendored
View file

@ -1,2 +1,4 @@
node_modules/
build/
logs/
config.json

View file

@ -0,0 +1,65 @@
import LoginViewModel from "../models/account/LoginViewModel";
import RegisterViewModel from "../models/account/RegisterViewModel";
import Session from "../objects/Session";
import UserService from "../services/UserService";
import Controller from "./Controller";
export default class AccountController extends Controller {
public async Login_Get_AllowAnonymous() {
return this.view();
}
public async Login_Post_AllowAnonymous(loginViewModel: LoginViewModel) {
if (typeof(loginViewModel.username) !== "string" || typeof(loginViewModel.password) !== "string") {
return this.badRequest();
}
const user = await UserService.AuthenticateUser(loginViewModel.username, loginViewModel.password);
if (!user) {
loginViewModel.password = "";
loginViewModel.message = "Username or Password is incorrect";
return this.view(loginViewModel);
}
Session.AssignUserSession(this.res, user);
return this.redirectToAction("index", "home");
}
public async Register_Get_AllowAnonymous() {
return this.view();
}
public async Register_Post_AllowAnonymous(registerViewModel: RegisterViewModel) {
if (typeof(registerViewModel.username) !== "string" || typeof(registerViewModel.password) !== "string") {
return this.badRequest();
}
const username = registerViewModel.username.replaceAll("<", "&lt;").replaceAll(">", "&gt;");
if (!await UserService.CreateUser(0, username, registerViewModel.password)) {
registerViewModel.password = "";
registerViewModel.message = "Sorry! That username is already taken.";
return this.view(registerViewModel);
}
const user = await UserService.GetUserByUsername(username);
if (!user) {
registerViewModel.password = "";
registerViewModel.message = "Failed to create your account, please try again later.";
return this.view(registerViewModel);
}
Session.AssignUserSession(this.res, user);
return this.redirectToAction("index", "home");
}
public async Logout_Get_AllowAnonymous() {
Session.Clear(this.req.cookies, this.res);
return this.redirectToAction("index", "home");
}
}

View file

@ -10,6 +10,7 @@ import UserType from "../enums/UserType";
// can auto badRequest on missing stuff.
export default abstract class Controller {
public static FastifyInstance:FastifyInstance;
public static RegisteredPaths:Array<string> = [];
public constructor() {
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this));
@ -20,10 +21,10 @@ export default abstract class Controller {
for (const prop of rawControllerParts) {
if (prop.startsWith("Auth")) {
const userLevel = prop.split("$")[1];
const userType = prop.split("$")[1];
// @ts-ignore
controllerAuthLevels.push(UserLevel[userLevel]);
Console.printInfo(`Set Auth level requirement for ${this.constructor.name} to ${userLevel}`);
controllerAuthLevels.push(UserType[userType]);
Console.printInfo(`Set Auth level requirement for ${this.constructor.name} to ${userType}`);
}
}
@ -41,6 +42,7 @@ export default abstract class Controller {
// @ts-ignore
const controllerRequestHandler = this[method];
const requestHandler = (req:FastifyRequest, res:FastifyReply) => {
const requestStartTime = Date.now();
let session = Session.CheckValiditiy(req.cookies);
if (doAuth && session === undefined) {
return res.redirect(`/account/login?returnTo=${encodeURIComponent(req.url)}`);
@ -90,37 +92,46 @@ export default abstract class Controller {
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName === "index" ? "" : methodName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName === "index" ? "" : methodName}" as ${param}`);
Controller.RegisteredPaths.push(`/${controllerName}/${methodName === "index" ? "" : methodName}`);
if (methodName === "index") {
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}/${methodName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}/${methodName}" as ${param}`);
Controller.RegisteredPaths.push(`/${controllerName}/${methodName}`);
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${controllerName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${controllerName}" as ${param}`);
Controller.RegisteredPaths.push(`/${controllerName}`);
} else if (controllerName === "home") {
// @ts-ignore
Controller.FastifyInstance[param.toLowerCase()](`/${methodName}`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/${methodName}" as ${param}`);
Controller.RegisteredPaths.push(`/${methodName}`);
}
} else if (param.startsWith("Auth")) {
const nameWithMethod = `${controllerName}_${methodName}_${thisMethodHttpMethod}`;
const userLevel = param.split("$")[1];
const userType = param.split("$")[1];
if (!(nameWithMethod in actionAuthLevels)) {
actionAuthLevels[nameWithMethod] = [];
}
// @ts-ignore
actionAuthLevels[nameWithMethod].push(UserLevel[userLevel]);
Console.printInfo(`Set Auth level requirement for ${this.constructor.name}.${method} to ${userLevel}`);
actionAuthLevels[nameWithMethod].push(UserType[userType]);
Console.printInfo(`Set Auth level requirement for ${this.constructor.name}.${method} to ${userType}`);
}
}
if (controllerName === "home" && methodName === "index") {
for (const httpMethod of funcMethods) {
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/" as ${httpMethod}`);
// @ts-ignore
Controller.FastifyInstance[httpMethod.toLowerCase()](`/`, requestHandler);
Console.printInfo(`Registered ${this.constructor.name}.${method} to "/" as ${httpMethod}`);
Controller.RegisteredPaths.push(`/`);
}
}
}
}
// not real, these RequestCtx so they autocomplete :)
// not real, these should mirror RequestCtx so they autocomplete :)
// yeah, i know. this is terrible.
// Fields

View file

@ -1,7 +1,14 @@
import Controller from "./Controller";
export default class HomeController extends Controller {
public AllowAnonymous_Index() {
public Index_Get_AllowAnonymous() {
return this.view();
}
public Upload_Post_AllowAnonymous() {
console.log(this.req.headers.authorization);
console.log(this.req.body);
return this.ok();
}
}

19
entities/User.ts Normal file
View file

@ -0,0 +1,19 @@
import UserType from "../enums/UserType";
export default class User {
public Id: number = Number.MIN_VALUE;
public UserType: UserType = UserType.Unknown;
public Username: string = "";
public EmailAddress: string = "";
public PasswordHash: string = "";
public PasswordSalt: string = "";
public ApiKey: string = "";
public UploadKey: string = "";
public CreatedByUserId: number = Number.MIN_VALUE;
public CreatedDatetime: Date = new Date();
public LastModifiedByUserId?: number;
public LastModifiedDatetime?: Date;
public DeletedByUserId?: number;
public DeletedDatetime?: Date;
public IsDeleted: boolean = false;
}

View file

@ -11,6 +11,9 @@ import Controller from "./controllers/Controller";
import HomeController from "./controllers/HomeController";
import Database from "./objects/Database";
import { join } from "path";
import AccountController from "./controllers/AccountController";
import { magenta, blue, cyan } from "dyetty";
import ConsoleUtility from "./utilities/ConsoleUtility";
Console.customHeader(`EUS server started at ${new Date()}`);
@ -38,16 +41,42 @@ fastify.register(FastifyCookie, {
fastify.register(FastifyStatic, {
root: join(__dirname, "wwwroot"),
preCompressed: true
//prefix: `${Config.ports.web}/static/`
});
fastify.setNotFoundHandler(async (_req, res) => {
return res.status(404).view("views/404.ejs", { });
fastify.addHook("preValidation", (req, res, done) => {
// @ts-ignore
req.startTime = Date.now();
// * Take usual controller path if this path is registered.
if (Controller.RegisteredPaths.includes(req.url)) {
// @ts-ignore
req.logType = cyan("CONTROLLER");
return done();
} else {
// @ts-ignore
req.logType = magenta(" STATIC ");
}
done();
});
fastify.addHook("onSend", (req, res, _payload, done) => {
// @ts-ignore
Console.printInfo(`[ ${req.logType} ] [ ${ConsoleUtility.StatusColor(res.statusCode)} ] [ ${blue(`${Date.now() - req.startTime}ms`)} ] > ${req.url}`);
done();
});
fastify.setNotFoundHandler(async (req, res) => {
return res.status(404).view("views/404.ejs", { session: null });
});
new Database(Config.database.address, Config.database.port, Config.database.username, Config.database.password, Config.database.name);
Controller.FastifyInstance = fastify;
new AccountController();
new HomeController();
fastify.listen({ port: Config.ports.web, host: "127.0.0.1" }, (err, address) => {

View file

@ -0,0 +1,5 @@
export default interface LoginViewModel {
message?: string,
username: string,
password: string
}

View file

@ -0,0 +1,5 @@
export default interface RegisterViewModel {
message?: string,
username: string,
password: string
}

View file

@ -5,6 +5,8 @@ export default abstract class Config {
public static ports:IPorts = config.ports;
public static database:IDatabase = config.database;
public static session:ISession = config.session;
public static controllers:IControllers = config.controllers;
public static accounts:IAccounts = config.accounts;
}
interface IPorts {
@ -24,3 +26,22 @@ interface ISession {
validity: number,
length: number
}
interface IControllers {
enabled: boolean
}
interface ISignup {
enabled: boolean,
key: string | null
}
interface IPbkdf2 {
itterations: number,
keylength: number
}
interface IAccounts {
signup: ISignup,
pbkdf2: IPbkdf2
}

View file

@ -31,7 +31,7 @@ export default class RequestCtx {
// @ts-ignore inject session
viewModel["session"] = this.session;
// @ts-ignore inject enums
viewModel["UserLevel"] = UserLevel;
viewModel["UserType"] = UserType;
return this.res.view(`views/${this.controllerName}/${viewName}.ejs`, viewModel);
}

View file

@ -25,7 +25,7 @@ export default abstract class Session {
validPeriod.setTime(validPeriod.getTime() + Config.session.validity);
const key = randomBytes(Config.session.length).toString("hex");
Session.Sessions.set(key, new SessionUser(user.Id, validPeriod));
Session.Sessions.set(key, new SessionUser(user.Id, user.UserType, validPeriod));
res.setCookie("EHP_SESSION", key, {
path: "/",

17
package-lock.json generated
View file

@ -14,15 +14,16 @@
"@fastify/multipart": "^9.0.1",
"@fastify/static": "^8.0.3",
"@fastify/view": "^10.0.1",
"dyetty": "^1.0.1",
"ejs": "^3.1.10",
"fastify": "^5.2.0",
"funky-array": "^1.0.0",
"hsconsole": "^1.0.2",
"hsconsole": "^1.1.0",
"mysql2": "^3.12.0"
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/node": "^22.10.2",
"@types/node": "^22.10.4",
"@vercel/ncc": "^0.38.3",
"check-outdated": "^2.12.0",
"nodemon": "^3.1.9",
@ -266,9 +267,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.10.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz",
"integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==",
"version": "22.10.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.4.tgz",
"integrity": "sha512-99l6wv4HEzBQhvaU/UGoeBoCK61SCROQaCCGyQSgX2tEQ3rKkNZ2S7CEWnS/4s1LV+8ODdK21UeyR1fHP2mXug==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -988,9 +989,9 @@
}
},
"node_modules/hsconsole": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/hsconsole/-/hsconsole-1.0.2.tgz",
"integrity": "sha512-st+jaSpNw3uoIhE5vl2lVN8Op8yQF2FyLRdBG68s8vqjduJdKUGtoEXd8Zxe6du1zzpFHHRcU3zJbAq8BOmYQA==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/hsconsole/-/hsconsole-1.1.0.tgz",
"integrity": "sha512-nQtnapTLf/d090AloKJkVbf15yXNaISYYHC21cwOLClF7hlJs2rlHF3JLaltspK/O2uhz6WcRMsE+2Yn6D8UEw==",
"license": "MIT",
"dependencies": {
"dyetty": "^1.0.1"

View file

@ -13,11 +13,11 @@
"scripts": {
"updateCheck": "check-outdated",
"dev": "nodemon --watch './**/*.ts' index.ts",
"build": "tsx --build --clean"
"build": "tsc --build"
},
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/node": "^22.10.2",
"@types/node": "^22.10.4",
"@vercel/ncc": "^0.38.3",
"check-outdated": "^2.12.0",
"nodemon": "^3.1.9",
@ -30,10 +30,11 @@
"@fastify/multipart": "^9.0.1",
"@fastify/static": "^8.0.3",
"@fastify/view": "^10.0.1",
"dyetty": "^1.0.1",
"ejs": "^3.1.10",
"fastify": "^5.2.0",
"funky-array": "^1.0.0",
"hsconsole": "^1.0.2",
"hsconsole": "^1.1.0",
"mysql2": "^3.12.0"
}
}

5
repos/RepoBase.ts Normal file
View file

@ -0,0 +1,5 @@
export default class RepoBase {
public static convertNullableDatetimeIntToDate(dateTimeInt?:number) {
return dateTimeInt ? new Date(dateTimeInt) : undefined;
}
}

80
repos/UserRepo.ts Normal file
View file

@ -0,0 +1,80 @@
import Database from "../objects/Database";
import RepoBase from "./RepoBase";
import User from "../entities/User";
export default abstract class UserRepo {
public static async SelectAll() {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE IsDeleted = 0");
const users = new Array<User>();
for (const row of dbUser) {
const user = new User();
PopulateUserFromDB(user, row);
users.push(user);
}
return users;
}
public static async SelectById(id:number) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE Id = ? LIMIT 1", [id]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async SelectByUsername(username:string) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE Username = ? LIMIT 1", [username]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async SelectByEmailAddress(emailAddress:string) {
const dbUser = await Database.Instance.query("SELECT * FROM User WHERE EmailAddress = ? LIMIT 1", [emailAddress]);
if (dbUser == null || dbUser.length === 0) {
return null;
} else {
const user = new User();
PopulateUserFromDB(user, dbUser[0]);
return user;
}
}
public static async InsertUpdate(user:User) {
if (user.Id === Number.MIN_VALUE) {
user.Id = (await Database.Instance.query("INSERT User (UserTypeId, Username, PasswordHash, PasswordSalt, ApiKey, UploadKey, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [
user.UserType, user.Username, user.PasswordHash, user.PasswordSalt, user.ApiKey, user.UploadKey, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted)
]))[0]["Id"];
} else {
await Database.Instance.query(`UPDATE User SET UserTypeId = ?, Username = ?, PasswordHash = ?, PasswordSalt = ?, CreatedByUserId = ?, CreatedDatetime = ?, LastModifiedByUserId = ?, LastModifiedDatetime = ?, DeletedByUserId = ?, DeletedDatetime = ?, IsDeleted = ? WHERE Id = ?`, [
user.UserType, user.Username, user.PasswordHash, user.PasswordSalt, user.CreatedByUserId, user.CreatedDatetime.getTime(), user.LastModifiedByUserId ?? null, user.LastModifiedDatetime?.getTime() ?? null, user.DeletedByUserId ?? null, user.DeletedDatetime?.getTime() ?? null, Number(user.IsDeleted), user.Id
]);
}
return user;
}
}
function PopulateUserFromDB(user:User, dbUser:any) {
user.Id = dbUser.Id;
user.UserType = dbUser.UserTypeId;
user.Username = dbUser.Username;
user.PasswordHash = dbUser.PasswordHash;
user.PasswordSalt = dbUser.PasswordSalt;
user.CreatedByUserId = dbUser.CreatedByUserId;
user.CreatedDatetime = new Date(dbUser.CreatedDatetime);
user.LastModifiedByUserId = dbUser.LastModifiedByUserId;
user.LastModifiedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUser.LastModifiedDatetime);
user.DeletedByUserId = dbUser.DeletedByUserId;
user.DeletedDatetime = RepoBase.convertNullableDatetimeIntToDate(dbUser.DeletedDatetime);
user.IsDeleted = dbUser.IsDeleted[0] === 1;
}

76
services/UserService.ts Normal file
View file

@ -0,0 +1,76 @@
import { Console } from "hsconsole";
import UserRepo from "../repos/UserRepo";
import PasswordUtility from "../utilities/PasswordUtility";
import UserType from "../enums/UserType";
import User from "../entities/User";
export default abstract class UserService {
public static async AuthenticateUser(username:string, password:string) {
try {
const user = await UserRepo.SelectByUsername(username);
if (!user) {
return null;
}
if (await PasswordUtility.ValidatePassword(user.PasswordHash, user.PasswordSalt, password)) {
return user;
}
return null;
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetUser(id:number) {
try {
return await UserRepo.SelectById(id);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetAll() {
try {
return await UserRepo.SelectAll();
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetUserByUsername(username:string) {
try {
return await UserRepo.SelectByUsername(username);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async CreateUser(currentUserId:number, username:string, password:string) {
try {
const existingCheck = await UserRepo.SelectByUsername(username);
if (existingCheck) {
return null;
}
const user = new User();
user.UserType = UserType.User;
user.Username = username;
user.PasswordSalt = PasswordUtility.GenerateSalt();
user.PasswordHash = await PasswordUtility.HashPassword(user.PasswordSalt, password);
user.CreatedByUserId = currentUserId;
user.CreatedDatetime = new Date();
await UserRepo.InsertUpdate(user);
return user;
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
}

View file

@ -0,0 +1,15 @@
import { green, yellow, red, gray } from "dyetty";
export default abstract class ConsoleUtility {
public static StatusColor(statusCode: number) {
if (statusCode < 300) {
return `${green(statusCode.toString())}`;
} else if (statusCode >= 300 && statusCode < 400) {
return `${yellow(statusCode.toString())}`;
} else if (statusCode >= 400 && statusCode < 600) {
return `${red(statusCode.toString())}`;
} else {
return `${gray(statusCode.toString())}`;
}
}
}

View file

@ -0,0 +1,36 @@
import { pbkdf2, randomBytes } from "crypto";
import Config from "../objects/Config";
export default abstract class PasswordUtility {
public static ValidatePassword(hash:string, salt:string, password:string) {
return new Promise<boolean>((resolve, reject) => {
pbkdf2(password, salt, Config.accounts.pbkdf2.itterations, Config.accounts.pbkdf2.keylength, "sha512", (err, derivedKey) => {
if (err) {
return reject(err);
} else {
if (derivedKey.toString("hex") !== hash) {
return resolve(false);
}
return resolve(true);
}
});
});
}
public static HashPassword(salt:string, password:string) {
return new Promise<string>((resolve, reject) => {
pbkdf2(password, salt, Config.accounts.pbkdf2.itterations, Config.accounts.pbkdf2.keylength, "sha512", (err, derivedKey) => {
if (err) {
return reject(err);
} else {
return resolve(derivedKey.toString("hex"));
}
});
});
}
public static GenerateSalt() {
return randomBytes(Config.accounts.pbkdf2.keylength).toString("hex");
}
}

5
views/404.ejs Normal file
View file

@ -0,0 +1,5 @@
<%- include("./base/header", { title: "404", session }) %>
<h1>404</h1>
<%- include("./base/footer") %>

36
views/base/footer.ejs Normal file
View file

@ -0,0 +1,36 @@
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cookieconsent/3.1.1/cookieconsent.min.js" integrity="sha512-yXXqOFjdjHNH1GND+1EO0jbvvebABpzGKD66djnUfiKlYME5HGMUJHoCaeE4D5PTG2YsSJf6dwqyUUvQvS0vaA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
(() => {
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault();
event.stopPropagation();
}
form.classList.add('was-validated');
}, false);
});
})();
window.cookieconsent.initialise({
"palette": {
"popup": {
"background": "#0b5ed7",
"text": "#fff"
},
"button": {
"background": "#198754",
"text": "#fff"
}
},
"content": {
"message": "This site uses cookies to retain your login, no more, no less.<br>If you do not agree with this use of cookies, please do not use this site."
}
});
</script>
</body>
</html>

57
views/base/header.ejs Normal file
View file

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= title %> - EUS</title>
<link rel="preconnect" href="https://rsms.me/">
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<style>
:root {
font-family: Inter, sans-serif;
--bs-font-sans-serif: "Inter, sans-serif";
font-feature-settings: 'liga' 1, 'calt' 1; /* fix for Chrome */
}
@supports (font-variation-settings: normal) {
:root {
font-family: InterVariable, sans-serif;
--bs-font-sans-serif: "InterVariable, sans-serif";
}
}
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css" integrity="sha512-jnSuA4Ss2PkkikSOLtYs8BlYIeeIK1h99ty4YfvRPAlzr377vr3CXDb7sb7eEEBYjDtcYj+AjBH3FLv5uSJuXg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css" integrity="sha512-dPXYcDub/aeb08c63jRq/k6GaKccl256JQy/AnOq7CAnEZ9FzSL9wSbcZkMp4R26vBsMLFYH4kQ67/bbV8XaCQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cookieconsent/3.1.1/cookieconsent.min.css" integrity="sha512-LQ97camar/lOliT/MqjcQs5kWgy6Qz/cCRzzRzUCfv0fotsCTC9ZHXaPQmJV8Xu/PVALfJZ7BDezl5lW3/qBxg==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.bundle.min.js" integrity="sha512-7Pi/otdlbbCR+LnW+F7PwFcSDJOuUJB3OxtEHbg4vSMvzvJjde4Po1v4BR9Gdc9aXNUNFVUY+SK51wWT8WF0Gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<nav class="navbar navbar-expand bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">
EUS<%= typeof(isAdmin) === "undefined" ? "" : " Admin" %>
</a>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto">
<div class="nav-item">
<a class="nav-link" href="/">Home</a>
</div>
</ul>
<ul class="navbar-nav">
<% if (typeof(userId) !== "undefined") { %>
<div class="nav-item float-end">
<a class="nav-link" href="/account/logout">Logout</a>
</div>
<% } else { %>
<div class="nav-item float-end">
<a class="nav-link" href="/account/login">Sign In</a>
</div>
<% } %>
</ul>
</div>
</div>
</nav>
<div class="container pt-5">

5
views/home/index.ejs Normal file
View file

@ -0,0 +1,5 @@
<%- include("../base/header", { title: "Home", session }) %>
<%- include("../base/footer") %>

BIN
wwwroot/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB