Refactored API sources, added registration to app.
This commit is contained in:
parent
4d4a8a9ff6
commit
a7be040dea
|
@ -16,3 +16,4 @@ litelist-api-test-*
|
||||||
*.lst
|
*.lst
|
||||||
|
|
||||||
users/
|
users/
|
||||||
|
application.properties
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (useCorsHeaders) {
|
||||||
// Set some CORS headers to prevent headache.
|
// Set some CORS headers to prevent headache.
|
||||||
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
|
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
|
||||||
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
|
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
|
||||||
config.defaultHeaders["Access-Control-Allow-Methods"] = "*";
|
config.defaultHeaders["Access-Control-Allow-Methods"] = "*";
|
||||||
config.defaultHeaders["Vary"] = "origin";
|
config.defaultHeaders["Vary"] = "origin";
|
||||||
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
|
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
|
||||||
|
}
|
||||||
|
|
||||||
immutable string API_PATH = "/api";
|
immutable string API_PATH = "/api";
|
||||||
|
|
||||||
|
|
|
@ -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 data.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;
|
import std.datetime;
|
||||||
import std.json;
|
|
||||||
import std.path;
|
|
||||||
import std.file;
|
|
||||||
import std.typecons;
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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, []);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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) {
|
|
@ -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>
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,16 +33,35 @@ 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>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<!-- Login form that's shown if the user is logging in (default) -->
|
||||||
<form v-if="!registering" @submit.prevent="doLogin" @reset="resetLogin">
|
<form v-if="!registering" @submit.prevent="doLogin" @reset="resetLogin">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="username-input">Username</label>
|
<label for="login-username-input">Username</label>
|
||||||
<input
|
<input
|
||||||
id="username-input"
|
id="login-username-input"
|
||||||
type="text"
|
type="text"
|
||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
|
@ -52,9 +70,9 @@ async function doLogin() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label for="password-input">Password</label>
|
<label for="login-password-input">Password</label>
|
||||||
<input
|
<input
|
||||||
id="password-input"
|
id="login-password-input"
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
|
@ -64,9 +82,52 @@ async function doLogin() {
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<button type="submit">Login</button>
|
<button type="submit">Login</button>
|
||||||
<button type="button" @click="registering = true">Create an Account</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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"
|
||||||
|
name="username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="12"
|
||||||
|
v-model="registerModel.username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="register-email-input">Email</label>
|
||||||
|
<input
|
||||||
|
id="register-email-input"
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
v-model="registerModel.email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="register-password-input">Password</label>
|
||||||
|
<input
|
||||||
|
id="register-password-input"
|
||||||
|
type="password"
|
||||||
|
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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue