Added more API stuff, front-end stuff too.
This commit is contained in:
parent
57d92d95a4
commit
69ea579ea3
|
@ -14,3 +14,5 @@ litelist-api-test-*
|
||||||
*.o
|
*.o
|
||||||
*.obj
|
*.obj
|
||||||
*.lst
|
*.lst
|
||||||
|
|
||||||
|
users/
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
],
|
],
|
||||||
"copyright": "Copyright © 2023, Andrew Lalis",
|
"copyright": "Copyright © 2023, Andrew Lalis",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"botan": "~>1.13.5",
|
||||||
"d2sqlite3": "~>1.0.0",
|
"d2sqlite3": "~>1.0.0",
|
||||||
"handy-httpd": "~>7.9.3",
|
"handy-httpd": "~>7.9.3",
|
||||||
"jwt": "~>0.4.0",
|
"jwt": "~>0.4.0",
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
{
|
{
|
||||||
"fileVersion": 1,
|
"fileVersion": 1,
|
||||||
"versions": {
|
"versions": {
|
||||||
|
"botan": "1.13.5",
|
||||||
|
"botan-math": "1.0.4",
|
||||||
"d2sqlite3": "1.0.0",
|
"d2sqlite3": "1.0.0",
|
||||||
"handy-httpd": "7.9.3",
|
"handy-httpd": "7.9.3",
|
||||||
"httparsed": "1.2.1",
|
"httparsed": "1.2.1",
|
||||||
"jwt": "0.4.0",
|
"jwt": "0.4.0",
|
||||||
|
"memutils": "1.0.9",
|
||||||
"slf4d": "2.4.2",
|
"slf4d": "2.4.2",
|
||||||
"streams": "3.5.0"
|
"streams": "3.5.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ 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 auth;
|
import auth;
|
||||||
|
import lists;
|
||||||
|
|
||||||
ServerConfig config = ServerConfig.defaultValues();
|
ServerConfig config = ServerConfig.defaultValues();
|
||||||
config.enableWebSockets = false;
|
config.enableWebSockets = false;
|
||||||
|
@ -21,20 +22,29 @@ private HttpServer initServer() {
|
||||||
config.port = 8080;
|
config.port = 8080;
|
||||||
config.connectionQueueSize = 10;
|
config.connectionQueueSize = 10;
|
||||||
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
|
config.defaultHeaders["Access-Control-Allow-Origin"] = "*";
|
||||||
|
config.defaultHeaders["Access-Control-Allow-Credentials"] = "true";
|
||||||
|
config.defaultHeaders["Vary"] = "origin";
|
||||||
|
config.defaultHeaders["Access-Control-Allow-Headers"] = "Authorization";
|
||||||
|
|
||||||
|
|
||||||
auto mainHandler = new PathDelegatingHandler();
|
auto mainHandler = new PathDelegatingHandler();
|
||||||
mainHandler.addMapping(Method.GET, "/status", (ref HttpRequestContext ctx) {
|
mainHandler.addMapping(Method.GET, "/status", (ref HttpRequestContext ctx) {
|
||||||
ctx.response.writeBodyString("online");
|
ctx.response.writeBodyString("online");
|
||||||
});
|
});
|
||||||
mainHandler.addMapping(Method.POST, "/login", &handleLogin);
|
|
||||||
|
|
||||||
// Authenticated endpoints are protected by the TokenFilter.
|
auto optionsHandler = toHandler((ref HttpRequestContext ctx) {
|
||||||
auto authEndpoints = new PathDelegatingHandler();
|
ctx.response.setStatus(HttpStatus.OK);
|
||||||
auto authHandler = new FilteredRequestHandler(
|
});
|
||||||
authEndpoints,
|
|
||||||
[new TokenFilter]
|
mainHandler.addMapping(Method.POST, "/register", &createNewUser);
|
||||||
);
|
mainHandler.addMapping(Method.POST, "/login", &handleLogin);
|
||||||
|
mainHandler.addMapping(Method.GET, "/me", &getMyUser);
|
||||||
|
mainHandler.addMapping(Method.OPTIONS, "/**", optionsHandler);
|
||||||
|
mainHandler.addMapping(Method.DELETE, "/me", &deleteMyUser);
|
||||||
|
|
||||||
|
mainHandler.addMapping(Method.GET, "/lists", &getNoteLists);
|
||||||
|
mainHandler.addMapping(Method.POST, "/lists", &createNoteList);
|
||||||
|
mainHandler.addMapping(Method.DELETE, "/lists/{id}", &deleteNoteList);
|
||||||
|
|
||||||
return new HttpServer(mainHandler, config);
|
return new HttpServer(mainHandler, config);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,18 +2,95 @@ 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 std.datetime;
|
||||||
import std.json;
|
import std.json;
|
||||||
|
import std.path;
|
||||||
|
import std.file;
|
||||||
|
import std.typecons;
|
||||||
|
|
||||||
|
import data;
|
||||||
|
|
||||||
|
|
||||||
void handleLogin(ref HttpRequestContext ctx) {
|
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);
|
JSONValue resp = JSONValue(string[string].init);
|
||||||
resp.object["token"] = "authtoken";
|
resp.object["token"] = generateToken(user);
|
||||||
ctx.response.writeBodyString(resp.toString(), "application/json");
|
ctx.response.writeBodyString(resp.toString(), "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
struct User {
|
void createNewUser(ref HttpRequestContext ctx) {
|
||||||
string username;
|
JSONValue userData = ctx.request.readBodyAsJson();
|
||||||
string email;
|
string username = userData.object["username"].str;
|
||||||
string passwordHash;
|
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.claims.aud("litelist-api");
|
||||||
|
token.claims.sub(user.username);
|
||||||
|
token.claims.exp(Clock.currTime.toUnixTime() + 5000);
|
||||||
|
token.claims.iss("litelist-api");
|
||||||
|
return token.encode("supersecret");// TODO: Extract secret.
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendUnauthenticatedResponse(ref HttpResponse resp) {
|
||||||
|
resp.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
|
resp.writeBodyString("Invalid credentials.");
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AuthContext {
|
struct AuthContext {
|
||||||
|
@ -41,32 +118,62 @@ class AuthContextHolder {
|
||||||
i.context = AuthContext(token, user);
|
i.context = AuthContext(token, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static AuthContext getOrThrow() {
|
||||||
|
auto i = getInstance();
|
||||||
|
if (!i.authenticated) throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "No authentication context.");
|
||||||
|
return i.context;
|
||||||
|
}
|
||||||
|
|
||||||
private bool authenticated;
|
private bool authenticated;
|
||||||
private AuthContext context;
|
private AuthContext context;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TokenFilter : HttpRequestFilter {
|
/**
|
||||||
|
* Validates any request that should be authenticated with an access token,
|
||||||
|
* and sets the AuthContextHolder's context if the user is authenticated.
|
||||||
|
* Otherwise, sends an appropriate "unauthorized" response.
|
||||||
|
* Params:
|
||||||
|
* ctx = The request context to validate.
|
||||||
|
* Returns: True if the user is authenticated, or false otherwise.
|
||||||
|
*/
|
||||||
|
bool validateAuthenticatedRequest(ref HttpRequestContext ctx) {
|
||||||
immutable HEADER_NAME = "Authorization";
|
immutable HEADER_NAME = "Authorization";
|
||||||
|
|
||||||
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
|
||||||
AuthContextHolder.reset();
|
AuthContextHolder.reset();
|
||||||
if (!ctx.request.hasHeader(HEADER_NAME)) {
|
if (!ctx.request.hasHeader(HEADER_NAME)) {
|
||||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
ctx.response.writeBodyString("Missing Authorization header.");
|
ctx.response.writeBodyString("Missing Authorization header.");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
string authHeader = ctx.request.getHeader(HEADER_NAME);
|
string authHeader = ctx.request.getHeader(HEADER_NAME);
|
||||||
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
|
if (authHeader.length < 7 || authHeader[0 .. 7] != "Bearer ") {
|
||||||
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
ctx.response.writeBodyString("Invalid bearer token authorization header.");
|
ctx.response.writeBodyString("Invalid bearer token authorization header.");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
string rawToken = authHeader[7 .. $];
|
string rawToken = authHeader[7 .. $];
|
||||||
|
string username;
|
||||||
|
try {
|
||||||
|
Token token = verify(rawToken, "supersecret", [JWTAlgorithm.HS512]);
|
||||||
|
username = token.claims.sub;
|
||||||
|
} catch (Exception e) {
|
||||||
|
warn("Failed to verify user token.", e);
|
||||||
|
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid token.");
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Validate token and fetch user.
|
Nullable!User user = userDataSource.getUser(username);
|
||||||
User user = User("bleh", "bleh@example.com", "faef9834rfe");
|
if (user.isNull) {
|
||||||
|
ctx.response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||||
|
ctx.response.writeBodyString("User does not exist.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
AuthContextHolder.setContext(rawToken, user);
|
AuthContextHolder.setContext(rawToken, user.get);
|
||||||
filterChain.doFilter(ctx);
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TokenFilter : HttpRequestFilter {
|
||||||
|
void apply(ref HttpRequestContext ctx, FilterChain filterChain) {
|
||||||
|
if (validateAuthenticatedRequest(ctx)) filterChain.doFilter(ctx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,213 @@
|
||||||
|
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);
|
||||||
|
NoteList createNoteList(string username, string name, string description = null);
|
||||||
|
void deleteNoteList(string username, ulong id);
|
||||||
|
Note createNote(string username, ulong noteListId, string content);
|
||||||
|
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.");
|
||||||
|
mkdir(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
Statement stmt1 = db.prepare("DELETE FROM note WHERE note_list_id = ?");
|
||||||
|
stmt1.bind(1, id);
|
||||||
|
stmt1.execute();
|
||||||
|
Statement stmt2 = db.prepare("DELETE FROM note_list WHERE id = ?");
|
||||||
|
stmt2.bind(1, id);
|
||||||
|
stmt2.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteNote(string username, ulong id) {
|
||||||
|
Database db = getDb(username);
|
||||||
|
Statement stmt = db.prepare("DELETE FROM note WHERE id = ?");
|
||||||
|
stmt.bind(1, id);
|
||||||
|
stmt.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
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,56 @@
|
||||||
|
module lists;
|
||||||
|
|
||||||
|
import handy_httpd;
|
||||||
|
import std.json;
|
||||||
|
|
||||||
|
import auth;
|
||||||
|
import data;
|
||||||
|
|
||||||
|
void getNoteLists(ref HttpRequestContext ctx) {
|
||||||
|
if (!validateAuthenticatedRequest(ctx)) return;
|
||||||
|
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||||
|
NoteList[] lists = userDataSource.getLists(auth.user.username);
|
||||||
|
JSONValue listsArray = JSONValue(string[].init);
|
||||||
|
foreach (NoteList list; lists) {
|
||||||
|
listsArray.array ~= serializeList(list);
|
||||||
|
}
|
||||||
|
ctx.response.writeBodyString(listsArray.toString(), "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
void createNoteList(ref HttpRequestContext ctx) {
|
||||||
|
if (!validateAuthenticatedRequest(ctx)) return;
|
||||||
|
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||||
|
JSONValue requestBody = ctx.request.readBodyAsJson();
|
||||||
|
string listName = requestBody.object["name"].str;
|
||||||
|
string description = requestBody.object["description"].str;
|
||||||
|
NoteList list = userDataSource.createNoteList(auth.user.username, listName, description);
|
||||||
|
ctx.response.writeBodyString(serializeList(list).toString(), "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteNoteList(ref HttpRequestContext ctx) {
|
||||||
|
if (!validateAuthenticatedRequest(ctx)) return;
|
||||||
|
AuthContext auth = AuthContextHolder.getOrThrow();
|
||||||
|
userDataSource.deleteNoteList(auth.user.username, ctx.request.getPathParamAs!ulong("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONValue serializeList(NoteList list) {
|
||||||
|
JSONValue listObj = JSONValue(string[string].init);
|
||||||
|
listObj.object["id"] = JSONValue(list.id);
|
||||||
|
listObj.object["name"] = JSONValue(list.name);
|
||||||
|
listObj.object["ordinality"] = JSONValue(list.ordinality);
|
||||||
|
listObj.object["description"] = JSONValue(list.description);
|
||||||
|
listObj.object["notes"] = JSONValue(string[].init);
|
||||||
|
foreach (Note note; list.notes) {
|
||||||
|
listObj.object["notes"].array ~= serializeNote(note);
|
||||||
|
}
|
||||||
|
return listObj;
|
||||||
|
}
|
||||||
|
|
||||||
|
private JSONValue serializeNote(Note note) {
|
||||||
|
JSONValue noteObj = JSONValue(string[string].init);
|
||||||
|
noteObj.object["id"] = JSONValue(note.id);
|
||||||
|
noteObj.object["ordinality"] = JSONValue(note.ordinality);
|
||||||
|
noteObj.object["noteListId"] = JSONValue(note.noteListId);
|
||||||
|
noteObj.object["content"] = JSONValue(note.content);
|
||||||
|
return noteObj;
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
import {API_URL} from "@/api/base";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginInfo {
|
||||||
|
user: User
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginError {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginTokenResponse {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<LoginInfo> {
|
||||||
|
let response: Response | null = null
|
||||||
|
try {
|
||||||
|
response = await fetch(
|
||||||
|
API_URL + "/login",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({username: username, password: password})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
throw {message: "Request failed: " + error.message}
|
||||||
|
}
|
||||||
|
if (response.ok) {
|
||||||
|
const content: LoginTokenResponse = await response.json()
|
||||||
|
const token = content.token
|
||||||
|
const userResponse = await fetch(API_URL + "/me", {
|
||||||
|
headers: {
|
||||||
|
"Authorization": "Bearer " + token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const user: User = await userResponse.json()
|
||||||
|
return {token: token, user: user}
|
||||||
|
} else if (response.status < 500) {
|
||||||
|
throw {message: "Invalid credentials."}
|
||||||
|
} else {
|
||||||
|
throw {message: "Server error. Try again later."}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export const API_URL = "http://localhost:8080"
|
|
@ -0,0 +1,27 @@
|
||||||
|
import {API_URL} from "@/api/base";
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
id: number
|
||||||
|
ordinality: number
|
||||||
|
content: string
|
||||||
|
noteListName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteList {
|
||||||
|
name: string
|
||||||
|
ordinality: number
|
||||||
|
description: string
|
||||||
|
notes: Note[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNoteLists(token: string): Promise<NoteList[]> {
|
||||||
|
const response = await fetch(API_URL + "/lists", {
|
||||||
|
headers: {"Authorization": "Bearer " + token}
|
||||||
|
})
|
||||||
|
if (response.ok) {
|
||||||
|
return await response.json()
|
||||||
|
} else {
|
||||||
|
console.error(response)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,31 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import LoginView from "@/views/LoginView.vue";
|
import LoginView from "@/views/LoginView.vue";
|
||||||
|
import ListsView from "@/views/ListsView.vue";
|
||||||
|
import {useAuthStore} from "@/stores/auth";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: "/",
|
||||||
name: 'login',
|
name: "home-redirect",
|
||||||
|
redirect: to => {
|
||||||
|
return "login"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "login",
|
||||||
component: LoginView
|
component: LoginView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/lists",
|
||||||
|
name: "lists",
|
||||||
|
component: ListsView,
|
||||||
|
beforeEnter: (to, from) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.authenticated) return "login"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {type Ref, ref} from "vue";
|
||||||
|
import type {User} from "@/api/auth";
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
|
const authenticated: Ref<boolean> = ref(false)
|
||||||
|
const user: Ref<User | null> = ref(null)
|
||||||
|
const token: Ref<string | null> = ref(null)
|
||||||
|
|
||||||
|
function logIn(newToken: string, newUser: User) {
|
||||||
|
authenticated.value = true
|
||||||
|
user.value = newUser
|
||||||
|
token.value = newToken
|
||||||
|
}
|
||||||
|
|
||||||
|
function logOut() {
|
||||||
|
authenticated.value = false
|
||||||
|
user.value = null
|
||||||
|
token.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {authenticated, user, token, logIn, logOut}
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AuthStore = typeof useAuthStore
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {useAuthStore} from "@/stores/auth";
|
||||||
|
import {onMounted, ref, type Ref} from "vue";
|
||||||
|
import type {NoteList} from "@/api/lists";
|
||||||
|
import {getNoteLists} from "@/api/lists";
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const noteLists: Ref<NoteList[]> = ref([])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
noteLists.value = await getNoteLists(authStore.token)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>
|
||||||
|
Lists
|
||||||
|
</h1>
|
||||||
|
<p>
|
||||||
|
Here are your lists!
|
||||||
|
</p>
|
||||||
|
<div v-for="list in noteLists" :key="list.id">
|
||||||
|
<h3 v-text="list.name"></h3>
|
||||||
|
<ul>
|
||||||
|
<li v-for="note in list.notes" :key="note.id" v-text="note.content"></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
|
@ -1,11 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
|
import {login, type LoginError} from "@/api/auth";
|
||||||
|
import {useAuthStore} from "@/stores/auth";
|
||||||
|
import {useRouter} from "vue-router";
|
||||||
|
|
||||||
const loginModel = ref({
|
const loginModel = ref({
|
||||||
username: "",
|
username: "",
|
||||||
password: ""
|
password: ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
function resetLogin() {
|
function resetLogin() {
|
||||||
loginModel.value.username = ""
|
loginModel.value.username = ""
|
||||||
loginModel.value.password = ""
|
loginModel.value.password = ""
|
||||||
|
@ -13,17 +19,11 @@ function resetLogin() {
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const info = await login(loginModel.value.username, loginModel.value.password)
|
||||||
"http://localhost:8080/login",
|
authStore.logIn(info.token, info.user)
|
||||||
{
|
await router.push("lists")
|
||||||
method: "POST",
|
} catch (error: any) {
|
||||||
mode: "no-cors",
|
console.error(error.message)
|
||||||
body: JSON.stringify(loginModel.value)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
console.log(response.json())
|
|
||||||
} catch (error: AxiosError) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in New Issue