Added admin page and more authentication improvements with latest handy-httpd version.

This commit is contained in:
Andrew Lalis 2023-08-24 15:37:25 -04:00
parent 859f17ccc5
commit f3fe5e9671
20 changed files with 297 additions and 107 deletions

View File

@ -5,17 +5,18 @@
"copyright": "Copyright © 2023, Andrew Lalis", "copyright": "Copyright © 2023, Andrew Lalis",
"dependencies": { "dependencies": {
"botan": "~>1.13.5", "botan": "~>1.13.5",
"d-properties": "~>1.0.4", "d-properties": "~>1.0.5",
"d2sqlite3": "~>1.0.0", "d2sqlite3": "~>1.0.0",
"handy-httpd": "~>7.9.3", "handy-httpd": "~>7.10.4",
"jwt": "~>0.4.0", "jwt": "~>0.4.0",
"resusage": "~>0.3.2", "resusage": "~>0.3.2",
"slf4d": "~>2.4.2" "slf4d": "~>2.4.3"
}, },
"description": "API for the litelist application.", "description": "API for the litelist application.",
"license": "MIT", "license": "MIT",
"name": "litelist-api", "name": "litelist-api",
"subConfigurations": { "subConfigurations": {
"d2sqlite3": "all-included" "d2sqlite3": "all-included"
} },
"buildRequirements": ["allowWarnings"]
} }

View File

@ -3,14 +3,14 @@
"versions": { "versions": {
"botan": "1.13.5", "botan": "1.13.5",
"botan-math": "1.0.4", "botan-math": "1.0.4",
"d-properties": "1.0.4", "d-properties": "1.0.5",
"d2sqlite3": "1.0.0", "d2sqlite3": "1.0.0",
"handy-httpd": "7.9.3", "handy-httpd": "7.10.4",
"httparsed": "1.2.1", "httparsed": "1.2.1",
"jwt": "0.4.0", "jwt": "0.4.0",
"memutils": "1.0.9", "memutils": "1.0.9",
"resusage": "0.3.2", "resusage": "0.3.2",
"slf4d": "2.4.2", "slf4d": "2.4.3",
"streams": "3.5.0" "streams": "3.5.0"
} }
} }

View File

@ -4,7 +4,7 @@ import slf4d.default_provider;
void main() { void main() {
auto provider = new shared DefaultProvider(true, Levels.INFO); auto provider = new shared DefaultProvider(true, Levels.INFO);
// provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.WARN); // provider.getLoggerFactory().setModuleLevelPrefix("handy_httpd", Levels.DEBUG);
configureLoggingProvider(provider); configureLoggingProvider(provider);
HttpServer server = initServer(); HttpServer server = initServer();
@ -21,9 +21,12 @@ private HttpServer initServer() {
import d_properties; import d_properties;
import endpoints.auth; import endpoints.auth;
import endpoints.lists; import endpoints.lists;
import endpoints.admin;
import std.file; import std.file;
import std.conv; import std.conv;
import auth : TokenFilter, AdminFilter, loadTokenSecret;
ServerConfig config = ServerConfig.defaultValues(); ServerConfig config = ServerConfig.defaultValues();
config.enableWebSockets = false; config.enableWebSockets = false;
config.workerPoolSize = 3; config.workerPoolSize = 3;
@ -56,28 +59,42 @@ private HttpServer initServer() {
immutable string API_PATH = "/api"; immutable string API_PATH = "/api";
auto mainHandler = new PathDelegatingHandler(); PathDelegatingHandler mainHandler = new PathDelegatingHandler();
mainHandler.addMapping(Method.GET, API_PATH ~ "/status", &handleStatus); 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 ~ "/register", &createNewUser);
mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin); mainHandler.addMapping(Method.POST, API_PATH ~ "/login", &handleLogin);
mainHandler.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); // mainHandler.addMapping(Method.GET, API_PATH ~ "/shutdown", (ref HttpRequestContext ctx) {
mainHandler.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser); // ctx.response.writeBodyString("Shutting down!");
mainHandler.addMapping(Method.GET, API_PATH ~ "/renew-token", &renewToken); // ctx.server.stop();
// });
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);
HttpRequestHandler optionsHandler = toHandler((ref HttpRequestContext ctx) {
ctx.response.setStatus(HttpStatus.OK);
});
mainHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", optionsHandler); 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); return new HttpServer(mainHandler, config);
} }

View File

