Add home page, add ability to view api keys, allow viewing your domains, add stats to account dashboard.

This commit is contained in:
Holly Stubbs 2025-03-25 02:12:58 +00:00
parent 1871abee00
commit 449f9c8dc7
Signed by: tgpholly
GPG key ID: B8583C4B7D18119E
21 changed files with 464 additions and 56 deletions

View file

@ -1,12 +1,14 @@
import type APIViewModel from "../models/account/APIViewModel";
import type LoginViewModel from "../models/account/LoginViewModel";
import type RegisterViewModel from "../models/account/RegisterViewModel";
import type DashboardViewModel from "../models/home/DashboardViewModel";
import type DashboardViewModel from "../models/account/DashboardViewModel";
import Config from "../objects/Config";
import Session from "../objects/Session";
import DomainService from "../services/DomainService";
import UserService from "../services/UserService";
import ArrayUtility from "../utilities/ArrayUtility";
import Controller from "./Controller";
import type DomainsViewModel from "../models/account/DomainsViewModel";
export default class AccountController extends Controller {
public async Login_Get_AllowAnonymous() {
@ -88,22 +90,50 @@ export default class AccountController extends Controller {
}
public async Dashboard_Get() {
if (this.session) {
const dashboardViewModel: DashboardViewModel = {
recentUploads: await UserService.GetRecentUploads(this.session.userId),
domains: ArrayUtility.ToIdKeyedDict(await DomainService.LoadDomains())
}
return this.view(dashboardViewModel);
if (!this.session) {
return this.redirect("/");
}
return this.redirect("/");
const dashboardViewModel: DashboardViewModel = {
recentUploads: await UserService.GetRecentUploads(this.session.userId),
domains: ArrayUtility.ToIdKeyedDict(await DomainService.LoadDomains()),
mediaCounts: await UserService.GetUserMediaCounts(this.session.userId),
mediaCount: await UserService.GetTotalMediaCount(this.session.userId),
mediaSize: await UserService.GetTotalMediaSize(this.session.userId)
}
return this.view(dashboardViewModel);
}
public async ImageList_Get() {
public async Domains_Get() {
const domainsViewModel: DomainsViewModel = {
domains: await UserService.GetDomains(this.session.userId)
};
console.log(domainsViewModel);
return this.view(domainsViewModel);
}
public async Media_Get() {
return this.view();
}
public async API_Get() {
const user = await UserService.GetUser(this.session.userId);
if (!user) {
return this.forbidden();
}
const apiViewModel: APIViewModel = {
apiKey: user.ApiKey,
uploadKey: user.UploadKey
};
return this.view(apiViewModel);
}
public async Logout_Get_AllowAnonymous() {
Session.Clear(this.req.cookies, this.res);

View file

@ -2,6 +2,10 @@ import Controller from "./Controller";
export default class HomeController extends Controller {
public Index_Get_AllowAnonymous() {
if (this.session) {
this.redirectToAction("dashboard", "account");
}
return this.view();
}
}

View file

@ -2,8 +2,14 @@ export default class Domain {
public Id: number = Number.MIN_VALUE;
public UserId: number = Number.MIN_VALUE;
public HasHttps: boolean = false;
public get HasHttpsString() {
return this.HasHttps ? "Yes" : "No";
}
public Domain: string = "";
public Active: boolean = false;
public get ActiveString() {
return this.Active ? "Yes" : "No";
}
public CreatedByUserId = Number.MIN_VALUE;
public CreatedDatetime = new Date(0);
public LastModifiedByUserId?: number;

4
entities/MediaCount.ts Normal file
View file

@ -0,0 +1,4 @@
export default class MediaCount {
public Type: string = "";
public Count: number = Number.MIN_VALUE;
}

View file

@ -0,0 +1,4 @@
export default interface APIViewModel {
apiKey: string,
uploadKey: string
}

View file

@ -1,7 +1,12 @@
import type Domain from "../../entities/Domain";
import Media from "../../entities/Media";
import type MediaCount from "../../entities/MediaCount";
export default interface DashboardViewModel {
recentUploads: Array<Media>,
domains: { [key: string]: Domain }
domains: { [key: string]: Domain },
mediaCount: number,
mediaCounts: Array<MediaCount>,
mediaSize: number
}

View file

@ -0,0 +1,5 @@
import type Domain from "../../entities/Domain";
export default interface DomainsViewModel {
domains: Array<Domain>
}

View file

@ -1,6 +1,7 @@
import { type FastifyReply, type FastifyRequest } from "fastify";
import SessionUser from "./SessionUser";
import UserType from "../enums/UserType";
import FormattingUtility from "../utilities/FormattingUtility";
export default class RequestCtx {
public controllerName:string;
@ -32,6 +33,8 @@ export default class RequestCtx {
viewModel["session"] = this.session;
// @ts-ignore inject enums
viewModel["UserType"] = UserType;
// @ts-ignore inject classes
viewModel["FormattingUtility"] = FormattingUtility;
return this.res.view(`views/${this.controllerName}/${viewName}.ejs`, viewModel);
}

View file

@ -3,37 +3,50 @@ import Database from "../objects/Database";
export default class DomainRepo {
public static async SelectAll() {
const dbMedia = await Database.Instance.query("SELECT * FROM Domain WHERE IsDeleted = 0");
const mediaList = new Array<Domain>();
const dbDomain = await Database.Instance.query("SELECT * FROM Domain WHERE IsDeleted = 0");
const domainList = new Array<Domain>();
for (const row of dbMedia) {
const media = new Domain();
PopulateDomainFromDB(media, row);
mediaList.push(media);
for (const row of dbDomain) {
const domain = new Domain();
PopulateDomainFromDB(domain, row);
domainList.push(domain);
}
return mediaList;
return domainList;
}
public static async SelectByUserId(userId: number) {
const dbDomain = await Database.Instance.query("SELECT * FROM Domain WHERE IsDeleted = 0 AND UserId = ?", [ userId ]);
const domainList = new Array<Domain>();
for (const row of dbDomain) {
const domain = new Domain();
PopulateDomainFromDB(domain, row);
domainList.push(domain);
}
return domainList;
}
public static async SelectById(id: number) {
const dbMedia = await Database.Instance.query("SELECT * FROM Domain WHERE Id = ? LIMIT 1", [id]);
if (dbMedia == null || dbMedia.length === 0) {
const dbDomain = await Database.Instance.query("SELECT * FROM Domain WHERE Id = ? LIMIT 1", [id]);
if (dbDomain == null || dbDomain.length === 0) {
return null;
} else {
const media = new Domain();
PopulateDomainFromDB(media, dbMedia[0]);
return media;
const domain = new Domain();
PopulateDomainFromDB(domain, dbDomain[0]);
return domain;
}
}
public static async SelectByDomain(domain: string) {
const dbMedia = await Database.Instance.query("SELECT * FROM Domain WHERE Domain = ? AND IsDeleted = 0 LIMIT 1", [domain]);
if (dbMedia == null || dbMedia.length === 0) {
const dbDomain = await Database.Instance.query("SELECT * FROM Domain WHERE Domain = ? AND IsDeleted = 0 LIMIT 1", [domain]);
if (dbDomain == null || dbDomain.length === 0) {
return null;
} else {
const media = new Domain();
PopulateDomainFromDB(media, dbMedia[0]);
return media;
const domain = new Domain();
PopulateDomainFromDB(domain, dbDomain[0]);
return domain;
}
}

View file

@ -1,5 +1,6 @@
import Database from "../objects/Database";
import Media from "../entities/Media";
import MediaCount from "../entities/MediaCount";
export default abstract class MediaRepo {
public static async SelectAll() {
@ -72,6 +73,31 @@ export default abstract class MediaRepo {
return mediaList;
}
public static async SelectTotalMediaSizeByUserId(userId: number) {
const dbCount = await Database.Instance.query('SELECT SUM(FileSize) FROM Media WHERE IsDeleted = 0 AND UserId = ?', [ userId ]);
return dbCount[0]["SUM(FileSize)"];
}
public static async SelectTotalMediaByUserId(userId: number) {
const dbCount = await Database.Instance.query('SELECT COUNT(Id) FROM Media WHERE IsDeleted = 0 AND UserId = ?', [ userId ]);
return dbCount[0]["COUNT(Id)"];
}
public static async SelectMediaTypeCountsByUserId(userId: number) {
const dbMedia = await Database.Instance.query('SELECT MediaType AS "Type", COUNT(Id) AS "Count" FROM Media WHERE IsDeleted = 0 AND UserId = ? GROUP BY MediaType ORDER BY "Count"', [ userId ]);
const mediaCountList = new Array<MediaCount>();
for (const row of dbMedia) {
const mediaCount = new MediaCount();
PopulateMediaCountFromDB(mediaCount, row);
mediaCountList.push(mediaCount);
}
return mediaCountList;
}
public static async InsertUpdate(media: Media) {
if (media.Id === Number.MIN_VALUE) {
media.Id = (await Database.Instance.query("INSERT Media (UserId, DomainId, FileName, MediaTag, MediaType, Hash, FileSize, CreatedByUserId, CreatedDatetime, LastModifiedByUserId, LastModifiedDatetime, DeletedByUserId, DeletedDatetime, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING Id;", [
@ -103,4 +129,9 @@ function PopulateMediaFromDB(media: Media, dbMedia: any) {
media.DeletedByUserId = dbMedia.DeletedByUserId;
media.DeletedDatetime = dbMedia.DeletedDatetime;
media.IsDeleted = dbMedia.IsDeleted[0] === 1;
}
function PopulateMediaCountFromDB(mediaCount: MediaCount, dbMediaCount: any) {
mediaCount.Type = dbMediaCount.Type;
mediaCount.Count = dbMediaCount.Count;
}

View file

@ -134,4 +134,40 @@ export default abstract class UserService {
throw e;
}
}
public static async GetUserMediaCounts(currentUserId: number) {
try {
return await MediaRepo.SelectMediaTypeCountsByUserId(currentUserId);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetTotalMediaCount(currentUserId: number) {
try {
return await MediaRepo.SelectTotalMediaByUserId(currentUserId);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetTotalMediaSize(currentUserId: number) {
try {
return await MediaRepo.SelectTotalMediaSizeByUserId(currentUserId);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async GetDomains(currentUserId: number) {
try {
return await DomainRepo.SelectByUserId(currentUserId);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
}

View file

@ -0,0 +1,20 @@
const SPACE_VALUES = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
export default class FormattingUtility {
public static NumberHumanReadable(num: number) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// HSNOTE: Assumes bytes!
public static NumberAsFileSize(num: number) {
// Converts space values to lower values e.g MB, GB, TB etc depending on the size of the number
let i = 1;
// Loop through until value is at it's lowest
while (num >= 1024) {
num = num / 1024;
if (num >= 1024) i++;
}
return `${num.toFixed(2)} ${SPACE_VALUES[i]}`;
}
}

103
views/account/api.ejs Normal file
View file

@ -0,0 +1,103 @@
<%- include("../base/header", { title: "API Information", session }) %>
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item"><a href="/account/dashboard">Dashboard</a></li>
<li class="breadcrumb-item" aria-current="page">API</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col text-start">API Information</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col">
<h4>API Key</h4>
<p>
This is the key you need to utilise the EUS API. This can be used with the Authorization header as a Bearer token.
</p>
<div class="input-group mb-3">
<input id="akinput" type="password" class="form-control" readonly value="<%= apiKey %>">
<button id="akbtn" class="btn btn-success">
<span id="ak-reveal"><i class="bi bi-eye"></i> Reveal</span>
<span id="ak-hide" class="d-none"><i class="bi bi-eye-slash"></i> Hide</span>
</button>
</div>
<a class="btn btn-danger w-100"><i class="bi bi-arrow-repeat"></i> Request New API Key</a>
<br>
<span class="small">Only do this if you really need to, existing applications using this API Key will cease to function until it is replaced.</span>
</div>
<div class="col">
<h4>Upload Key</h4>
<p>
This is the key you need to upload files to EUS when using the /upload POST endpoint.<br>
Simply add a header to your request called "Upload-Key" containing this key.
</p>
<div class="input-group mb-3">
<input id="ukinput" type="password" class="form-control" readonly value="<%= uploadKey %>">
<button id="ukbtn" class="btn btn-success">
<span id="uk-reveal"><i class="bi bi-eye"></i> Reveal</span>
<span id="uk-hide" class="d-none"><i class="bi bi-eye-slash"></i> Hide</span>
</button>
</div>
<a class="btn btn-danger w-100"><i class="bi bi-arrow-repeat"></i> Request New Upload Key</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
const akinput = document.querySelector("#akinput");
const akbtn = document.querySelector("#akbtn");
const akReveal = document.querySelector("#ak-reveal");
const akHide = document.querySelector("#ak-hide");
const ukinput = document.querySelector("#ukinput");
const ukbtn = document.querySelector("#ukbtn");
const ukReveal = document.querySelector("#uk-reveal");
const ukHide = document.querySelector("#uk-hide");
akbtn.addEventListener("click", _ => {
akReveal.classList.toggle("d-none");
akHide.classList.toggle("d-none");
if (akReveal.classList.contains("d-none")) {
akbtn.classList.remove("btn-success");
akbtn.classList.add("btn-danger");
akinput.type = "text";
} else {
akbtn.classList.add("btn-success");
akbtn.classList.remove("btn-danger");
akinput.type = "password";
}
});
ukbtn.addEventListener("click", _ => {
ukReveal.classList.toggle("d-none");
ukHide.classList.toggle("d-none");
if (ukReveal.classList.contains("d-none")) {
ukbtn.classList.remove("btn-success");
ukbtn.classList.add("btn-danger");
ukinput.type = "text";
} else {
ukbtn.classList.add("btn-success");
ukbtn.classList.remove("btn-danger");
ukinput.type = "password";
}
});
</script>
<%- include("../base/footer") %>

View file

@ -1,4 +1,15 @@
<%- include("../base/header", { title: "Home", session }) %>
<%- include("../base/header", { title: "Account Dashboard", session }) %>
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item" aria-current="page">Dashboard</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<!-- Recent Uploads -->
@ -7,7 +18,7 @@
<div class="card-header">
<div class="row">
<div class="col text-start">Recent Uploads</div>
<div class="col text-end"><a aria-label="View All Uploads" href="/imagelist">View All >></a></div>
<div class="col text-end"><a aria-label="View All Uploads" href="/account/media">View All >></a></div>
</div>
</div>
<div class="card-body">
@ -18,6 +29,8 @@
<div class="col-auto">
<% if (upload.MediaType.startsWith("image/")) { %>
<img src="<%= domains[upload.DomainId].HasHttps ? "https" : "http" %>://<%= domains[upload.DomainId].Domain %>/<%= upload.MediaTag %>" height="30" width="50">
<% } else { %>
<i class="bi bi-file-earmark"></i>
<% } %>
</div>
<div class="col"><a href="<%= domains[upload.DomainId].HasHttps ? "https" : "http" %>://<%= domains[upload.DomainId].Domain %>/<%= upload.MediaTag %>" target="_blank"><%= upload.FileName %></a></div>
@ -30,9 +43,31 @@
</div>
<!-- Stats -->
<div class="col">
<div class="card mb-3">
<div class="card-header">Account</div>
<div class="card-body">
<div class="row">
<div class="col">
<a class="btn btn-primary" href="/account/information">Account Information</a>
<a class="btn btn-primary" href="/account/api">API</a>
<a class="btn btn-primary" href="/account/domains">Domains</a>
<a class="btn btn-primary" href="/account/media">Media</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">Stats</div>
<div class="card-body"></div>
<div class="card-body">
<p>Total Media: <%= FormattingUtility.NumberHumanReadable(mediaCount) %></p>
<p>Media By Type:</p>
<ol>
<% for (const mediaCount of mediaCounts) { %>
<li><b><%= mediaCount.Type %></b>: <%= FormattingUtility.NumberHumanReadable(mediaCount.Count) %></li>
<% } %>
</ol>
<p>Total size of Media: <%= FormattingUtility.NumberAsFileSize(mediaSize) %></p>
</div>
</div>
</div>
</div>

58
views/account/domains.ejs Normal file
View file

@ -0,0 +1,58 @@
<%- include("../base/header", { title: "Your Domains", session }) %>
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item"><a href="/account/dashboard">Dashboard</a></li>
<li class="breadcrumb-item" aria-current="page">Domains</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col text-start">Your Domains</div>
</div>
</div>
<div class="card-body">
<div class="row">
<div class="col">
<table class="table table-striped">
<thead>
<th>Domain Name</th>
<th>Has HTTPS</th>
<th>Active</th>
<th>&nbsp;</th>
</thead>
<tbody>
<% for (const domain of domains) { %>
<tr>
<td><%= domain.Domain %></td>
<td><%= domain.HasHttpsString %></td>
<td><%= domain.ActiveString %></td>
<td>
<a class="btn btn-success"><i class="bi bi-pencil-square"></i> Edit</a>
<a class="btn btn-danger"><i class="bi bi-trash"></i> Delete</a>
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
</script>
<%- include("../base/footer") %>

View file

@ -1,22 +0,0 @@
<%- include("../base/header", { title: "Home", session }) %>
<div class="row">
<!-- Recent Uploads -->
<div class="col">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col text-start">Recent Uploads</div>
<div class="col text-end"><a aria-label="Back to dashboard" href="/"><< Back to dashboard</a></div>
</div>
</div>
<div class="card-body">
<div class="row row-cols-1">
</div>
</div>
</div>
</div>
</div>
<%- include("../base/footer") %>

33
views/account/media.ejs Normal file
View file

@ -0,0 +1,33 @@
<%- include("../base/header", { title: "Your Media", session }) %>
<div class="row mb-3">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item"><a href="/account/dashboard">Dashboard</a></li>
<li class="breadcrumb-item" aria-current="page">Media</li>
</ol>
</nav>
</div>
</div>
<div class="row">
<!-- Recent Uploads -->
<div class="col">
<div class="card">
<div class="card-header">
<div class="row">
<div class="col text-start">Your Media</div>
</div>
</div>
<div class="card-body">
<div class="row row-cols-1">
</div>
</div>
</div>
</div>
</div>
<%- include("../base/footer") %>

View file

@ -29,7 +29,7 @@
<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">
<nav class="navbar navbar-expand bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/"><img src="/img/EUSIcon32xSlim.webp" alt="EUS"></a>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
@ -45,6 +45,8 @@
<div class="dropdown">
<button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Logged in as <%= session.username %></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/account/dashboard">Dashboard</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/account/pwchange">Change Password</a></li>
<li><a class="dropdown-item" href="/account/2fa">Enable 2FA</a></li>
<li><hr class="dropdown-divider"></li>
@ -54,11 +56,11 @@
</div>
<% } else { %>
<div class="nav-item float-end">
<a class="nav-link" href="/account/login">Sign In</a>
<a class="nav-link" href="/account/login">Login</a>
</div>
<% } %>
</ul>
</div>
</div>
</nav>
<div class="container pt-5">
<div class="container pt-3">

View file

@ -1,5 +1,43 @@
<%- include("../base/header", { title: "Home", session }) %>
<div class="row">
<div class="col">
<h1>EUS</h1>
<h3>EUS is an approval based server for hosting media and screenshots.</h3>
<div class="row mt-3">
<div class="col">
<a class="btn btn-lg btn-primary me-2" href="mailto:admin+eusaccess@eusv.net">Request Access</a>
<a class="btn btn-lg btn-secondary" href="/account/login">Login</a>
</div>
</div>
<div id="carousel" class="carousel slide border mt-3">
<div class="carousel-indicators">
<button type="button" data-bs-target="#carousel" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
<button type="button" data-bs-target="#carousel" data-bs-slide-to="1" aria-label="Slide 2"></button>
<button type="button" data-bs-target="#carousel" data-bs-slide-to="2" aria-label="Slide 3"></button>
</div>
<div class="carousel-inner">
<div class="carousel-item active">
<img src="/img/scroller-1.png" class="d-block w-100" alt="Account Dashboard">
</div>
<div class="carousel-item">
<img src="/img/scroller-2.png" class="d-block w-100" alt="API Key Management">
</div>
<div class="carousel-item">
<img src="/img/scroller-1.png" class="d-block w-100" alt="Account Dashboard">
</div>
</div>
<button class="carousel-control-prev" type="button" data-bs-target="#carousel" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carousel" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
</button>
</div>
</div>
</div>
<%- include("../base/footer") %>

BIN
wwwroot/img/scroller-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
wwwroot/img/scroller-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB