From f3fe5e9671cef64da67b39881feac33ad66fa9b4 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 24 Aug 2023 15:37:25 -0400 Subject: [PATCH] Added admin page and more authentication improvements with latest handy-httpd version. --- litelist-api/dub.json | 9 +- litelist-api/dub.selections.json | 6 +- litelist-api/source/app.d | 51 +++++++---- litelist-api/source/auth.d | 85 ++++++++++--------- litelist-api/source/data/impl/list.d | 5 ++ litelist-api/source/data/impl/note.d | 35 +++++--- litelist-api/source/data/impl/user.d | 21 +++-- litelist-api/source/data/list.d | 1 + litelist-api/source/data/model.d | 1 + litelist-api/source/data/note.d | 2 + litelist-api/source/endpoints/admin.d | 33 +++++++ litelist-api/source/endpoints/auth.d | 32 +++++-- litelist-api/source/endpoints/lists.d | 18 ++-- litelist-app/src/api/admin.ts | 19 +++++ litelist-app/src/api/auth.ts | 3 +- litelist-app/src/components/PageContainer.vue | 18 +++- litelist-app/src/router/index.ts | 12 +++ litelist-app/src/stores/auth.ts | 2 +- litelist-app/src/views/AdminView.vue | 49 +++++++++++ litelist-app/src/views/SingleListView.vue | 2 +- 20 files changed, 297 insertions(+), 107 deletions(-) create mode 100644 litelist-api/source/endpoints/admin.d create mode 100644 litelist-app/src/api/admin.ts create mode 100644 litelist-app/src/views/AdminView.vue diff --git a/litelist-api/dub.json b/litelist-api/dub.json index 500f397..41adfc7 100644 --- a/litelist-api/dub.json +++ b/litelist-api/dub.json @@ -5,17 +5,18 @@ "copyright": "Copyright © 2023, Andrew Lalis", "dependencies": { "botan": "~>1.13.5", - "d-properties": "~>1.0.4", + "d-properties": "~>1.0.5", "d2sqlite3": "~>1.0.0", - "handy-httpd": "~>7.9.3", + "handy-httpd": "~>7.10.4", "jwt": "~>0.4.0", "resusage": "~>0.3.2", - "slf4d": "~>2.4.2" + "slf4d": "~>2.4.3" }, "description": "API for the litelist application.", "license": "MIT", "name": "litelist-api", "subConfigurations": { "d2sqlite3": "all-included" - } + }, + "buildRequirements": ["allowWarnings"] } \ No newline at end of file diff --git a/litelist-api/dub.selections.json b/litelist-api/dub.selections.json index 46ed182..22c3754 100644 --- a/litelist-api/dub.selections.json +++ b/litelist-api/dub.selections.json @@ -3,14 +3,14 @@ "versions": { "botan": "1.13.5", "botan-math": "1.0.4", - "d-properties": "1.0.4", + "d-properties": "1.0.5", "d2sqlite3": "1.0.0", - "handy-httpd": "7.9.3", + "handy-httpd": "7.10.4", "httparsed": "1.2.1", "jwt": "0.4.0", "memutils": "1.0.9", "resusage": "0.3.2", - "slf4d": "2.4.2", + "slf4d": "2.4.3", "streams": "3.5.0" } } diff --git a/litelist-api/source/app.d b/litelist-api/source/app.d index 158bdc0..1887416 100644 --- a/litelist-api/source/app.d +++ b/litelist-api/source/app.d @@ -4,7 +4,7 @@ import slf4d.default_provider; void main() { auto provider = new shared DefaultProvider(true, Levels.INFO); - // provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN); + // provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.DEBUG); configureLoggingProvider(provider); HttpServer server = initServer(); @@ -21,9 +21,12 @@ private HttpServer initServer() { import d_properties; import endpoints.auth; import endpoints.lists; + import endpoints.admin; import std.file; import std.conv; + import auth : TokenFilter, AdminFilter, loadTokenSecret; + ServerConfig config = ServerConfig.defaultValues(); config.enableWebSockets = false; config.workerPoolSize = 3; @@ -56,28 +59,42 @@ private HttpServer initServer() { immutable string API_PATH = "/api"; - auto mainHandler = new PathDelegatingHandler(); + PathDelegatingHandler mainHandler = new PathDelegatingHandler(); mainHandler.addMapping(Method.GET, API_PATH ~ "/status", &handleStatus); - - auto optionsHandler = toHandler((ref HttpRequestContext ctx) { - ctx.response.setStatus(HttpStatus.OK); - }); - mainHandler.addMapping(Method.POST, API_PATH ~ "/register", &createNewUser); mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin); - mainHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); - mainHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser); - mainHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken); - - mainHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists); - mainHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList); - mainHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList); - mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList); - mainHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote); - mainHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote); + // mainHandler.addMapping(Method.GET, API_PATH ~ "/shutdown", (ref HttpRequestContext ctx) { + // ctx.response.writeBodyString("Shutting down!"); + // ctx.server.stop(); + // }); + HttpRequestHandler optionsHandler = toHandler((ref HttpRequestContext ctx) { + ctx.response.setStatus(HttpStatus.OK); + }); mainHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", optionsHandler); + // Separate handler for authenticated paths, protected by a TokenFilter. + PathDelegatingHandler authHandler = new PathDelegatingHandler(); + authHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); + authHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser); + authHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken); + + authHandler.addMapping(Method.GET, API_PATH ~ "/lists", &getNoteLists); + authHandler.addMapping(Method.POST, API_PATH ~ "/lists", &createNoteList); + authHandler.addMapping(Method.GET, API_PATH ~ "/lists/{id}", &getNoteList); + authHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{id}", &deleteNoteList); + authHandler.addMapping(Method.POST, API_PATH ~ "/lists/{listId}/notes", &createNote); + authHandler.addMapping(Method.DELETE, API_PATH ~ "/lists/{listId}/notes/{noteId}", &deleteNote); + HttpRequestFilter tokenFilter = new TokenFilter(loadTokenSecret()); + HttpRequestFilter adminFilter = new AdminFilter(); + + // Separate handler for admin paths, protected by an AdminFilter. + PathDelegatingHandler adminHandler = new PathDelegatingHandler(); + adminHandler.addMapping(Method.GET, API_PATH ~ "/admin/users", &getAllUsers); + mainHandler.addMapping(API_PATH ~ "/admin/**", new FilteredRequestHandler(adminHandler, [tokenFilter, adminFilter])); + + mainHandler.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(authHandler, [tokenFilter])); + return new HttpServer(mainHandler, config); } diff --git a/litelist-api/source/auth.d b/litelist-api/source/auth.d index 81de303..d3a0a0b 100644 --- a/litelist-api/source/auth.d +++ b/litelist-api/source/auth.d @@ -7,8 +7,12 @@ import handy_httpd; import handy_httpd.handlers.filtered_handler; import slf4d; +import std.typecons; + import data.user; +immutable string AUTH_METADATA_KEY = "AuthContext"; + /** * Generates a new access token for an authenticated user. * Params: @@ -25,7 +29,7 @@ string generateToken(in User user, in string secret) { token.claims.sub(user.username); token.claims.exp(Clock.currTime.toUnixTime() + 5000); token.claims.iss("litelist-api"); - return token.encode("supersecret");// TODO: Extract secret. + return token.encode(secret); } void sendUnauthenticatedResponse(ref HttpResponse resp) { @@ -46,39 +50,14 @@ string loadTokenSecret() { return "supersecret"; } -struct AuthContext { +class AuthContext { string token; User user; -} -class AuthContextHolder { - private static AuthContextHolder instance; - - static getInstance() { - if (!instance) instance = new AuthContextHolder(); - return instance; + this(string token, User user) { + this.token = token; + this.user = user; } - - static reset() { - auto i = getInstance(); - i.authenticated = false; - i.context = AuthContext.init; - } - - static setContext(string token, User user) { - auto i = getInstance(); - i.authenticated = true; - i.context = AuthContext(token, user); - } - - static AuthContext getOrThrow() { - auto i = getInstance(); - if (!i.authenticated) throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "No authentication context."); - return i.context; - } - - private bool authenticated; - private AuthContext context; } /** @@ -88,46 +67,46 @@ class AuthContextHolder { * Params: * ctx = The request context to validate. * secret = The secret key that should have been used to sign the token. - * Returns: True if the user is authenticated, or false otherwise. + * Returns: The AuthContext if authentication is successful, or null otherwise. */ -bool validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) { +Nullable!AuthContext validateAuthenticatedRequest(ref HttpRequestContext ctx, in string secret) { import jwt.jwt : verify, Token; import jwt.algorithms : JWTAlgorithm; import std.typecons; immutable HEADER_NAME = "Authorization"; - AuthContextHolder.reset(); if (!ctx.request.hasHeader(HEADER_NAME)) { ctx.response.setStatus(HttpStatus.UNAUTHORIZED); ctx.response.writeBodyString("Missing Authorization header."); - return false; + return Nullable!AuthContext.init; } string authHeader = ctx.request.getHeader(HEADER_NAME); if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") { ctx.response.setStatus(HttpStatus.UNAUTHORIZED); ctx.response.writeBodyString("Invalid bearer token authorization header."); - return false; + return Nullable!AuthContext.init; } string rawToken = authHeader[7 .. $]; string username; try { - Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]); + Token token = verify(rawToken, secret, [JWTAlgorithm.HS512]); username = token.claims.sub; } catch (Exception e) { warn("Failed to verify user token.", e); - throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid token."); + ctx.response.setStatus(HttpStatus.UNAUTHORIZED); + ctx.response.writeBodyString("Invalid or malformed token."); + return Nullable!AuthContext.init; } Nullable!User user = userDataSource.getUser(username); if (user.isNull) { ctx.response.setStatus(HttpStatus.UNAUTHORIZED); ctx.response.writeBodyString("User does not exist."); - return false; + return Nullable!AuthContext.init; } - AuthContextHolder.setContext(rawToken, user.get); - return true; + return nullable(new AuthContext(rawToken, user.get)); } class TokenFilter : HttpRequestFilter { @@ -138,6 +117,30 @@ class TokenFilter : HttpRequestFilter { } void apply(ref HttpRequestContext ctx, FilterChain filterChain) { - if (validateAuthenticatedRequest(ctx, this.secret)) filterChain.doFilter(ctx); + Nullable!AuthContext optionalAuth = validateAuthenticatedRequest(ctx, this.secret); + if (!optionalAuth.isNull) { + ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.get(); + filterChain.doFilter(ctx); // Only continue the filter chain if we're authenticated. + } } } + +class AdminFilter : HttpRequestFilter { + void apply(ref HttpRequestContext ctx, FilterChain filterChain) { + AuthContext authCtx = getAuthContextOrThrow(ctx); + if (authCtx.user.admin) { + filterChain.doFilter(ctx); + } else { + ctx.response.setStatus(HttpStatus.FORBIDDEN); + } + } +} + +AuthContext getAuthContextOrThrow(ref HttpRequestContext ctx) { + if (AUTH_METADATA_KEY in ctx.metadata) { + if (auto authCtx = cast(AuthContext) ctx.metadata[AUTH_METADATA_KEY]) { + return authCtx; + } + } + throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated."); +} diff --git a/litelist-api/source/data/impl/list.d b/litelist-api/source/data/impl/list.d index a6ad496..3b4da46 100644 --- a/litelist-api/source/data/impl/list.d +++ b/litelist-api/source/data/impl/list.d @@ -102,4 +102,9 @@ class SqliteNoteListDataSource : NoteListDataSource { db.commit(); return NoteList(id, newData.name, newData.ordinality, newData.description, []); } + + ulong countLists(string username) { + Database db = getDb(username); + return db.execute("SELECT COUNT(id) FROM note_list").oneValue!ulong(); + } } \ No newline at end of file diff --git a/litelist-api/source/data/impl/note.d b/litelist-api/source/data/impl/note.d index 255f8e1..f656392 100644 --- a/litelist-api/source/data/impl/note.d +++ b/litelist-api/source/data/impl/note.d @@ -41,16 +41,18 @@ class SqliteNoteDataSource : NoteDataSource { if (newData.ordinality > note.ordinality) { // Decrement all notes between the old index and the new one. db.execute( - "UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?", + "UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ? AND note_list_id = ?", note.ordinality, - newData.ordinality + newData.ordinality, + note.noteListId ); } else { // Increment all notes between the old index and the new one. db.execute( - "UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?", + "UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ? AND note_list_id = ?", newData.ordinality, - note.ordinality + note.ordinality, + note.noteListId ); } } @@ -66,14 +68,27 @@ class SqliteNoteDataSource : NoteDataSource { void deleteNote(string username, ulong id) { Database db = getDb(username); + ResultRange result = db.execute("SELECT * FROM note WHERE id = ?", id); + if (result.empty) return; + Note note = parseNote(result.front); db.begin(); - Nullable!uint ordinality = db.execute( - "SELECT ordinality FROM note WHERE id = ?", id - ).oneValue!(Nullable!uint)(); db.execute("DELETE FROM note WHERE id = ?", id); - if (!ordinality.isNull) { - db.execute("UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get); - } + db.execute( + "UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND note_list_id = ?", + note.ordinality, + note.noteListId + ); db.commit(); } + + ulong countNotes(string username) { + return getDb(username) + .execute("SELECT COUNT(id) FROM note") + .oneValue!ulong(); + } + ulong countNotes(string username, ulong noteListId) { + return getDb(username) + .execute("SELECT COUNT(id) FROM note WHERE note_list_id = ?", noteListId) + .oneValue!ulong(); + } } \ No newline at end of file diff --git a/litelist-api/source/data/impl/user.d b/litelist-api/source/data/impl/user.d index ce64982..d3e1dea 100644 --- a/litelist-api/source/data/impl/user.d +++ b/litelist-api/source/data/impl/user.d @@ -18,9 +18,9 @@ class FileSystemUserDataSource : UserDataSource { mkdirRecurse(dirPath); string dataPath = buildPath(dirPath, DATA_FILE); JSONValue userObj = JSONValue(string[string].init); - userObj.object["username"] = username; - userObj.object["email"] = email; - userObj.object["passwordHash"] = passwordHash; + userObj.object["email"] = JSONValue(email); + userObj.object["passwordHash"] = JSONValue(passwordHash); + userObj.object["admin"] = JSONValue(false); std.file.write(dataPath, userObj.toPrettyString()); return User(username, email, passwordHash); } @@ -35,11 +35,16 @@ class FileSystemUserDataSource : UserDataSource { string dataPath = buildPath(USERS_DIR, username, DATA_FILE); if (exists(dataPath) && isFile(dataPath)) { JSONValue userObj = parseJSON(strip(readText(dataPath))); - return nullable(User( - userObj.object["username"].str, - userObj.object["email"].str, - userObj.object["passwordHash"].str - )); + string email = userObj.object["email"].str; + string passwordHash = userObj.object["passwordHash"].str; + bool admin = false; + if ("admin" !in userObj.object) { + userObj.object["admin"] = JSONValue(false); + std.file.write(dataPath, userObj.toPrettyString()); + } else { + admin = userObj.object["admin"].boolean; + } + return nullable(User(username, email, passwordHash, admin)); } return Nullable!User.init; } diff --git a/litelist-api/source/data/list.d b/litelist-api/source/data/list.d index 6444656..3b33791 100644 --- a/litelist-api/source/data/list.d +++ b/litelist-api/source/data/list.d @@ -10,6 +10,7 @@ interface NoteListDataSource { NoteList createNoteList(string username, string name, string description = null); void deleteNoteList(string username, ulong id); NoteList updateNoteList(string username, ulong id, NoteList newData); + ulong countLists(string username); } static NoteListDataSource noteListDataSource; diff --git a/litelist-api/source/data/model.d b/litelist-api/source/data/model.d index 7ab2b02..efd4f4b 100644 --- a/litelist-api/source/data/model.d +++ b/litelist-api/source/data/model.d @@ -4,6 +4,7 @@ struct User { string username; string email; string passwordHash; + bool admin; } struct NoteList { diff --git a/litelist-api/source/data/note.d b/litelist-api/source/data/note.d index 0a24859..85412da 100644 --- a/litelist-api/source/data/note.d +++ b/litelist-api/source/data/note.d @@ -6,6 +6,8 @@ interface NoteDataSource { Note createNote(string username, ulong noteListId, string content); Note updateNote(string username, ulong id, Note newData); void deleteNote(string username, ulong id); + ulong countNotes(string username); + ulong countNotes(string username, ulong noteListId); } static NoteDataSource noteDataSource; diff --git a/litelist-api/source/endpoints/admin.d b/litelist-api/source/endpoints/admin.d new file mode 100644 index 0000000..0395e0d --- /dev/null +++ b/litelist-api/source/endpoints/admin.d @@ -0,0 +1,33 @@ +module endpoints.admin; + +import handy_httpd; +import slf4d; + +import std.file; +import std.path; +import std.json; + +void getAllUsers(ref HttpRequestContext ctx) { + import data.impl.user; + import data.list; + import data.note; + + JSONValue usersArray = JSONValue(string[].init); + + foreach (DirEntry entry; dirEntries(USERS_DIR, SpanMode.shallow, false)) { + string username = baseName(entry.name); + JSONValue userData = parseJSON(readText(buildPath(USERS_DIR, username, DATA_FILE))); + string email = userData.object["email"].str; + bool admin = userData.object["admin"].boolean; + ulong listCount = noteListDataSource.countLists(username); + ulong noteCount = noteDataSource.countNotes(username); + JSONValue userObj = JSONValue(string[string].init); + userObj.object["username"] = JSONValue(username); + userObj.object["email"] = JSONValue(email); + userObj.object["admin"] = JSONValue(admin); + userObj.object["listCount"] = JSONValue(listCount); + userObj.object["noteCount"] = JSONValue(noteCount); + usersArray.array ~= userObj; + } + ctx.response.writeBodyString(usersArray.toString(), "application/json"); +} \ No newline at end of file diff --git a/litelist-api/source/endpoints/auth.d b/litelist-api/source/endpoints/auth.d index a6e6f7b..87a4340 100644 --- a/litelist-api/source/endpoints/auth.d +++ b/litelist-api/source/endpoints/auth.d @@ -42,8 +42,7 @@ void handleLogin(ref HttpRequestContext ctx) { } void renewToken(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); JSONValue resp = JSONValue(string[string].init); resp.object["token"] = generateToken(auth.user, loadTokenSecret()); @@ -51,11 +50,33 @@ void renewToken(ref HttpRequestContext ctx) { } void createNewUser(ref HttpRequestContext ctx) { + import std.regex; + JSONValue userData = ctx.request.readBodyAsJson(); + if ("username" !in userData.object || "email" !in userData.object || "password" !in userData.object) { + ctx.response.setStatus(HttpStatus.BAD_REQUEST); + ctx.response.writeBodyString("Missing required data."); + return; + } + string username = userData.object["username"].str; string email = userData.object["email"].str; string password = userData.object["password"].str; + const ctr = ctRegex!(`^[a-zA-Z0-9][a-zA-Z0-9-_]{2,23}$`); + Captures!string c = matchFirst(username, ctr); + if (c.empty) { + ctx.response.setStatus(HttpStatus.BAD_REQUEST); + ctx.response.writeBodyString("Invalid username."); + return; + } + + if (password.length < 8) { + ctx.response.setStatus(HttpStatus.BAD_REQUEST); + ctx.response.writeBodyString("Password is too short. Should be at least 8 characters."); + return; + } + if (!userDataSource.getUser(username).isNull) { ctx.response.setStatus(HttpStatus.BAD_REQUEST); ctx.response.writeBodyString("Username is taken."); @@ -72,16 +93,15 @@ void createNewUser(ref HttpRequestContext ctx) { } void getMyUser(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); JSONValue resp = JSONValue(string[string].init); resp.object["username"] = JSONValue(auth.user.username); resp.object["email"] = JSONValue(auth.user.email); + resp.object["admin"] = JSONValue(auth.user.admin); ctx.response.writeBodyString(resp.toString(), "application/json"); } void deleteMyUser(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); userDataSource.deleteUser(auth.user.username); } diff --git a/litelist-api/source/endpoints/lists.d b/litelist-api/source/endpoints/lists.d index fcecfee..0054845 100644 --- a/litelist-api/source/endpoints/lists.d +++ b/litelist-api/source/endpoints/lists.d @@ -11,8 +11,7 @@ import data.list; import data.note; void getNoteLists(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); NoteList[] lists = noteListDataSource.getLists(auth.user.username); JSONValue listsArray = JSONValue(string[].init); foreach (NoteList list; lists) { @@ -22,8 +21,7 @@ void getNoteLists(ref HttpRequestContext ctx) { } void getNoteList(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); ulong id = ctx.request.getPathParamAs!ulong("id"); Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id); if (!optionalList.isNull) { @@ -34,8 +32,7 @@ void getNoteList(ref HttpRequestContext ctx) { } void createNoteList(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); JSONValue requestBody = ctx.request.readBodyAsJson(); if ("name" !in requestBody.object) { ctx.response.setStatus(HttpStatus.BAD_REQUEST); @@ -56,8 +53,7 @@ void createNoteList(ref HttpRequestContext ctx) { } void createNote(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); ulong listId = ctx.request.getPathParamAs!ulong("listId"); JSONValue requestBody = ctx.request.readBodyAsJson(); if ( @@ -75,14 +71,12 @@ void createNote(ref HttpRequestContext ctx) { } void deleteNoteList(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id")); } void deleteNote(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; - AuthContext auth = AuthContextHolder.getOrThrow(); + AuthContext auth = getAuthContextOrThrow(ctx); ulong noteId = ctx.request.getPathParamAs!ulong("noteId"); noteDataSource.deleteNote(auth.user.username, noteId); } diff --git a/litelist-app/src/api/admin.ts b/litelist-app/src/api/admin.ts new file mode 100644 index 0000000..83b4092 --- /dev/null +++ b/litelist-app/src/api/admin.ts @@ -0,0 +1,19 @@ +import {API_URL} from "@/api/base"; + +export interface AdminUserInfo { + username: string + email: string + admin: boolean + listCount: number + noteCount: number +} + +export async function getAllUsers(token: string): Promise { + const response = await fetch(API_URL + "/admin/users", { + headers: {"Authorization": "Bearer " + token} + }) + if (response.ok) { + return await response.json() + } + throw response +} diff --git a/litelist-app/src/api/auth.ts b/litelist-app/src/api/auth.ts index 26d770f..31ea283 100644 --- a/litelist-app/src/api/auth.ts +++ b/litelist-app/src/api/auth.ts @@ -3,10 +3,11 @@ import {API_URL} from "@/api/base"; export interface User { username: string email: string + admin: boolean } export function emptyUser(): User { - return {username: "", email: ""} + return {username: "", email: "", admin: false} } export interface LoginInfo { diff --git a/litelist-app/src/components/PageContainer.vue b/litelist-app/src/components/PageContainer.vue index 9de1de3..e315cd1 100644 --- a/litelist-app/src/components/PageContainer.vue +++ b/litelist-app/src/components/PageContainer.vue @@ -4,26 +4,35 @@ a mobile-friendly width. --> diff --git a/litelist-app/src/router/index.ts b/litelist-app/src/router/index.ts index 5314acb..9959f63 100644 --- a/litelist-app/src/router/index.ts +++ b/litelist-app/src/router/index.ts @@ -3,6 +3,7 @@ import LoginView from "@/views/LoginView.vue"; import ListsView from "@/views/ListsView.vue"; import {useAuthStore} from "@/stores/auth"; import SingleListView from "@/views/SingleListView.vue"; +import AdminView from "@/views/AdminView.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -31,6 +32,10 @@ const router = createRouter({ { path: "/lists/:id", component: SingleListView + }, + { + path: "/admin", + component: AdminView } ] }) @@ -40,12 +45,19 @@ const publicRoutes = [ "/login" ] +const adminRoutes = [ + "/admin" +] + router.beforeEach(async (to, from) => { const authStore = useAuthStore() await authStore.tryLogInFromStoredToken() if (!publicRoutes.includes(to.path) && !authStore.authenticated) { return "/login" // Redirect to login page if user is trying to go to an authenticated page. } + if (adminRoutes.includes(to.path) && !authStore.user.admin) { + return "/lists" // Redirect to /lists if a non-admin user tries to access an admin page. + } }) export default router diff --git a/litelist-app/src/stores/auth.ts b/litelist-app/src/stores/auth.ts index 5117c64..b6a607e 100644 --- a/litelist-app/src/stores/auth.ts +++ b/litelist-app/src/stores/auth.ts @@ -61,7 +61,7 @@ export const useAuthStore = defineStore("auth", () => { try { const storedUser = await getMyUser(storedToken) console.log("Logging in using stored token for user: " + storedUser.username) - logIn(storedToken, storedUser) + await logIn(storedToken, storedUser) } catch (e: any) { console.warn("Failed to log in using stored token.", e) } diff --git a/litelist-app/src/views/AdminView.vue b/litelist-app/src/views/AdminView.vue new file mode 100644 index 0000000..cd70ad2 --- /dev/null +++ b/litelist-app/src/views/AdminView.vue @@ -0,0 +1,49 @@ + + + + + \ No newline at end of file diff --git a/litelist-app/src/views/SingleListView.vue b/litelist-app/src/views/SingleListView.vue index b1cc96e..990da81 100644 --- a/litelist-app/src/views/SingleListView.vue +++ b/litelist-app/src/views/SingleListView.vue @@ -110,7 +110,7 @@ async function createNoteAndRefresh() {

- There are no notes in this list. + There are no notes in this list.