Added admin page and more authentication improvements with latest handy-httpd version.
This commit is contained in:
parent
859f17ccc5
commit
f3fe5e9671
|
@ -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"]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -4,6 +4,7 @@ struct User {
|
|||
string username;
|
||||
string email;
|
||||
string passwordHash;
|
||||
bool admin;
|
||||
}
|
||||
|
||||
struct NoteList {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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");
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -4,26 +4,35 @@ a mobile-friendly width.
|
|||
-->
|
||||
<script setup lang="ts">
|
||||
import type {Ref} from "vue";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onMounted, onUnmounted, ref} from "vue";
|
||||
import type {StatusInfo} from "@/api/base";
|
||||
import {getStatus} from "@/api/base";
|
||||
import {humanFileSize} from "@/util";
|
||||
import {useAuthStore} from "@/stores/auth";
|
||||
|
||||
const statusInfo: Ref<StatusInfo | null> = ref(null)
|
||||
const statusRefreshInterval: Ref<number | null> = ref(null);
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
statusInfo.value = await getStatus()
|
||||
setInterval(async () => {
|
||||
statusRefreshInterval.value = setInterval(async () => {
|
||||
statusInfo.value = await getStatus()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (statusRefreshInterval.value) {
|
||||
clearInterval(statusRefreshInterval.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<slot/>
|
||||
<!-- 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">
|
||||
LiteList created with ❤️ by
|
||||
<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;">
|
||||
Memory used: <span v-text="humanFileSize(statusInfo.physicalMemory, true, 1)"></span>
|
||||
</p>
|
||||
<p v-if="authStore.authenticated && authStore.user.admin" style="font-size: smaller">
|
||||
<RouterLink to="/admin">Admin Page</RouterLink>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -110,7 +110,7 @@ async function createNoteAndRefresh() {
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<dialog id="list-delete-dialog">
|
||||
|
|
Loading…
Reference in New Issue