Add paging concept and controls, add media list, allow regeneration of keys.

This commit is contained in:
Holly Stubbs 2025-03-29 19:48:05 +00:00
parent 81afe50526
commit 02d1663c5d
Signed by: tgpholly
GPG key ID: B8583C4B7D18119E
15 changed files with 290 additions and 13 deletions

View file

@ -9,6 +9,8 @@ import UserService from "../services/UserService";
import ArrayUtility from "../utilities/ArrayUtility";
import Controller from "./Controller";
import type DomainsViewModel from "../models/account/DomainsViewModel";
import type MediaViewModel from "../models/account/MediaViewModel";
import type MediaGetParameters from "../models/account/MediaGetParameters";
export default class AccountController extends Controller {
public async Login_Get_AllowAnonymous() {
@ -114,8 +116,20 @@ export default class AccountController extends Controller {
return this.view(domainsViewModel);
}
public async Media_Get() {
return this.view();
public async Media_Get(mediaGetParameters: MediaGetParameters) {
let pageNumber = parseInt(mediaGetParameters.pageNumber);
if (isNaN(pageNumber)) {
pageNumber = 0;
}
const mediaViewModel: MediaViewModel = {
media: await UserService.GetMediaListPaged(this.session.userId, pageNumber),
domains: ArrayUtility.ToIdKeyedDict(await DomainService.LoadDomains()),
pageNumber
};
return this.view(mediaViewModel);
}
public async API_Get() {
@ -132,6 +146,27 @@ export default class AccountController extends Controller {
return this.view(apiViewModel);
}
public async Information_Get() {
const user = await UserService.GetUser(this.session.userId);
if (!user) {
return this.forbidden();
}
return this.view();
}
public async NewAPIKey_Get() {
await UserService.ResetAPIKey(this.session.userId);
return this.redirectToAction("api");
}
public async NewUploadKey_Get() {
await UserService.ResetUploadKey(this.session.userId);
return this.redirectToAction("api");
}
public async Logout_Get_AllowAnonymous() {
Session.Clear(this.req.cookies, this.res);

View file

@ -0,0 +1,10 @@
export default class MediaListItem {
public Id: number = Number.MIN_VALUE;
public FileName: string = "";
public MediaTag: string = "";
public MediaType: string = "";
public DomainId: number = Number.MIN_VALUE;
public DomainName: string = "";
public DomainHasHttps: boolean = false;
public CreatedDatetime: Date = new Date(0);
}

View file

@ -81,7 +81,7 @@ fastify.addHook("preHandler", (req, res, done) => {
req.startTime = Date.now();
// * Take usual controller path if this path is registered.
if (Controller.RegisteredPaths.includes(req.url)) {
if (Controller.RegisteredPaths.includes(req.url.split("?")[0])) {
// @ts-ignore
req.logType = cyan("CONTROLLER");
HeaderUtility.AddBakedHeaders(res);

View file

@ -0,0 +1,3 @@
export default interface MediaGetParameters {
pageNumber: string
}

View file

@ -0,0 +1,10 @@
import type { Domain } from "domain";
import type MediaListItem from "../../entities/listitems/MediaListItem";
import type Paged from "../../objects/Paged";
export default interface MediaViewModel {
media: Paged<MediaListItem>,
domains: { [key: string]: Domain },
pageNumber: number
}

13
objects/Paged.ts Normal file
View file

@ -0,0 +1,13 @@
export default class Paged<T> {
public Data: Array<T>;
public readonly TotalRecords: number;
public readonly PageSize: number;
public readonly PageCount: number;
public constructor(totalRecords: number, pageSize: number) {
this.Data = new Array<T>();
this.TotalRecords = totalRecords;
this.PageSize = pageSize;
this.PageCount = Math.max(Math.ceil(totalRecords / pageSize), 1);
}
}

View file

@ -1,6 +1,8 @@
import Database from "../objects/Database";
import Media from "../entities/Media";
import MediaCount from "../entities/MediaCount";
import Paged from "../objects/Paged";
import MediaListItem from "../entities/listitems/MediaListItem";
export default abstract class MediaRepo {
public static async SelectAll() {
@ -82,7 +84,7 @@ export default abstract class MediaRepo {
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)"];
return dbCount[0]["COUNT(Id)"] ?? 0;
}
public static async SelectMediaTypeCountsByUserId(userId: number) {
@ -98,6 +100,20 @@ export default abstract class MediaRepo {
return mediaCountList;
}
public static async SelectUserMediaListPaged(userId: number, pageNumber: number, pageSize: number) {
const totalRecords = (await Database.Instance.query("SELECT COUNT(Id) FROM Media WHERE IsDeleted = 0 AND UserId = ?", [ userId ]))[0]["COUNT(Id)"] ?? 0;
const mediaListPaged = new Paged<MediaListItem>(totalRecords, pageSize);
const pageRecords = await Database.Instance.query('SELECT Media.Id, FileName, MediaTag, MediaType, Domain.Id AS "DomainId", Domain.Domain AS "DomainName", Domain.HasHttps AS "DomainHasHttps", Media.CreatedDatetime FROM Media JOIN Domain ON Domain.Id = DomainId WHERE Media.IsDeleted = 0 AND Media.UserId = ? ORDER BY Media.CreatedDatetime DESC LIMIT ? OFFSET ?', [ userId, pageSize, pageNumber * pageSize ]);
for (const row of pageRecords) {
const mediaListItem = new MediaListItem();
PopulateMediaListItemFromDB(mediaListItem, row);
mediaListPaged.Data.push(mediaListItem);
}
return mediaListPaged;
}
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;", [
@ -113,6 +129,17 @@ export default abstract class MediaRepo {
}
}
function PopulateMediaListItemFromDB(mediaListItem: MediaListItem, dbMediaListItem: any) {
mediaListItem.Id = dbMediaListItem.Id;
mediaListItem.FileName = dbMediaListItem.FileName;
mediaListItem.MediaTag = dbMediaListItem.MediaTag;
mediaListItem.MediaType = dbMediaListItem.MediaType;
mediaListItem.DomainId = dbMediaListItem.DomainId;
mediaListItem.DomainName = dbMediaListItem.DomainName;
mediaListItem.DomainHasHttps = dbMediaListItem.DomainHasHttps[0] === 1;
mediaListItem.CreatedDatetime = dbMediaListItem.CreatedDatetime;
}
function PopulateMediaFromDB(media: Media, dbMedia: any) {
media.Id = dbMedia.Id;
media.UserId = dbMedia.UserId;

View file

@ -170,4 +170,51 @@ export default abstract class UserService {
throw e;
}
}
public static async GetMediaListPaged(currentUserId: number, pageNumber: number) {
try {
return await MediaRepo.SelectUserMediaListPaged(currentUserId, pageNumber, 50);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async ResetAPIKey(currentUserId: number) {
try {
const user = await UserRepo.SelectById(currentUserId);
if (!user) {
return null;
}
user.ApiKey = randomBytes(64).toString("base64url");
user.LastModifiedByUserId = currentUserId;
user.LastModifiedDatetime = new Date();
return await UserRepo.InsertUpdate(user);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
public static async ResetUploadKey(currentUserId: number) {
try {
const user = await UserRepo.SelectById(currentUserId);
if (!user) {
return null;
}
user.UploadKey = randomBytes(64).toString("base64url");
user.LastModifiedByUserId = currentUserId;
user.LastModifiedDatetime = new Date();
return await UserRepo.InsertUpdate(user);
} catch (e) {
Console.printError(`EUS server service error:\n${e}`);
throw e;
}
}
}

View file

@ -34,9 +34,7 @@
<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>
<a class="btn btn-danger w-100" href="/account/newapikey" data-confirm="Are you sure you want to request a new API Key?\nAny applications that are using this key will cease to work until the key is replaced."><i class="bi bi-arrow-repeat"></i> Request New API Key</a>
</div>
<div class="col mt-3 mt-md-0">
<h4>Upload Key</h4>
@ -51,7 +49,7 @@
<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>
<a class="btn btn-danger w-100" href="/account/newuploadkey" data-confirm="Are you sure you want to request a new Upload Key?\nAny applications, e.g. ShareX, that are using this key will cease to work until the key is replaced."><i class="bi bi-arrow-repeat"></i> Request New Upload Key</a>
</div>
</div>
</div>

View file

@ -48,9 +48,11 @@
<div class="card-body">
<div class="row">
<div class="col">
<a class="btn btn-primary my-1" href="/account/information">Account Information</a>
<!-- <a class="btn btn-primary my-1" href="/account/information">Account Information</a> -->
<a class="btn btn-primary my-1" href="/account/api">API</a>
<% if (session.userType === UserType.Admin) { %>
<a class="btn btn-primary my-1" href="/account/domains">Domains</a>
<% } %>
<a class="btn btn-primary my-1" href="/account/media">Media</a>
</div>
</div>
@ -59,6 +61,9 @@
<div class="card">
<div class="card-header">Stats</div>
<div class="card-body">
<% if (mediaCount === 0) { %>
<p>No stats to show.</p>
<% } else { %>
<p>Total Media: <%= FormattingUtility.NumberHumanReadable(mediaCount) %></p>
<p>Media By Type:</p>
<ol>
@ -67,6 +72,7 @@
<% } %>
</ol>
<p>Total size of Media: <%= FormattingUtility.NumberAsFileSize(mediaSize) %></p>
<% } %>
</div>
</div>
</div>

View file

@ -0,0 +1,34 @@
<%- 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">Account Information</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">Account Information</div>
</div>
</div>
<div class="card-body">
</div>
</div>
</div>
</div>
<script type="text/javascript">
</script>
<%- include("../base/footer") %>

View file

@ -19,11 +19,53 @@
<div class="card-header">
<div class="row">
<div class="col text-start">Your Media</div>
<div class="col text-end">Media: <%= FormattingUtility.NumberHumanReadable(media.TotalRecords) %></div>
</div>
</div>
<div class="card-body">
<div class="card-body pb-0">
<div class="row row-cols-1">
<div class="col mb-3">
<div class="d-none d-md-block">
<div class="row fw-bold border-bottom pb-2">
<div class="col-auto"><img style="visibility: hidden" height="30" width="50"></div>
<div class="col-3">File Name</div>
<div class="col-3">Media Tag</div>
<div class="col-4">Upload Date/Time</div>
<div class="col">&nbsp;</div>
</div>
</div>
</div>
<% for (const mediaItem of media.Data) { %>
<div class="col mb-3 border-bottom pb-3">
<div class="row">
<div class="col-12 col-md-auto">
<div class="row p-0 m-0">
<div class="col p-0 m-0">
<% if (mediaItem.MediaType.startsWith("image/")) { %>
<img style="cursor: zoom-in" src="<%= domains[mediaItem.DomainId].HasHttps ? "https" : "http" %>://<%= domains[mediaItem.DomainId].Domain %>/<%= mediaItem.MediaTag %>" height="30" width="50">
<% } else { %>
<i class="bi bi-file-earmark"></i>
<% } %>
</div>
<!-- <div class="d-md-none col-auto">
<a class="btn btn-danger d-inline d-md-none" aria-label="Delete <%= mediaItem.FileName %>" href="/account/deletemedia?id=<%= mediaItem.Id %>"><i class="bi bi-trash"></i></a>
</div> -->
</div>
</div>
<div class="col-12 col-md-3 pt-3 pt-md-0"><span class="d-inline d-md-none fw-bold">File Name: </span><%= mediaItem.FileName %></div>
<div class="col-12 col-md-3"><span class="d-inline d-md-none fw-bold">Media Tag: </span><%= mediaItem.MediaTag %></div>
<div class="col-12 col-md-4">
<span class="d-inline d-md-none fw-bold">Upload Date/Time: </span>
<%= mediaItem.CreatedDatetime.toString().split(" ").slice(1, 5).join(" ") %><br class="d-none d-md-block">
<%= mediaItem.CreatedDatetime.toString().split(" ").slice(-4, -3).join(" ") %>
</div>
<!-- <div class="col-12 col-md text-end d-none d-md-inline">
<a class="btn btn-danger" aria-label="Delete <%= mediaItem.FileName %>" href="/account/deletemedia?id=<%= mediaItem.Id %>"><i class="bi bi-trash"></i></a>
</div> -->
</div>
</div>
<% } %>
<%- include("../base/paging", { pageCount: media.PageCount, pageNumber: pageNumber }) %>
</div>
</div>
</div>

View file

@ -14,6 +14,23 @@
form.classList.add('was-validated');
}, false);
});
const allButtons = document.querySelectorAll("a, button, input");
Array.from(allButtons).forEach(button => {
if ("confirm" in button.dataset) {
button.addEventListener("click", e => {
const result = confirm(button.dataset["confirm"].replaceAll("\\n", "\n"));
if (result) {
return true;
} else {
e.preventDefault();
return false;
}
return false;
});
}
});
})();
window.cookieconsent.initialise({

View file

@ -46,9 +46,9 @@
<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><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><a class="dropdown-item" href="/account/2fa">Enable 2FA</a></li> -->
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/account/logout">Logout</a></li>
</ul>

35
views/base/paging.ejs Normal file
View file

@ -0,0 +1,35 @@
<nav>
<ul class="pagination">
<li class="page-item <%= (pageNumber === 0 ? "disabled" : "") %>">
<% if (pageNumber === 0) { %>
<a class="page-link" aria-label="First Page"><i class="bi bi-chevron-double-left"></i></a>
<% } else { %>
<a class="page-link" aria-label="First Page" href="?pageNumber=0"><i class="bi bi-chevron-double-left"></i></a>
<% } %>
</li>
<li class="page-item <%= (pageNumber === 0 ? "disabled" : "") %>">
<% if (pageNumber === 0) { %>
<a class="page-link" aria-label="Previous Page"><i class="bi bi-chevron-left"></i></a>
<% } else { %>
<a class="page-link" aria-label="Previous Page" href="?pageNumber=<%= (pageNumber - 1) %>"><i class="bi bi-chevron-left"></i></a>
<% } %>
</li>
<% for (let i = Math.max(pageNumber - (2 + (2 - Math.min(pageCount - 1 - pageNumber, 2))), 0); i < Math.min(pageNumber + Math.max(5 - pageNumber, 3), pageCount); i++) { %>
<li class="page-item <%= (i === pageNumber ? "active" : "") %>"><a class="page-link" aria-label="Go to page <%= (i + 1) %>" href="?pageNumber=<%= i %>"><%= (i + 1) %></a></li>
<% } %>
<li class="page-item <%= (pageNumber >= pageCount - 1 ? "disabled" : "") %>">
<% if (pageNumber >= pageCount - 1) { %>
<a class="page-link" aria-label="Next Page"><i class="bi bi-chevron-right"></i></a>
<% } else { %>
<a class="page-link" aria-label="Next Page" href="?pageNumber=<%= (pageNumber + 1) %>"><i class="bi bi-chevron-right"></i></a>
<% } %>
</li>
<li class="page-item <%= (pageNumber >= pageCount - 1 ? "disabled" : "") %>">
<% if (pageNumber >= pageCount - 1) { %>
<a class="page-link" aria-label="Last Page"><i class="bi bi-chevron-double-right"></i></a>
<% } else { %>
<a class="page-link" aria-label="Last Page" href="?pageNumber=<%= (pageCount - 1) %>"><i class="bi bi-chevron-double-right"></i></a>
<% } %>
</li>
</ul>
</nav>