diff --git a/.gitignore b/.gitignore index cf7c602..abc1ca7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ +build/ +logs/ config.json \ No newline at end of file diff --git a/controllers/AccountController.ts b/controllers/AccountController.ts new file mode 100644 index 0000000..e688898 --- /dev/null +++ b/controllers/AccountController.ts @@ -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("<", "<").replaceAll(">", ">"); + 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"); + } +} \ No newline at end of file diff --git a/controllers/Controller.ts b/controllers/Controller.ts index e027cd1..3fdfb23 100644 --- a/controllers/Controller.ts +++ b/controllers/Controller.ts @@ -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 = []; 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 diff --git a/controllers/HomeController.ts b/controllers/HomeController.ts index 0233ad6..0d62405 100644 --- a/controllers/HomeController.ts +++ b/controllers/HomeController.ts @@ -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(); + } } \ No newline at end of file diff --git a/entities/User.ts b/entities/User.ts new file mode 100644 index 0000000..3cca44a --- /dev/null +++ b/entities/User.ts @@ -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; +} \ No newline at end of file diff --git a/index.ts b/index.ts index f878a4b..5eeec96 100644 --- a/index.ts +++ b/index.ts @@ -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) => { diff --git a/models/account/LoginViewModel.ts b/models/account/LoginViewModel.ts new file mode 100644 index 0000000..0efb9d3 --- /dev/null +++ b/models/account/LoginViewModel.ts @@ -0,0 +1,5 @@ +export default interface LoginViewModel { + message?: string, + username: string, + password: string +} \ No newline at end of file diff --git a/models/account/RegisterViewModel.ts b/models/account/RegisterViewModel.ts new file mode 100644 index 0000000..e6c7a67 --- /dev/null +++ b/models/account/RegisterViewModel.ts @@ -0,0 +1,5 @@ +export default interface RegisterViewModel { + message?: string, + username: string, + password: string +} \ No newline at end of file diff --git a/objects/Config.ts b/objects/Config.ts index 7750228..26aace0 100644 --- a/objects/Config.ts +++ b/objects/Config.ts @@ -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 { @@ -23,4 +25,23 @@ interface ISession { secret: string, 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 } \ No newline at end of file diff --git a/objects/RequestCtx.ts b/objects/RequestCtx.ts index 6698064..a4aa7b8 100644 --- a/objects/RequestCtx.ts +++ b/objects/RequestCtx.ts @@ -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); } diff --git a/objects/Session.ts b/objects/Session.ts index b4a7b9c..010ff5f 100644 --- a/objects/Session.ts +++ b/objects/Session.ts @@ -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: "/", diff --git a/package-lock.json b/package-lock.json index 07c1001..6582e75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index aa787b1..2df4e6d 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/repos/RepoBase.ts b/repos/RepoBase.ts new file mode 100644 index 0000000..ba85361 --- /dev/null +++ b/repos/RepoBase.ts @@ -0,0 +1,5 @@ +export default class RepoBase { + public static convertNullableDatetimeIntToDate(dateTimeInt?:number) { + return dateTimeInt ? new Date(dateTimeInt) : undefined; + } +} \ No newline at end of file diff --git a/repos/UserRepo.ts b/repos/UserRepo.ts new file mode 100644 index 0000000..591b88a --- /dev/null +++ b/repos/UserRepo.ts @@ -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(); + + 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; +} \ No newline at end of file diff --git a/services/UserService.ts b/services/UserService.ts new file mode 100644 index 0000000..a2f5e47 --- /dev/null +++ b/services/UserService.ts @@ -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; + } + } +} \ No newline at end of file diff --git a/utilities/ConsoleUtility.ts b/utilities/ConsoleUtility.ts new file mode 100644 index 0000000..926399e --- /dev/null +++ b/utilities/ConsoleUtility.ts @@ -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())}`; + } + } +} \ No newline at end of file diff --git a/utilities/PasswordUtility.ts b/utilities/PasswordUtility.ts new file mode 100644 index 0000000..1aff7b4 --- /dev/null +++ b/utilities/PasswordUtility.ts @@ -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((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((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"); + } +} \ No newline at end of file diff --git a/views/404.ejs b/views/404.ejs new file mode 100644 index 0000000..3c63d7c --- /dev/null +++ b/views/404.ejs @@ -0,0 +1,5 @@ +<%- include("./base/header", { title: "404", session }) %> + +

404

+ +<%- include("./base/footer") %> \ No newline at end of file diff --git a/views/base/footer.ejs b/views/base/footer.ejs new file mode 100644 index 0000000..794bf3a --- /dev/null +++ b/views/base/footer.ejs @@ -0,0 +1,36 @@ + + + + + \ No newline at end of file diff --git a/views/base/header.ejs b/views/base/header.ejs new file mode 100644 index 0000000..b1418b7 --- /dev/null +++ b/views/base/header.ejs @@ -0,0 +1,57 @@ + + + + + + <%= title %> - EUS + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/views/home/index.ejs b/views/home/index.ejs new file mode 100644 index 0000000..4112302 --- /dev/null +++ b/views/home/index.ejs @@ -0,0 +1,5 @@ +<%- include("../base/header", { title: "Home", session }) %> + + + +<%- include("../base/footer") %> \ No newline at end of file diff --git a/wwwroot/favicon.ico b/wwwroot/favicon.ico new file mode 100644 index 0000000..b094058 Binary files /dev/null and b/wwwroot/favicon.ico differ