diff --git a/controllers/AccountController.ts b/controllers/AccountController.ts index 8334737..0c6849d 100644 --- a/controllers/AccountController.ts +++ b/controllers/AccountController.ts @@ -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); diff --git a/entities/listitems/MediaListItem.ts b/entities/listitems/MediaListItem.ts new file mode 100644 index 0000000..d101727 --- /dev/null +++ b/entities/listitems/MediaListItem.ts @@ -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); +} \ No newline at end of file diff --git a/index.ts b/index.ts index d9a3bb1..f399ea3 100644 --- a/index.ts +++ b/index.ts @@ -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); diff --git a/models/account/MediaGetParameters.ts b/models/account/MediaGetParameters.ts new file mode 100644 index 0000000..d8ad0d2 --- /dev/null +++ b/models/account/MediaGetParameters.ts @@ -0,0 +1,3 @@ +export default interface MediaGetParameters { + pageNumber: string +} \ No newline at end of file diff --git a/models/account/MediaViewModel.ts b/models/account/MediaViewModel.ts new file mode 100644 index 0000000..f33e456 --- /dev/null +++ b/models/account/MediaViewModel.ts @@ -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 +} \ No newline at end of file diff --git a/objects/Paged.ts b/objects/Paged.ts new file mode 100644 index 0000000..45cc281 --- /dev/null +++ b/objects/Paged.ts @@ -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); + } +} \ No newline at end of file diff --git a/repos/MediaRepo.ts b/repos/MediaRepo.ts index 0431e03..0f53c2c 100644 --- a/repos/MediaRepo.ts +++ b/repos/MediaRepo.ts @@ -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; diff --git a/services/UserService.ts b/services/UserService.ts index 62c123c..ccadbfe 100644 --- a/services/UserService.ts +++ b/services/UserService.ts @@ -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; + } + } } \ No newline at end of file diff --git a/views/account/api.ejs b/views/account/api.ejs index 7cae18d..0fe43fe 100644 --- a/views/account/api.ejs +++ b/views/account/api.ejs @@ -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> diff --git a/views/account/dashboard.ejs b/views/account/dashboard.ejs index ee10e5c..c679375 100644 --- a/views/account/dashboard.ejs +++ b/views/account/dashboard.ejs @@ -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> diff --git a/views/account/information.ejs b/views/account/information.ejs new file mode 100644 index 0000000..93b3087 --- /dev/null +++ b/views/account/information.ejs @@ -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") %> diff --git a/views/account/media.ejs b/views/account/media.ejs index fabe160..b88ad15 100644 --- a/views/account/media.ejs +++ b/views/account/media.ejs @@ -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"> </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> diff --git a/views/base/footer.ejs b/views/base/footer.ejs index 794bf3a..5f74d44 100644 --- a/views/base/footer.ejs +++ b/views/base/footer.ejs @@ -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({ diff --git a/views/base/header.ejs b/views/base/header.ejs index 9068d76..2ff9e17 100644 --- a/views/base/header.ejs +++ b/views/base/header.ejs @@ -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> diff --git a/views/base/paging.ejs b/views/base/paging.ejs new file mode 100644 index 0000000..13f151b --- /dev/null +++ b/views/base/paging.ejs @@ -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> \ No newline at end of file