From a7be040deaa39e900fc71e275f10c01214082162 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Tue, 22 Aug 2023 10:05:26 -0400 Subject: [PATCH] Refactored API sources, added registration to app. --- litelist-api/.gitignore | 1 + litelist-api/source/app.d | 26 +- litelist-api/source/auth.d | 129 +++----- litelist-api/source/data.d | 303 ------------------ litelist-api/source/data/impl/list.d | 105 ++++++ litelist-api/source/data/impl/note.d | 79 +++++ .../source/data/impl/sqlite3_helpers.d | 54 ++++ litelist-api/source/data/impl/user.d | 46 +++ litelist-api/source/data/list.d | 19 ++ litelist-api/source/data/model.d | 22 ++ litelist-api/source/data/note.d | 15 + litelist-api/source/data/user.d | 17 + litelist-api/source/endpoints/auth.d | 87 +++++ litelist-api/source/{ => endpoints}/lists.d | 38 ++- litelist-app/index.html | 2 +- litelist-app/src/api/auth.ts | 9 + litelist-app/src/views/ListsView.vue | 12 +- litelist-app/src/views/LoginView.vue | 115 +++++-- litelist-app/src/views/SingleListView.vue | 9 +- 19 files changed, 640 insertions(+), 448 deletions(-) delete mode 100644 litelist-api/source/data.d create mode 100644 litelist-api/source/data/impl/list.d create mode 100644 litelist-api/source/data/impl/note.d create mode 100644 litelist-api/source/data/impl/sqlite3_helpers.d create mode 100644 litelist-api/source/data/impl/user.d create mode 100644 litelist-api/source/data/list.d create mode 100644 litelist-api/source/data/model.d create mode 100644 litelist-api/source/data/note.d create mode 100644 litelist-api/source/data/user.d create mode 100644 litelist-api/source/endpoints/auth.d rename litelist-api/source/{ => endpoints}/lists.d (74%) diff --git a/litelist-api/.gitignore b/litelist-api/.gitignore index 825b679..0005184 100644 --- a/litelist-api/.gitignore +++ b/litelist-api/.gitignore @@ -16,3 +16,4 @@ litelist-api-test-* *.lst users/ +application.properties diff --git a/litelist-api/source/app.d b/litelist-api/source/app.d index 129b194..f8eb7f5 100644 --- a/litelist-api/source/app.d +++ b/litelist-api/source/app.d @@ -11,12 +11,16 @@ void main() { server.start(); } +/** + * Initializes the HTTP server that this app will run. + * Returns: The HTTP server to use. + */ private HttpServer initServer() { import handy_httpd.handlers.path_delegating_handler; import handy_httpd.handlers.filtered_handler; import d_properties; - import auth; - import lists; + import endpoints.auth; + import endpoints.lists; import std.file; import std.conv; @@ -24,6 +28,7 @@ private HttpServer initServer() { config.enableWebSockets = false; config.workerPoolSize = 3; config.connectionQueueSize = 10; + bool useCorsHeaders = true; if (exists("application.properties")) { Properties props = Properties("application.properties"); if (props.has("port")) { @@ -35,14 +40,19 @@ private HttpServer initServer() { if (props.has("hostname")) { config.hostname = props.get("hostname"); } + if (props.has("useCorsHeaders")) { + useCorsHeaders = props.get("useCorsHeaders").to!bool; + } } - // Set some CORS headers to prevent headache. - config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; - config.defaultHeaders["Access-Control-Allow-Credentials"] = "true"; - config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; - config.defaultHeaders["Vary"] = "origin"; - config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization"; + if (useCorsHeaders) { + // Set some CORS headers to prevent headache. + config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; + config.defaultHeaders["Access-Control-Allow-Credentials"] = "true"; + config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; + config.defaultHeaders["Vary"] = "origin"; + config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization"; + } immutable string API_PATH = "/api"; diff --git a/litelist-api/source/auth.d b/litelist-api/source/auth.d index 1bf168f..81de303 100644 --- a/litelist-api/source/auth.d +++ b/litelist-api/source/auth.d @@ -1,94 +1,25 @@ +/** + * Logic for user authentication. + */ module auth; import handy_httpd; import handy_httpd.handlers.filtered_handler; -import jwt.jwt; -import jwt.algorithms; import slf4d; -import std.datetime; -import std.json; -import std.path; -import std.file; -import std.typecons; +import data.user; -import data; - - -void handleLogin(ref HttpRequestContext ctx) { - JSONValue loginData = ctx.request.readBodyAsJson(); - if ("username" !in loginData.object || "password" !in loginData.object) { - ctx.response.setStatus(HttpStatus.BAD_REQUEST); - ctx.response.writeBodyString("Invalid login request data. Expected username and password."); - return; - } - string username = loginData.object["username"].str; - infoF!"Got login request for user \"%s\"."(username); - string password = loginData.object["password"].str; - Nullable!User userNullable = userDataSource.getUser(username); - if (userNullable.isNull) { - infoF!"User \"%s\" doesn't exist."(username); - sendUnauthenticatedResponse(ctx.response); - return; - } - User user = userNullable.get(); - - import botan.passhash.bcrypt : checkBcrypt; - if (!checkBcrypt(password, user.passwordHash)) { - sendUnauthenticatedResponse(ctx.response); - return; - } - - JSONValue resp = JSONValue(string[string].init); - resp.object["token"] = generateToken(user); - ctx.response.writeBodyString(resp.toString(), "application/json"); -} - -void renewToken(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; - AuthContext auth = AuthContextHolder.getOrThrow(); - - JSONValue resp = JSONValue(string[string].init); - resp.object["token"] = generateToken(auth.user); - ctx.response.writeBodyString(resp.toString(), "application/json"); -} - -void createNewUser(ref HttpRequestContext ctx) { - JSONValue userData = ctx.request.readBodyAsJson(); - string username = userData.object["username"].str; - string email = userData.object["email"].str; - string password = userData.object["password"].str; - - if (!userDataSource.getUser(username).isNull) { - ctx.response.setStatus(HttpStatus.BAD_REQUEST); - ctx.response.writeBodyString("Username is taken."); - return; - } - - import botan.passhash.bcrypt : generateBcrypt; - import botan.rng.auto_rng; - RandomNumberGenerator rng = new AutoSeededRNG(); - string passwordHash = generateBcrypt(password, rng, 12); - - userDataSource.createUser(username, email, passwordHash); -} - -void getMyUser(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; - AuthContext auth = AuthContextHolder.getOrThrow(); - JSONValue resp = JSONValue(string[string].init); - resp.object["username"] = JSONValue(auth.user.username); - resp.object["email"] = JSONValue(auth.user.email); - ctx.response.writeBodyString(resp.toString(), "application/json"); -} - -void deleteMyUser(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; - AuthContext auth = AuthContextHolder.getOrThrow(); - userDataSource.deleteUser(auth.user.username); -} - -private string generateToken(in User user) { +/** + * Generates a new access token for an authenticated user. + * Params: + * user = The user to generate a token for. + * secret = The secret key to use to sign the token. + * Returns: The base-64 encoded and signed token string. + */ +string generateToken(in User user, in string secret) { + import jwt.jwt : Token; + import jwt.algorithms : JWTAlgorithm; + import std.datetime; Token token = new Token(JWTAlgorithm.HS512); token.claims.aud("litelist-api"); token.claims.sub(user.username); @@ -97,11 +28,24 @@ private string generateToken(in User user) { return token.encode("supersecret");// TODO: Extract secret. } -private void sendUnauthenticatedResponse(ref HttpResponse resp) { +void sendUnauthenticatedResponse(ref HttpResponse resp) { resp.setStatus(HttpStatus.UNAUTHORIZED); resp.writeBodyString("Invalid credentials."); } +string loadTokenSecret() { + import std.file : exists; + import d_properties; + if (exists("application.properties")) { + Properties props = Properties("application.properties"); + if (props.has("secret")) { + return props.get("secret"); + } + } + error("Couldn't load token secret from application.properties. Using insecure secret."); + return "supersecret"; +} + struct AuthContext { string token; User user; @@ -143,9 +87,14 @@ class AuthContextHolder { * Otherwise, sends an appropriate "unauthorized" response. * 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. */ -bool validateAuthenticatedRequest(ref HttpRequestContext ctx) { +bool 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)) { @@ -182,7 +131,13 @@ bool validateAuthenticatedRequest(ref HttpRequestContext ctx) { } class TokenFilter : HttpRequestFilter { + private immutable string secret; + + this(string secret) { + this.secret = secret; + } + void apply(ref HttpRequestContext ctx, FilterChain filterChain) { - if (validateAuthenticatedRequest(ctx)) filterChain.doFilter(ctx); + if (validateAuthenticatedRequest(ctx, this.secret)) filterChain.doFilter(ctx); } } diff --git a/litelist-api/source/data.d b/litelist-api/source/data.d deleted file mode 100644 index c07e1c2..0000000 --- a/litelist-api/source/data.d +++ /dev/null @@ -1,303 +0,0 @@ -module data; - -import handy_httpd; -import d2sqlite3; - -import std.path; -import std.file; -import std.stdio; -import std.typecons; -import std.string; -import std.json; - -static UserDataSource userDataSource; - -static this() { - userDataSource = new FsSqliteDataSource(); -} - -struct User { - string username; - string email; - string passwordHash; -} - -struct NoteList { - ulong id; - string name; - uint ordinality; - string description; - Note[] notes; -} - -struct Note { - ulong id; - ulong noteListId; - uint ordinality; - string content; -} - -interface UserDataSource { - User createUser(string username, string email, string passwordHash); - void deleteUser(string username); - Nullable!User getUser(string username); - NoteList[] getLists(string username); - Nullable!NoteList getList(string username, ulong id); - NoteList createNoteList(string username, string name, string description = null); - void deleteNoteList(string username, ulong id); - NoteList updateNoteList(string username, ulong id, NoteList newData); - Note createNote(string username, ulong noteListId, string content); - Note updateNote(string username, ulong id, Note newData); - void deleteNote(string username, ulong id); -} - -private immutable string USERS_DIR = "users"; -private immutable string DATA_FILE = "user.json"; -private immutable string DB_FILE = "notes.sqlite"; - -class FsSqliteDataSource : UserDataSource { - User createUser(string username, string email, string passwordHash) { - string dirPath = buildPath(USERS_DIR, username); - if (exists(dirPath)) throw new Exception("User already has a directory."); - 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; - std.file.write(dataPath, userObj.toPrettyString()); - - // Set up a default list. - NoteList defaultList = this.createNoteList(username, "Default", "Your default list of notes."); - this.createNote(username, defaultList.id, "Here's an example note that was added to the Default list."); - - return User(username, email, passwordHash); - } - - void deleteUser(string username) { - string dirPath = buildPath(USERS_DIR, username); - if (exists(dirPath)) rmdirRecurse(dirPath); - } - - Nullable!User getUser(string username) { - 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 - )); - } - return Nullable!User.init; - } - - NoteList[] getLists(string username) { - Database db = getDb(username); - ResultRange results = db.execute("SELECT * FROM note_list ORDER BY ordinality ASC"); - NoteList[] lists; - foreach (Row row; results) { - lists ~= parseNoteList(row); - } - // Now eager-fetch notes for each list. - Statement stmt = db.prepare("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC"); - foreach (ref list; lists) { - stmt.bind(1, list.id); - ResultRange noteResult = stmt.execute(); - foreach (row; noteResult) list.notes ~= parseNote(row); - stmt.reset(); - } - return lists; - } - - Nullable!NoteList getList(string username, ulong id) { - Database db = getDb(username); - ResultRange results = db.execute("SELECT * FROM note_list WHERE id = ?", id); - if (results.empty()) return Nullable!NoteList.init; - NoteList list = parseNoteList(results.front()); - ResultRange noteResults = db.execute("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC", id); - foreach (Row row; noteResults) { - list.notes ~= parseNote(row); - } - return nullable(list); - } - - NoteList createNoteList(string username, string name, string description = null) { - Database db = getDb(username); - Statement existsStatement = db.prepare("SELECT COUNT(name) FROM note_list WHERE name = ?"); - existsStatement.bind(1, name); - ResultRange existsResult = existsStatement.execute(); - if (existsResult.oneValue!int() > 0) { - throw new HttpStatusException(HttpStatus.BAD_REQUEST, "List already exists."); - } - - Nullable!uint ordResult = db.execute("SELECT MAX(ordinality) + 1 FROM note_list").oneValue!(Nullable!uint); - uint ordinality = 0; - if (!ordResult.isNull) ordinality = ordResult.get(); - Statement stmt = db.prepare("INSERT INTO note_list (name, ordinality, description) VALUES (?, ?, ?)"); - stmt.bind(1, name); - stmt.bind(2, ordinality); - stmt.bind(3, description); - stmt.execute(); - return NoteList(db.lastInsertRowid(), name, ordinality, description, []); - } - - void deleteNoteList(string username, ulong id) { - Database db = getDb(username); - db.begin(); - db.execute("DELETE FROM note WHERE note_list_id = ?", id); - Nullable!uint ordinality = db.execute( - "SELECT ordinality FROM note_list WHERE id = ?", id - ).oneValue!(Nullable!uint)(); - db.execute("DELETE FROM note_list WHERE id = ?", id); - if (!ordinality.isNull) { - db.execute("UPDATE note_list SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get); - } - db.commit(); - } - - NoteList updateNoteList(string username, ulong id, NoteList newData) { - Database db = getDb(username); - ResultRange result = db.execute("SELECT * FROM note_list WHERE id = ?", id); - if (result.empty()) throw new HttpStatusException(HttpStatus.NOT_FOUND); - NoteList list = parseNoteList(result.front()); - db.begin(); - if (list.ordinality != newData.ordinality) { - if (newData.ordinality > list.ordinality) { - // Decrement all lists between the old index and the new one. - db.execute( - "UPDATE note_list SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?", - list.ordinality, - newData.ordinality - ); - } else { - // Increment all lists between the old index and the new one. - db.execute( - "UPDATE note_list SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?", - newData.ordinality, - list.ordinality - ); - } - } - db.execute( - "UPDATE note_list SET name = ?, description = ?, ordinality = ? WHERE id = ?", - newData.name, newData.description, newData.ordinality, id - ); - db.commit(); - return NoteList(id, newData.name, newData.ordinality, newData.description, []); - } - - Note createNote(string username, ulong noteListId, string content) { - Database db = getDb(username); - - Statement ordStmt = db.prepare("SELECT MAX(ordinality) + 1 FROM note WHERE note_list_id = ?"); - ordStmt.bind(1, noteListId); - Nullable!uint ordResult = ordStmt.execute().oneValue!(Nullable!uint); - uint ordinality = 0; - if (!ordResult.isNull) ordinality = ordResult.get(); - - Statement insertStmt = db.prepare("INSERT INTO note (note_list_id, ordinality, content) VALUES (?, ?, ?)"); - insertStmt.bind(1, noteListId); - insertStmt.bind(2, ordinality); - insertStmt.bind(3, content); - insertStmt.execute(); - return Note( - db.lastInsertRowid(), - noteListId, - ordinality, - content - ); - } - - Note updateNote(string username, ulong id, Note newData) { - Database db = getDb(username); - ResultRange result = db.execute("SELECT * FROM note WHERE id = ?", id); - if (result.empty()) throw new HttpStatusException(HttpStatus.NOT_FOUND); - Note note = parseNote(result.front()); - db.begin(); - if (note.ordinality != newData.ordinality) { - 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 <= ?", - note.ordinality, - newData.ordinality - ); - } else { - // Increment all notes between the old index and the new one. - db.execute( - "UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?", - newData.ordinality, - note.ordinality - ); - } - } - db.execute( - "UPDATE note SET ordinality = ?, content = ? WHERE id = ?", - newData.ordinality, - newData.content, - id - ); - db.commit(); - return Note(id, note.noteListId, newData.ordinality, newData.content); - } - - void deleteNote(string username, ulong id) { - Database db = getDb(username); - 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.commit(); - } - - private NoteList parseNoteList(Row row) { - NoteList list; - list.id = row["id"].as!ulong; - list.name = row["name"].as!string; - list.ordinality = row["ordinality"].as!uint; - list.description = row["description"].as!string; - return list; - } - - private Note parseNote(Row row) { - Note note; - note.id = row["id"].as!ulong; - note.noteListId = row["note_list_id"].as!ulong; - note.ordinality = row["ordinality"].as!uint; - note.content = row["content"].as!string; - return note; - } - - private Database getDb(string username) { - string dbPath = buildPath(USERS_DIR, username, DB_FILE); - if (!exists(dbPath)) initDb(dbPath); - return Database(dbPath); - } - - private void initDb(string path) { - if (exists(path)) std.file.remove(path); - Database db = Database(path); - db.run(q"SQL - CREATE TABLE note_list ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - ordinality INTEGER NOT NULL DEFAULT 0, - description TEXT NULL - ); - - CREATE TABLE note ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - note_list_id INTEGER NOT NULL, - ordinality INTEGER NOT NULL DEFAULT 0, - content TEXT NOT NULL - ); -SQL" - ); - db.close(); - } -} diff --git a/litelist-api/source/data/impl/list.d b/litelist-api/source/data/impl/list.d new file mode 100644 index 0000000..a6ad496 --- /dev/null +++ b/litelist-api/source/data/impl/list.d @@ -0,0 +1,105 @@ +module data.impl.list; + +import data.list; +import data.impl.sqlite3_helpers; +import d2sqlite3; +import handy_httpd; + +import std.typecons; + +class SqliteNoteListDataSource : NoteListDataSource { + NoteList[] getLists(string username) { + Database db = getDb(username); + ResultRange results = db.execute("SELECT * FROM note_list ORDER BY ordinality ASC"); + NoteList[] lists; + foreach (Row row; results) { + lists ~= parseNoteList(row); + } + // Now eager-fetch notes for each list. + Statement stmt = db.prepare("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC"); + foreach (ref list; lists) { + stmt.bind(1, list.id); + ResultRange noteResult = stmt.execute(); + foreach (row; noteResult) list.notes ~= parseNote(row); + stmt.reset(); + } + return lists; + } + + Nullable!NoteList getList(string username, ulong id){ + Database db = getDb(username); + ResultRange results = db.execute("SELECT * FROM note_list WHERE id = ?", id); + if (results.empty()) return Nullable!NoteList.init; + NoteList list = parseNoteList(results.front()); + ResultRange noteResults = db.execute("SELECT * FROM note WHERE note_list_id = ? ORDER BY ordinality ASC", id); + foreach (Row row; noteResults) { + list.notes ~= parseNote(row); + } + return nullable(list); + } + + NoteList createNoteList(string username, string name, string description = null){ + Database db = getDb(username); + Statement existsStatement = db.prepare("SELECT COUNT(name) FROM note_list WHERE name = ?"); + existsStatement.bind(1, name); + ResultRange existsResult = existsStatement.execute(); + if (existsResult.oneValue!int() > 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "List already exists."); + } + + Nullable!uint ordResult = db.execute("SELECT MAX(ordinality) + 1 FROM note_list").oneValue!(Nullable!uint); + uint ordinality = 0; + if (!ordResult.isNull) ordinality = ordResult.get(); + Statement stmt = db.prepare("INSERT INTO note_list (name, ordinality, description) VALUES (?, ?, ?)"); + stmt.bind(1, name); + stmt.bind(2, ordinality); + stmt.bind(3, description); + stmt.execute(); + return NoteList(db.lastInsertRowid(), name, ordinality, description, []); + } + + void deleteNoteList(string username, ulong id) { + Database db = getDb(username); + db.begin(); + db.execute("DELETE FROM note WHERE note_list_id = ?", id); + Nullable!uint ordinality = db.execute( + "SELECT ordinality FROM note_list WHERE id = ?", id + ).oneValue!(Nullable!uint)(); + db.execute("DELETE FROM note_list WHERE id = ?", id); + if (!ordinality.isNull) { + db.execute("UPDATE note_list SET ordinality = ordinality - 1 WHERE ordinality > ?", ordinality.get); + } + db.commit(); + } + + NoteList updateNoteList(string username, ulong id, NoteList newData) { + Database db = getDb(username); + ResultRange result = db.execute("SELECT * FROM note_list WHERE id = ?", id); + if (result.empty()) throw new HttpStatusException(HttpStatus.NOT_FOUND); + NoteList list = parseNoteList(result.front()); + db.begin(); + if (list.ordinality != newData.ordinality) { + if (newData.ordinality > list.ordinality) { + // Decrement all lists between the old index and the new one. + db.execute( + "UPDATE note_list SET ordinality = ordinality - 1 WHERE ordinality > ? AND ordinality <= ?", + list.ordinality, + newData.ordinality + ); + } else { + // Increment all lists between the old index and the new one. + db.execute( + "UPDATE note_list SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?", + newData.ordinality, + list.ordinality + ); + } + } + db.execute( + "UPDATE note_list SET name = ?, description = ?, ordinality = ? WHERE id = ?", + newData.name, newData.description, newData.ordinality, id + ); + db.commit(); + return NoteList(id, newData.name, newData.ordinality, newData.description, []); + } +} \ No newline at end of file diff --git a/litelist-api/source/data/impl/note.d b/litelist-api/source/data/impl/note.d new file mode 100644 index 0000000..255f8e1 --- /dev/null +++ b/litelist-api/source/data/impl/note.d @@ -0,0 +1,79 @@ +module data.impl.note; + +import data.model : Note; +import data.note; +import d2sqlite3; +import data.impl.sqlite3_helpers; +import handy_httpd; + +import std.typecons; + +class SqliteNoteDataSource : NoteDataSource { + Note createNote(string username, ulong noteListId, string content) { + Database db = getDb(username); + + Statement ordStmt = db.prepare("SELECT MAX(ordinality) + 1 FROM note WHERE note_list_id = ?"); + ordStmt.bind(1, noteListId); + Nullable!uint ordResult = ordStmt.execute().oneValue!(Nullable!uint); + uint ordinality = 0; + if (!ordResult.isNull) ordinality = ordResult.get(); + + Statement insertStmt = db.prepare("INSERT INTO note (note_list_id, ordinality, content) VALUES (?, ?, ?)"); + insertStmt.bind(1, noteListId); + insertStmt.bind(2, ordinality); + insertStmt.bind(3, content); + insertStmt.execute(); + return Note( + db.lastInsertRowid(), + noteListId, + ordinality, + content + ); + } + + Note updateNote(string username, ulong id, Note newData) { + Database db = getDb(username); + ResultRange result = db.execute("SELECT * FROM note WHERE id = ?", id); + if (result.empty()) throw new HttpStatusException(HttpStatus.NOT_FOUND); + Note note = parseNote(result.front()); + db.begin(); + if (note.ordinality != newData.ordinality) { + 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 <= ?", + note.ordinality, + newData.ordinality + ); + } else { + // Increment all notes between the old index and the new one. + db.execute( + "UPDATE note SET ordinality = ordinality + 1 WHERE ordinality >= ? AND ordinality < ?", + newData.ordinality, + note.ordinality + ); + } + } + db.execute( + "UPDATE note SET ordinality = ?, content = ? WHERE id = ?", + newData.ordinality, + newData.content, + id + ); + db.commit(); + return Note(id, note.noteListId, newData.ordinality, newData.content); + } + + void deleteNote(string username, ulong id) { + Database db = getDb(username); + 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.commit(); + } +} \ No newline at end of file diff --git a/litelist-api/source/data/impl/sqlite3_helpers.d b/litelist-api/source/data/impl/sqlite3_helpers.d new file mode 100644 index 0000000..3e41f40 --- /dev/null +++ b/litelist-api/source/data/impl/sqlite3_helpers.d @@ -0,0 +1,54 @@ +module data.impl.sqlite3_helpers; + +import d2sqlite3; +import data.model; + +import std.file; +import std.path; + +NoteList parseNoteList(Row row) { + NoteList list; + list.id = row["id"].as!ulong; + list.name = row["name"].as!string; + list.ordinality = row["ordinality"].as!uint; + list.description = row["description"].as!string; + return list; +} + +Note parseNote(Row row) { + Note note; + note.id = row["id"].as!ulong; + note.noteListId = row["note_list_id"].as!ulong; + note.ordinality = row["ordinality"].as!uint; + note.content = row["content"].as!string; + return note; +} + +Database getDb(string username) { + import data.impl.user : USERS_DIR, DB_FILE; + string dbPath = buildPath(USERS_DIR, username, DB_FILE); + if (!exists(dbPath)) initDb(dbPath); + return Database(dbPath); +} + +void initDb(string path) { + if (exists(path)) std.file.remove(path); + Database db = Database(path); + db.run(q"SQL + CREATE TABLE note_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + ordinality INTEGER NOT NULL DEFAULT 0, + description TEXT NULL + ); + + CREATE TABLE note ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_list_id INTEGER NOT NULL, + ordinality INTEGER NOT NULL DEFAULT 0, + content TEXT NOT NULL + ); +SQL" + ); + db.close(); +} \ No newline at end of file diff --git a/litelist-api/source/data/impl/user.d b/litelist-api/source/data/impl/user.d new file mode 100644 index 0000000..ce64982 --- /dev/null +++ b/litelist-api/source/data/impl/user.d @@ -0,0 +1,46 @@ +module data.impl.user; + +import std.file; +import std.path; +import std.json; +import std.typecons; + +import data.user; + +immutable string USERS_DIR = "users"; +immutable string DATA_FILE = "user.json"; +immutable string DB_FILE = "notes.sqlite"; + +class FileSystemUserDataSource : UserDataSource { + User createUser(string username, string email, string passwordHash) { + string dirPath = buildPath(USERS_DIR, username); + if (exists(dirPath)) throw new Exception("User already has a directory."); + 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; + std.file.write(dataPath, userObj.toPrettyString()); + return User(username, email, passwordHash); + } + + void deleteUser(string username) { + string dirPath = buildPath(USERS_DIR, username); + if (exists(dirPath)) rmdirRecurse(dirPath); + } + + Nullable!User getUser(string username) { + import std.string : strip; + 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 + )); + } + return Nullable!User.init; + } +} diff --git a/litelist-api/source/data/list.d b/litelist-api/source/data/list.d new file mode 100644 index 0000000..6444656 --- /dev/null +++ b/litelist-api/source/data/list.d @@ -0,0 +1,19 @@ +module data.list; + +public import data.model : NoteList; + +import std.typecons : Nullable; + +interface NoteListDataSource { + NoteList[] getLists(string username); + Nullable!NoteList getList(string username, ulong id); + NoteList createNoteList(string username, string name, string description = null); + void deleteNoteList(string username, ulong id); + NoteList updateNoteList(string username, ulong id, NoteList newData); +} + +static NoteListDataSource noteListDataSource; +static this() { + import data.impl.list; + noteListDataSource = new SqliteNoteListDataSource(); +} diff --git a/litelist-api/source/data/model.d b/litelist-api/source/data/model.d new file mode 100644 index 0000000..7ab2b02 --- /dev/null +++ b/litelist-api/source/data/model.d @@ -0,0 +1,22 @@ +module data.model; + +struct User { + string username; + string email; + string passwordHash; +} + +struct NoteList { + ulong id; + string name; + uint ordinality; + string description; + Note[] notes; +} + +struct Note { + ulong id; + ulong noteListId; + uint ordinality; + string content; +} diff --git a/litelist-api/source/data/note.d b/litelist-api/source/data/note.d new file mode 100644 index 0000000..0a24859 --- /dev/null +++ b/litelist-api/source/data/note.d @@ -0,0 +1,15 @@ +module data.note; + +public import data.model : Note; + +interface NoteDataSource { + Note createNote(string username, ulong noteListId, string content); + Note updateNote(string username, ulong id, Note newData); + void deleteNote(string username, ulong id); +} + +static NoteDataSource noteDataSource; +static this() { + import data.impl.note; + noteDataSource = new SqliteNoteDataSource(); +} diff --git a/litelist-api/source/data/user.d b/litelist-api/source/data/user.d new file mode 100644 index 0000000..a73c069 --- /dev/null +++ b/litelist-api/source/data/user.d @@ -0,0 +1,17 @@ +module data.user; + +public import data.model : User; + +import std.typecons : Nullable; + +interface UserDataSource { + User createUser(string username, string email, string passwordHash); + void deleteUser(string username); + Nullable!User getUser(string username); +} + +static UserDataSource userDataSource; +static this() { + import data.impl.user; + userDataSource = new FileSystemUserDataSource(); +} diff --git a/litelist-api/source/endpoints/auth.d b/litelist-api/source/endpoints/auth.d new file mode 100644 index 0000000..a6e6f7b --- /dev/null +++ b/litelist-api/source/endpoints/auth.d @@ -0,0 +1,87 @@ +/** + * API endpoints related to authentication. + */ +module endpoints.auth; + +import handy_httpd; +import slf4d; + +import std.json; +import std.typecons; + +import auth; +import data.user; + +void handleLogin(ref HttpRequestContext ctx) { + JSONValue loginData = ctx.request.readBodyAsJson(); + if ("username" !in loginData.object || "password" !in loginData.object) { + ctx.response.setStatus(HttpStatus.BAD_REQUEST); + ctx.response.writeBodyString("Invalid login request data. Expected username and password."); + return; + } + string username = loginData.object["username"].str; + infoF!"Got login request for user \"%s\"."(username); + string password = loginData.object["password"].str; + Nullable!User userNullable = userDataSource.getUser(username); + if (userNullable.isNull) { + infoF!"User \"%s\" doesn't exist."(username); + sendUnauthenticatedResponse(ctx.response); + return; + } + User user = userNullable.get(); + + import botan.passhash.bcrypt : checkBcrypt; + if (!checkBcrypt(password, user.passwordHash)) { + sendUnauthenticatedResponse(ctx.response); + return; + } + + JSONValue resp = JSONValue(string[string].init); + resp.object["token"] = generateToken(user, loadTokenSecret()); + ctx.response.writeBodyString(resp.toString(), "application/json"); +} + +void renewToken(ref HttpRequestContext ctx) { + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; + AuthContext auth = AuthContextHolder.getOrThrow(); + + JSONValue resp = JSONValue(string[string].init); + resp.object["token"] = generateToken(auth.user, loadTokenSecret()); + ctx.response.writeBodyString(resp.toString(), "application/json"); +} + +void createNewUser(ref HttpRequestContext ctx) { + JSONValue userData = ctx.request.readBodyAsJson(); + string username = userData.object["username"].str; + string email = userData.object["email"].str; + string password = userData.object["password"].str; + + if (!userDataSource.getUser(username).isNull) { + ctx.response.setStatus(HttpStatus.BAD_REQUEST); + ctx.response.writeBodyString("Username is taken."); + return; + } + + import botan.passhash.bcrypt : generateBcrypt; + import botan.rng.auto_rng; + RandomNumberGenerator rng = new AutoSeededRNG(); + string passwordHash = generateBcrypt(password, rng, 12); + + userDataSource.createUser(username, email, passwordHash); + infoF!"Created new user: %s, %s"(username, email); +} + +void getMyUser(ref HttpRequestContext ctx) { + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; + AuthContext auth = AuthContextHolder.getOrThrow(); + JSONValue resp = JSONValue(string[string].init); + resp.object["username"] = JSONValue(auth.user.username); + resp.object["email"] = JSONValue(auth.user.email); + ctx.response.writeBodyString(resp.toString(), "application/json"); +} + +void deleteMyUser(ref HttpRequestContext ctx) { + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; + AuthContext auth = AuthContextHolder.getOrThrow(); + userDataSource.deleteUser(auth.user.username); +} diff --git a/litelist-api/source/lists.d b/litelist-api/source/endpoints/lists.d similarity index 74% rename from litelist-api/source/lists.d rename to litelist-api/source/endpoints/lists.d index 649ec87..fcecfee 100644 --- a/litelist-api/source/lists.d +++ b/litelist-api/source/endpoints/lists.d @@ -1,17 +1,19 @@ -module lists; +module endpoints.lists; import handy_httpd; + import std.json; import std.typecons; import std.string; import auth; -import data; +import data.list; +import data.note; void getNoteLists(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = AuthContextHolder.getOrThrow(); - NoteList[] lists = userDataSource.getLists(auth.user.username); + NoteList[] lists = noteListDataSource.getLists(auth.user.username); JSONValue listsArray = JSONValue(string[].init); foreach (NoteList list; lists) { listsArray.array ~= serializeList(list); @@ -20,10 +22,10 @@ void getNoteLists(ref HttpRequestContext ctx) { } void getNoteList(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = AuthContextHolder.getOrThrow(); ulong id = ctx.request.getPathParamAs!ulong("id"); - Nullable!NoteList optionalList = userDataSource.getList(auth.user.username, id); + Nullable!NoteList optionalList = noteListDataSource.getList(auth.user.username, id); if (!optionalList.isNull) { ctx.response.writeBodyString(serializeList(optionalList.get()).toString(), "application/json"); } else { @@ -32,7 +34,7 @@ void getNoteList(ref HttpRequestContext ctx) { } void createNoteList(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = AuthContextHolder.getOrThrow(); JSONValue requestBody = ctx.request.readBodyAsJson(); if ("name" !in requestBody.object) { @@ -49,36 +51,40 @@ void createNoteList(ref HttpRequestContext ctx) { if ("description" in requestBody.object) { description = strip(requestBody.object["description"].str); } - NoteList list = userDataSource.createNoteList(auth.user.username, listName, description); + NoteList list = noteListDataSource.createNoteList(auth.user.username, listName, description); ctx.response.writeBodyString(serializeList(list).toString(), "application/json"); } void createNote(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = AuthContextHolder.getOrThrow(); ulong listId = ctx.request.getPathParamAs!ulong("listId"); JSONValue requestBody = ctx.request.readBodyAsJson(); - if ("content" !in requestBody || requestBody.object["content"].type != JSONType.STRING || requestBody.object["content"].str.length < 1) { + if ( + "content" !in requestBody || + requestBody.object["content"].type != JSONType.STRING || + requestBody.object["content"].str.length < 1 + ) { ctx.response.setStatus(HttpStatus.BAD_REQUEST); ctx.response.writeBodyString("Missing string content."); return; } string content = requestBody.object["content"].str; - Note note = userDataSource.createNote(auth.user.username, listId, content); + Note note = noteDataSource.createNote(auth.user.username, listId, content); ctx.response.writeBodyString(serializeNote(note).toString(), "application/json"); } void deleteNoteList(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = AuthContextHolder.getOrThrow(); - userDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id")); + noteListDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id")); } void deleteNote(ref HttpRequestContext ctx) { - if (!validateAuthenticatedRequest(ctx)) return; + if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return; AuthContext auth = AuthContextHolder.getOrThrow(); ulong noteId = ctx.request.getPathParamAs!ulong("noteId"); - userDataSource.deleteNote(auth.user.username, noteId); + noteDataSource.deleteNote(auth.user.username, noteId); } private JSONValue serializeList(NoteList list) { @@ -101,4 +107,4 @@ private JSONValue serializeNote(Note note) { noteObj.object["noteListId"] = JSONValue(note.noteListId); noteObj.object["content"] = JSONValue(note.content); return noteObj; -} +} \ No newline at end of file diff --git a/litelist-app/index.html b/litelist-app/index.html index a888544..a728d65 100644 --- a/litelist-app/index.html +++ b/litelist-app/index.html @@ -4,7 +4,7 @@ - Vite App + LiteList
diff --git a/litelist-app/src/api/auth.ts b/litelist-app/src/api/auth.ts index 993248d..26d770f 100644 --- a/litelist-app/src/api/auth.ts +++ b/litelist-app/src/api/auth.ts @@ -47,6 +47,15 @@ export async function login(username: string, password: string): Promise { + const response = await fetch(API_URL + "/register", { + method: "POST", + body: JSON.stringify({username: username, email: email, password: password}) + }) + if (response.ok) return; + throw response +} + export async function getMyUser(token: string): Promise { const userResponse = await fetch(API_URL + "/me", { headers: { diff --git a/litelist-app/src/views/ListsView.vue b/litelist-app/src/views/ListsView.vue index b0aaaad..ffa9563 100644 --- a/litelist-app/src/views/ListsView.vue +++ b/litelist-app/src/views/ListsView.vue @@ -62,10 +62,11 @@ function getListItemStyle(list: NoteList) {

Lists

-
+
+
@@ -93,10 +94,6 @@ function getListItemStyle(list: NoteList) {

- -
- -
@@ -105,6 +102,10 @@ h1 { text-align: center; } +.buttons-list button { + margin-right: 1rem; +} + .note-list-item { display: block; margin: 1rem 0; @@ -121,6 +122,7 @@ h1 { .note-list-item h3 { margin: 0; + font-size: xx-large; } .note-list-item p { diff --git a/litelist-app/src/views/LoginView.vue b/litelist-app/src/views/LoginView.vue index d959864..c882c72 100644 --- a/litelist-app/src/views/LoginView.vue +++ b/litelist-app/src/views/LoginView.vue @@ -1,6 +1,6 @@ @@ -75,7 +136,7 @@ h1 { text-align: center; } -form { +.login-container { max-width: 50ch; margin: 0 auto; background-color: #efefef; diff --git a/litelist-app/src/views/SingleListView.vue b/litelist-app/src/views/SingleListView.vue index b155e13..b1cc96e 100644 --- a/litelist-app/src/views/SingleListView.vue +++ b/litelist-app/src/views/SingleListView.vue @@ -84,6 +84,7 @@ async function createNoteAndRefresh() { Delete this List + @@ -108,7 +109,9 @@ async function createNoteAndRefresh() { /> - +

+ There are no notes in this list. +

@@ -170,6 +173,10 @@ h1 { display: block; } +.form-row button { + margin-right: 1rem; +} + .form-row input { font-size: medium; padding: 0.25rem;