Refactored API sources, added registration to app.

This commit is contained in:
Andrew Lalis 2023-08-22 10:05:26 -04:00
parent 4d4a8a9ff6
commit a7be040dea
19 changed files with 640 additions and 448 deletions

View File

@ -16,3 +16,4 @@ litelist-api-test-*
*.lst *.lst
users/ users/
application.properties

View File

@ -11,12 +11,16 @@ void main() {
server.start(); server.start();
} }
/**
* Initializes the HTTP server that this app will run.
* Returns: The HTTP server to use.
*/
private HttpServer initServer() { private HttpServer initServer() {
import handy_httpd.handlers.path_delegating_handler; import handy_httpd.handlers.path_delegating_handler;
import handy_httpd.handlers.filtered_handler; import handy_httpd.handlers.filtered_handler;
import d_properties; import d_properties;
import auth; import endpoints.auth;
import lists; import endpoints.lists;
import std.file; import std.file;
import std.conv; import std.conv;
@ -24,6 +28,7 @@ private HttpServer initServer() {
config.enableWebSockets = false; config.enableWebSockets = false;
config.workerPoolSize = 3; config.workerPoolSize = 3;
config.connectionQueueSize = 10; config.connectionQueueSize = 10;
bool useCorsHeaders = true;
if (exists("application.properties")) { if (exists("application.properties")) {
Properties props = Properties("application.properties"); Properties props = Properties("application.properties");
if (props.has("port")) { if (props.has("port")) {
@ -35,14 +40,19 @@ private HttpServer initServer() {
if (props.has("hostname")) { if (props.has("hostname")) {
config.hostname = props.get("hostname"); config.hostname = props.get("hostname");
} }
if (props.has("useCorsHeaders")) {
useCorsHeaders = props.get("useCorsHeaders").to!bool;
}
} }
// Set some CORS headers to prevent headache. if (useCorsHeaders) {
config.defaultHeaders["Access-Control-Allow-Origin"] = "*"; // Set some CORS headers to prevent headache.
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true"; config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
config.defaultHeaders["Access-Control-Allow-Methods"] = "*"; config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
config.defaultHeaders["Vary"] = "origin"; config.defaultHeaders["Access-Control-Allow-Methods"] = "*";
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization"; config.defaultHeaders["Vary"] = "origin";
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
}
immutable string API_PATH = "/api"; immutable string API_PATH = "/api";

View File

@ -1,94 +1,25 @@
/**
* Logic for user authentication.
*/
module auth; module auth;
import handy_httpd; import handy_httpd;
import handy_httpd.handlers.filtered_handler; import handy_httpd.handlers.filtered_handler;
import jwt.jwt;
import jwt.algorithms;
import slf4d; import slf4d;
import std.datetime; import data.user;
import std.json;
import std.path;
import std.file;
import std.typecons;
import data; /**
* Generates a new access token for an authenticated user.
* Params:
void handleLogin(ref HttpRequestContext ctx) { * user = The user to generate a token for.
JSONValue loginData = ctx.request.readBodyAsJson(); * secret = The secret key to use to sign the token.
if ("username" !in loginData.object || "password" !in loginData.object) { * Returns: The base-64 encoded and signed token string.
ctx.response.setStatus(HttpStatus.BAD_REQUEST); */
ctx.response.writeBodyString("Invalid login request data. Expected username and password."); string generateToken(in User user, in string secret) {
return; import jwt.jwt : Token;
} import jwt.algorithms : JWTAlgorithm;
string username = loginData.object["username"].str; import std.datetime;
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) {
Token token = new Token(JWTAlgorithm.HS512); Token token = new Token(JWTAlgorithm.HS512);
token.claims.aud("litelist-api"); token.claims.aud("litelist-api");
token.claims.sub(user.username); token.claims.sub(user.username);
@ -97,11 +28,24 @@ private string generateToken(in User user) {
return token.encode("supersecret");// TODO: Extract secret. return token.encode("supersecret");// TODO: Extract secret.
} }
private void sendUnauthenticatedResponse(ref HttpResponse resp) { void sendUnauthenticatedResponse(ref HttpResponse resp) {
resp.setStatus(HttpStatus.UNAUTHORIZED); resp.setStatus(HttpStatus.UNAUTHORIZED);
resp.writeBodyString("Invalid credentials."); 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 { struct AuthContext {
string token; string token;
User user; User user;
@ -143,9 +87,14 @@ class AuthContextHolder {
* Otherwise, sends an appropriate "unauthorized" response. * Otherwise, sends an appropriate "unauthorized" response.
* 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.
* Returns: True if the user is authenticated, or false otherwise. * 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"; immutable HEADER_NAME = "Authorization";
AuthContextHolder.reset(); AuthContextHolder.reset();
if (!ctx.request.hasHeader(HEADER_NAME)) { if (!ctx.request.hasHeader(HEADER_NAME)) {
@ -182,7 +131,13 @@ bool validateAuthenticatedRequest(ref HttpRequestContext ctx) {
} }
class TokenFilter : HttpRequestFilter { class TokenFilter : HttpRequestFilter {
private immutable string secret;
this(string secret) {
this.secret = secret;
}
void apply(ref HttpRequestContext ctx, FilterChain filterChain) { void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
if (validateAuthenticatedRequest(ctx)) filterChain.doFilter(ctx); if (validateAuthenticatedRequest(ctx, this.secret)) filterChain.doFilter(ctx);
} }
} }

View File

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

View File

@ -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, []);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,19 @@
module lists; module endpoints.lists;
import handy_httpd; import handy_httpd;
import std.json; import std.json;
import std.typecons; import std.typecons;
import std.string; import std.string;
import auth; import auth;
import data; import data.list;
import data.note;
void getNoteLists(ref HttpRequestContext ctx) { void getNoteLists(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow(); AuthContext auth = AuthContextHolder.getOrThrow();
NoteList[] lists = userDataSource.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) {
listsArray.array ~= serializeList(list); listsArray.array ~= serializeList(list);
@ -20,10 +22,10 @@ void getNoteLists(ref HttpRequestContext ctx) {
} }
void getNoteList(ref HttpRequestContext ctx) { void getNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow(); AuthContext auth = AuthContextHolder.getOrThrow();
ulong id = ctx.request.getPathParamAs!ulong("id"); 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) { if (!optionalList.isNull) {
ctx.response.writeBodyString(serializeList(optionalList.get()).toString(), "application/json"); ctx.response.writeBodyString(serializeList(optionalList.get()).toString(), "application/json");
} else { } else {
@ -32,7 +34,7 @@ void getNoteList(ref HttpRequestContext ctx) {
} }
void createNoteList(ref HttpRequestContext ctx) { void createNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow(); AuthContext auth = AuthContextHolder.getOrThrow();
JSONValue requestBody = ctx.request.readBodyAsJson(); JSONValue requestBody = ctx.request.readBodyAsJson();
if ("name" !in requestBody.object) { if ("name" !in requestBody.object) {
@ -49,36 +51,40 @@ void createNoteList(ref HttpRequestContext ctx) {
if ("description" in requestBody.object) { if ("description" in requestBody.object) {
description = strip(requestBody.object["description"].str); 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"); ctx.response.writeBodyString(serializeList(list).toString(), "application/json");
} }
void createNote(ref HttpRequestContext ctx) { void createNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow(); 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 ("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.setStatus(HttpStatus.BAD_REQUEST);
ctx.response.writeBodyString("Missing string content."); ctx.response.writeBodyString("Missing string content.");
return; return;
} }
string content = requestBody.object["content"].str; 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"); ctx.response.writeBodyString(serializeNote(note).toString(), "application/json");
} }
void deleteNoteList(ref HttpRequestContext ctx) { void deleteNoteList(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow(); 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) { void deleteNote(ref HttpRequestContext ctx) {
if (!validateAuthenticatedRequest(ctx)) return; if (!validateAuthenticatedRequest(ctx, loadTokenSecret())) return;
AuthContext auth = AuthContextHolder.getOrThrow(); AuthContext auth = AuthContextHolder.getOrThrow();
ulong noteId = ctx.request.getPathParamAs!ulong("noteId"); ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
userDataSource.deleteNote(auth.user.username, noteId); noteDataSource.deleteNote(auth.user.username, noteId);
} }
private JSONValue serializeList(NoteList list) { private JSONValue serializeList(NoteList list) {
@ -101,4 +107,4 @@ private JSONValue serializeNote(Note note) {
noteObj.object["noteListId"] = JSONValue(note.noteListId); noteObj.object["noteListId"] = JSONValue(note.noteListId);
noteObj.object["content"] = JSONValue(note.content); noteObj.object["content"] = JSONValue(note.content);
return noteObj; return noteObj;
} }

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>LiteList</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -47,6 +47,15 @@ export async function login(username: string, password: string): Promise<LoginIn
} }
} }
export async function register(username: string, email: string, password: string): Promise<void> {
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<User> { export async function getMyUser(token: string): Promise<User> {
const userResponse = await fetch(API_URL + "/me", { const userResponse = await fetch(API_URL + "/me", {
headers: { headers: {

View File

@ -62,10 +62,11 @@ function getListItemStyle(list: NoteList) {
<PageContainer> <PageContainer>
<header> <header>
<h1>Lists</h1> <h1>Lists</h1>
<div> <div class="buttons-list">
<button @click="toggleCreatingNewList()"> <button @click="toggleCreatingNewList()">
Create New List Create New List
</button> </button>
<LogOutButton/>
</div> </div>
</header> </header>
@ -93,10 +94,6 @@ function getListItemStyle(list: NoteList) {
<h3 v-text="list.name"></h3> <h3 v-text="list.name"></h3>
<p v-text="list.description"></p> <p v-text="list.description"></p>
</div> </div>
<div>
<LogOutButton/>
</div>
</PageContainer> </PageContainer>
</template> </template>
@ -105,6 +102,10 @@ h1 {
text-align: center; text-align: center;
} }
.buttons-list button {
margin-right: 1rem;
}
.note-list-item { .note-list-item {
display: block; display: block;
margin: 1rem 0; margin: 1rem 0;
@ -121,6 +122,7 @@ h1 {
.note-list-item h3 { .note-list-item h3 {
margin: 0; margin: 0;
font-size: xx-large;
} }
.note-list-item p { .note-list-item p {

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref} from "vue"; import {ref} from "vue";
import {login, type LoginError} from "@/api/auth"; import {login, type LoginError, register} from "@/api/auth";
import {useAuthStore} from "@/stores/auth"; import {useAuthStore} from "@/stores/auth";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import PageContainer from "@/components/PageContainer.vue"; import PageContainer from "@/components/PageContainer.vue";
@ -13,8 +13,7 @@ const loginModel = ref({
const registerModel = ref({ const registerModel = ref({
username: "", username: "",
password: "", password: "",
email: "", email: ""
code: ""
}) })
const registering = ref(false) const registering = ref(false)
@ -34,39 +33,101 @@ async function doLogin() {
console.error(error.message) console.error(error.message)
} }
} }
function resetRegister() {
registerModel.value.username = ""
registerModel.value.email = ""
registerModel.value.password = ""
}
async function doRegister() {
try {
await register(registerModel.value.username, registerModel.value.email, registerModel.value.password)
const info = await login(registerModel.value.username, registerModel.value.password)
await authStore.logIn(info.token, info.user)
} catch (error: any) {
console.error(error)
}
}
</script> </script>
<template> <template>
<PageContainer> <PageContainer>
<h1>LiteList</h1> <h1>LiteList</h1>
<form v-if="!registering" @submit.prevent="doLogin" @reset="resetLogin">
<div class="form-row"> <div class="login-container">
<label for="username-input">Username</label> <!-- Login form that's shown if the user is logging in (default) -->
<input <form v-if="!registering" @submit.prevent="doLogin" @reset="resetLogin">
id="username-input" <div class="form-row">
<label for="login-username-input">Username</label>
<input
id="login-username-input"
type="text"
name="username"
required
v-model="loginModel.username"
minlength="3"
/>
</div>
<div class="form-row">
<label for="login-password-input">Password</label>
<input
id="login-password-input"
type="password"
name="password"
required
v-model="loginModel.password"
minlength="8"
/>
</div>
<div class="form-row">
<button type="submit">Login</button>
</div>
</form>
<!-- Registration form that's shown if the user is registering (default) -->
<form v-if="registering" @submit.prevent="doRegister" @reset="resetRegister">
<div class="form-row">
<label for="register-username-input">Username</label>
<input
id="register-username-input"
type="text" type="text"
name="username" name="username"
required required
v-model="loginModel.username"
minlength="3" minlength="3"
/> maxlength="12"
</div> v-model="registerModel.username"
<div class="form-row"> />
<label for="password-input">Password</label> </div>
<input <div class="form-row">
id="password-input" <label for="register-email-input">Email</label>
type="password" <input
name="password" id="register-email-input"
type="email"
name="email"
required required
v-model="loginModel.password" v-model="registerModel.email"
minlength="8" />
/> </div>
</div> <div class="form-row">
<div class="form-row"> <label for="register-password-input">Password</label>
<button type="submit">Login</button> <input
<button type="button" @click="registering = true">Create an Account</button> id="register-password-input"
</div> type="password"
</form> name="password"
required
v-model="registerModel.password"
minlength="8"
/>
</div>
<div class="form-row">
<button type="submit">Register</button>
</div>
</form>
<button v-if="!registering" @click="registering = true">Create an Account</button>
<button v-if="registering" @click="registering = false">Log in with an existing account</button>
</div>
</PageContainer> </PageContainer>
</template> </template>
@ -75,7 +136,7 @@ h1 {
text-align: center; text-align: center;
} }
form { .login-container {
max-width: 50ch; max-width: 50ch;
margin: 0 auto; margin: 0 auto;
background-color: #efefef; background-color: #efefef;

View File

@ -84,6 +84,7 @@ async function createNoteAndRefresh() {
Delete this List Delete this List
</button> </button>
<button @click="router.push('/lists')">All Lists</button> <button @click="router.push('/lists')">All Lists</button>
<LogOutButton/>
</div> </div>
</header> </header>
@ -108,7 +109,9 @@ async function createNoteAndRefresh() {
/> />
</div> </div>
<LogOutButton/> <p v-if="list.notes.length === 0">
<em>There are no notes in this list.</em> <Button @click="toggleCreatingNewNote()">Add one!</Button>
</p>
<dialog id="list-delete-dialog"> <dialog id="list-delete-dialog">
<form method="dialog"> <form method="dialog">
@ -170,6 +173,10 @@ h1 {
display: block; display: block;
} }
.form-row button {
margin-right: 1rem;
}
.form-row input { .form-row input {
font-size: medium; font-size: medium;
padding: 0.25rem; padding: 0.25rem;