@ -7,8 +7,12 @@ import handy_httpd;
import handy_httpd.handlers.filtered_handler; import handy_httpd.handlers.filtered_handler;
import slf4d; import slf4d;
import std.typecons;
import data.user; import data.user;
immutable string AUTH_METADATA_KEY = "AuthContext";
/** /**
* Generates a new access token for an authenticated user. * Generates a new access token for an authenticated user.
* Params: * Params:
@ -25,7 +29,7 @@ string generateToken(in User user, in string secret) {
token.claims.sub(user.username); token.claims.sub(user.username);
token.claims.exp(Clock.currTime.toUnixTime() + 5000); token.claims.exp(Clock.currTime.toUnixTime() + 5000);
token.claims.iss("litelist-api"); token.claims.iss("litelist-api");
return token.encode("supersecret");// TODO: Extract secret. return token.encode(secret);
} }
void sendUnauthenticatedResponse(ref HttpResponse resp) { void sendUnauthenticatedResponse(ref HttpResponse resp) {
@ -46,39 +50,14 @@ string loadTokenSecret() {
return "supersecret"; return "supersecret";
} }
struct AuthContext { class AuthContext {
string token; string token;
User user; User user;
this(string token, User user) {
this.token = token;
this.user = user;
} }
class AuthContextHolder {
private static AuthContextHolder instance;
static getInstance() {
if (!instance) instance = new AuthContextHolder();
return instance;
}
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: * Params:
* ctx = The request context to validate. * ctx = The request context to validate.
* secret = The secret key that should have been used to sign the token. * 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.jwt : verify, Token;
import jwt.algorithms : JWTAlgorithm; import jwt.algorithms : JWTAlgorithm;
import std.typecons; import std.typecons;
immutable HEADER_NAME = "Authorization"; immutable HEADER_NAME = "Authorization";
AuthContextHolder.reset();
if (!ctx.request.hasHeader(HEADER_NAME)) { if (!ctx.request.hasHeader(HEADER_NAME)) {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED); ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Missing Authorization header."); ctx.response.writeBodyString("Missing Authorization header.");
return false; return Nullable!AuthContext.init;
} }
string authHeader = ctx.request.getHeader(HEADER_NAME); string authHeader = ctx.request.getHeader(HEADER_NAME);
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") { if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED); ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("Invalid bearer token authorization header."); ctx.response.writeBodyString("Invalid bearer token authorization header.");
return false; return Nullable!AuthContext.init;
} }
string rawToken = authHeader[7 .. $]; string rawToken = authHeader[7 .. $];
string username; string username;
try { try {
Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]); Token token = verify(rawToken, secret, [JWTAlgorithm.HS512]);
username = token.claims.sub; username = token.claims.sub;
} catch (Exception e) { } catch (Exception e) {
warn("Failed to verify user token.", 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); Nullable!User user = userDataSource.getUser(username);
if (user.isNull) { if (user.isNull) {
ctx.response.setStatus(HttpStatus.UNAUTHORIZED); ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
ctx.response.writeBodyString("User does not exist."); ctx.response.writeBodyString("User does not exist.");
return false; return Nullable!AuthContext.init;
} }
AuthContextHolder.setContext(rawToken, user.get); return nullable(new AuthContext(rawToken, user.get));
return true;
} }
class TokenFilter : HttpRequestFilter { class TokenFilter : HttpRequestFilter {
@ -138,6 +117,30 @@ class TokenFilter : HttpRequestFilter {
} }
void apply(ref HttpRequestContext ctx, FilterChain filterChain) { 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.");
}

View File

@ -102,4 +102,9 @@ class SqliteNoteListDataSource : NoteListDataSource {
db.commit(); db.commit();
return NoteList(id, newData.name, newData.ordinality, newData.description, []); 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();
}
} }

View File

@ -41,16 +41,18 @@ class SqliteNoteDataSource : NoteDataSource {
if (newData.ordinality > note.ordinality) { if (newData.ordinality > note.ordinality) {
// Decrement all notes between the old index and the new one. // Decrement all notes between the old index and the new one.
db.execute( 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, note.ordinality,
newData.ordinality newData.ordinality,
note.noteListId
); );
} else { } else {
// Increment all notes between the old index and the new one. // Increment all notes between the old index and the new one.
db.execute( 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, newData.ordinality,
note.ordinality note.ordinality,
note.noteListId
); );
} }
} }
@ -66,14 +68,27 @@ class SqliteNoteDataSource : NoteDataSource {
void deleteNote(string username, ulong id) { void deleteNote(string username, ulong id) {
Database db = getDb(username); 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(); 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); db.execute("DELETE FROM note WHERE id = ?", id);
if (!ordinality.isNull) { db.execute(
db.execute("UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get); "UPDATE note SET ordinality = ordinality - 1 WHERE ordinality > ? AND note_list_id = ?",
} note.ordinality,
note.noteListId
);
db.commit(); 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();
}
} }

View File

@ -18,9 +18,9 @@ class FileSystemUserDataSource : UserDataSource {
mkdirRecurse(dirPath); mkdirRecurse(dirPath);
string dataPath = buildPath(dirPath, DATA_FILE); string dataPath = buildPath(dirPath, DATA_FILE);
JSONValue userObj = JSONValue(string[string].init); JSONValue userObj = JSONValue(string[string].init);
userObj.object["username"] = username; userObj.object["email"] = JSONValue(email);
userObj.object["email"] = email; userObj.object["passwordHash"] = JSONValue(passwordHash);
userObj.object["passwordHash"] = passwordHash; userObj.object["admin"] = JSONValue(false);
std.file.write(dataPath, userObj.toPrettyString()); std.file.write(dataPath, userObj.toPrettyString());
return User(username, email, passwordHash); return User(username, email, passwordHash);
} }
@ -35,11 +35,16 @@ class FileSystemUserDataSource : UserDataSource {
string dataPath = buildPath(USERS_DIR, username, DATA_FILE); string dataPath = buildPath(USERS_DIR, username, DATA_FILE);
if (exists(dataPath) && isFile(dataPath)) { if (exists(dataPath) && isFile(dataPath)) {
JSONValue userObj = parseJSON(strip(readText(dataPath))); JSONValue userObj = parseJSON(strip(readText(dataPath)));
return nullable(User( string email = userObj.object["email"].str;
userObj.object["username"].str, string passwordHash = userObj.object["passwordHash"].str;
userObj.object["email"].str, bool admin = false;
userObj.object["passwordHash"].str 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; return Nullable!User.init;
} }

View File

@ -10,6 +10,7 @@ interface NoteListDataSource {
NoteList createNoteList(string username, string name, string description = null); NoteList createNoteList(string username, string name, string description = null);
void deleteNoteList(string username, ulong id); void deleteNoteList(string username, ulong id);
NoteList updateNoteList(string username, ulong id, NoteList newData); NoteList updateNoteList(string username, ulong id, NoteList newData);
ulong countLists(string username);
} }
static NoteListDataSource noteListDataSource; static NoteListDataSource noteListDataSource;

View File

@ -4,6 +4,7 @@ struct User {
string username; string username;
string email; string email;
string passwordHash; string passwordHash;
bool admin;
} }
struct NoteList { struct NoteList {

View File

@ -6,6 +6,8 @@ interface NoteDataSource {
Note createNote(string username, ulong noteListId, string content); Note createNote(string username, ulong noteListId, string content);
Note updateNote(string username, ulong id, Note newData); Note updateNote(string username, ulong id, Note newData);
void deleteNote(string username, ulong id); void deleteNote(string username, ulong id);
ulong countNotes(string username);
ulong countNotes(string username, ulong noteListId);
} }
static NoteDataSource noteDataSource; static NoteDataSource noteDataSource;

View File

@ -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");
}

View File

@ -42,8 +42,7 @@ void handleLogin(ref HttpRequestContext ctx) {
} }
void renewToken(ref HttpRequestContext ctx) { void renewToken(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue resp = JSONValue(string[string].init); JSONValue resp = JSONValue(string[string].init);
resp.object["token"] = generateToken(auth.user, loadTokenSecret()); resp.object["token"] = generateToken(auth.user, loadTokenSecret());
@ -51,11 +50,33 @@ void renewToken(ref HttpRequestContext ctx) {
} }
void createNewUser(ref HttpRequestContext ctx) { void createNewUser(ref HttpRequestContext ctx) {
import std.regex;
JSONValue userData = ctx.request.readBodyAsJson(); 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 username = userData.object["username"].str;
string email = userData.object["email"].str; string email = userData.object["email"].str;
string password = userData.object["password"].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) { if (!userDataSource.getUser(username).isNull) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST); ctx.response.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Username is taken."); ctx.response.writeBodyString("Username is taken.");
@ -72,16 +93,15 @@ void createNewUser(ref HttpRequestContext ctx) {
} }
void getMyUser(ref HttpRequestContext ctx) { void getMyUser(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue resp = JSONValue(string[string].init); JSONValue resp = JSONValue(string[string].init);
resp.object["username"] = JSONValue(auth.user.username); resp.object["username"] = JSONValue(auth.user.username);
resp.object["email"] = JSONValue(auth.user.email); resp.object["email"] = JSONValue(auth.user.email);
resp.object["admin"] = JSONValue(auth.user.admin);
ctx.response.writeBodyString(resp.toString(), "application/json"); ctx.response.writeBodyString(resp.toString(), "application/json");
} }
void deleteMyUser(ref HttpRequestContext ctx) { void deleteMyUser(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
userDataSource.deleteUser(auth.user.username); userDataSource.deleteUser(auth.user.username);
} }

View File

@ -11,8 +11,7 @@ import data.list;
import data.note; import data.note;
void getNoteLists(ref HttpRequestContext ctx) { void getNoteLists(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
NoteList[] lists = noteListDataSource.getLists(auth.user.username); NoteList[] lists = noteListDataSource.getLists(auth.user.username);
JSONValue listsArray = JSONValue(string[].init); JSONValue listsArray = JSONValue(string[].init);
foreach (NoteList list; lists) { foreach (NoteList list; lists) {
@ -22,8 +21,7 @@ void getNoteLists(ref HttpRequestContext ctx) {
} }
void getNoteList(ref HttpRequestContext ctx) { void getNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
ulong id = ctx.request.getPathParamAs!ulong("id"); ulong id = ctx.request.getPathParamAs!ulong("id");
Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id); Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id);
if (!optionalList.isNull) { if (!optionalList.isNull) {
@ -34,8 +32,7 @@ void getNoteList(ref HttpRequestContext ctx) {
} }
void createNoteList(ref HttpRequestContext ctx) { void createNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue requestBody = ctx.request.readBodyAsJson(); JSONValue requestBody = ctx.request.readBodyAsJson();
if ("name" !in requestBody.object) { if ("name" !in requestBody.object) {
ctx.response.setStatus(HttpStatus.BAD_REQUEST); ctx.response.setStatus(HttpStatus.BAD_REQUEST);
@ -56,8 +53,7 @@ void createNoteList(ref HttpRequestContext ctx) {
} }
void createNote(ref HttpRequestContext ctx) { void createNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
ulong listId = ctx.request.getPathParamAs!ulong("listId"); ulong listId = ctx.request.getPathParamAs!ulong("listId");
JSONValue requestBody = ctx.request.readBodyAsJson(); JSONValue requestBody = ctx.request.readBodyAsJson();
if ( if (
@ -75,14 +71,12 @@ void createNote(ref HttpRequestContext ctx) {
} }
void deleteNoteList(ref HttpRequestContext ctx) { void deleteNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id")); noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
} }
void deleteNote(ref HttpRequestContext ctx) { void deleteNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = getAuthContextOrThrow(ctx);
AuthContext auth = AuthContextHolder.getOrThrow();
ulong noteId = ctx.request.getPathParamAs!ulong("noteId"); ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
noteDataSource.deleteNote(auth.user.username, noteId); noteDataSource.deleteNote(auth.user.username, noteId);
} }

View File

@ -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<AdminUserInfo[]> {
const response = await fetch(API_URL + "/admin/users", {
headers: {"Authorization": "Bearer " + token}
})
if (response.ok) {
return await response.json()
}
throw response
}

View File

@ -3,10 +3,11 @@ import {API_URL} from "@/api/base";
export interface User { export interface User {
username: string username: string
email: string email: string
admin: boolean
} }
export function emptyUser(): User { export function emptyUser(): User {
return {username: "", email: ""} return {username: "", email: "", admin: false}
} }
export interface LoginInfo { export interface LoginInfo {

View File

@ -4,26 +4,35 @@ a mobile-friendly width.
--> -->
<script setup lang="ts"> <script setup lang="ts">
import type {Ref} from "vue"; import type {Ref} from "vue";
import {onMounted, ref} from "vue"; import {onMounted, onUnmounted, ref} from "vue";
import type {StatusInfo} from "@/api/base"; import type {StatusInfo} from "@/api/base";
import {getStatus} from "@/api/base"; import {getStatus} from "@/api/base";
import {humanFileSize} from "@/util"; import {humanFileSize} from "@/util";
import {useAuthStore} from "@/stores/auth";
const statusInfo: Ref<StatusInfo | null> = ref(null) const statusInfo: Ref<StatusInfo | null> = ref(null)
const statusRefreshInterval: Ref<number | null> = ref(null);
const authStore = useAuthStore()
onMounted(async () => { onMounted(async () => {
statusInfo.value = await getStatus() statusInfo.value = await getStatus()
setInterval(async () => { statusRefreshInterval.value = setInterval(async () => {
statusInfo.value = await getStatus() statusInfo.value = await getStatus()
}, 5000) }, 5000)
}) })
onUnmounted(() => {
if (statusRefreshInterval.value) {
clearInterval(statusRefreshInterval.value)
}
})
</script> </script>
<template> <template>
<div class="page-container"> <div class="page-container">
<slot/> <slot/>
<!-- Each contained page also gets a nice little footer! --> <!-- Each contained page also gets a nice little footer! -->
<footer style="text-align: center"> <footer style="text-align: center; margin-top: 2rem;">
<p style="font-size: smaller"> <p style="font-size: smaller">
LiteList created with by LiteList created with by
<a href="https://andrewlalis.com" target="_blank">Andrew Lalis</a> <a href="https://andrewlalis.com" target="_blank">Andrew Lalis</a>
@ -33,6 +42,9 @@ onMounted(async () => {
<p v-if="statusInfo" style="font-size: smaller; font-family: monospace;"> <p v-if="statusInfo" style="font-size: smaller; font-family: monospace;">
Memory used: <span v-text="humanFileSize(statusInfo.physicalMemory, true, 1)"></span> Memory used: <span v-text="humanFileSize(statusInfo.physicalMemory, true, 1)"></span>
</p> </p>
<p v-if="authStore.authenticated && authStore.user.admin" style="font-size: smaller">
<RouterLink to="/admin">Admin Page</RouterLink>
</p>
</footer> </footer>
</div> </div>
</template> </template>

View File

@ -3,6 +3,7 @@ import LoginView from "@/views/LoginView.vue";
import ListsView from "@/views/ListsView.vue"; import ListsView from "@/views/ListsView.vue";
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import SingleListView from "@/views/SingleListView.vue"; import SingleListView from "@/views/SingleListView.vue";
import AdminView from "@/views/AdminView.vue";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -31,6 +32,10 @@ const router = createRouter({
{ {
path: "/lists/:id", path: "/lists/:id",
component: SingleListView component: SingleListView
},
{
path: "/admin",
component: AdminView
} }
] ]
}) })
@ -40,12 +45,19 @@ const publicRoutes = [
"/login" "/login"
] ]
const adminRoutes = [
"/admin"
]
router.beforeEach(async (to, from) => { router.beforeEach(async (to, from) => {
const authStore = useAuthStore() const authStore = useAuthStore()
await authStore.tryLogInFromStoredToken() await authStore.tryLogInFromStoredToken()
if (!publicRoutes.includes(to.path) && !authStore.authenticated) { if (!publicRoutes.includes(to.path) && !authStore.authenticated) {
return "/login" // Redirect to login page if user is trying to go to an authenticated page. 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 export default router

View File

@ -61,7 +61,7 @@ export const useAuthStore = defineStore("auth", () => {
try { try {
const storedUser = await getMyUser(storedToken) const storedUser = await getMyUser(storedToken)
console.log("Logging in using stored token for user: " + storedUser.username) console.log("Logging in using stored token for user: " + storedUser.username)
logIn(storedToken, storedUser) await logIn(storedToken, storedUser)
} catch (e: any) { } catch (e: any) {
console.warn("Failed to log in using stored token.", e) console.warn("Failed to log in using stored token.", e)
} }

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import PageContainer from "@/components/PageContainer.vue";
import type {Ref} from "vue";
import type {AdminUserInfo} from "@/api/admin";
import {onMounted, ref} from "vue";
import {getAllUsers} from "@/api/admin";
import {useAuthStore} from "@/stores/auth";
const authStore = useAuthStore()
const users: Ref<AdminUserInfo[]> = ref([])
onMounted(async () => {
users.value = await getAllUsers(authStore.token)
})
</script>
<template>
<PageContainer>
<h1>Admin</h1>
<p>
This is the admin page!
</p>
<h3>Users</h3>
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Admin</th>
<th>List Count</th>
<th>Note Count</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.username">
<td v-text="user.username"/>
<td v-text="user.email"/>
<td v-text="user.admin"/>
<td v-text="user.listCount"/>
<td v-text="user.noteCount"/>
</tr>
</tbody>
</table>
</PageContainer>
</template>
<style scoped>
</style>

View File

@ -110,7 +110,7 @@ async function createNoteAndRefresh() {
</div> </div>
<p v-if="list.notes.length === 0"> <p v-if="list.notes.length === 0">
<em>There are no notes in this list.</em> <Button @click="toggleCreatingNewNote()">Add one!</Button> <em>There are no notes in this list.</em> <button @click="toggleCreatingNewNote()">Add one!</button>
</p> </p>
<dialog id="list-delete-dialog"> <dialog id="list-delete-dialog">