Added web app, refactored api to use new handyhttp.

This commit is contained in:
Andrew Lalis 2025-08-01 18:43:58 -04:00
parent 33089b3b75
commit d610e70b18
66 changed files with 6282 additions and 399 deletions

View File

@ -16,3 +16,6 @@ finnow-api-test-*
*.lst
users/
# Ignore testing RSA keys.
test-key
test-key.pub

View File

@ -0,0 +1,15 @@
meta {
name: My User copy
type: http
seq: 5
}
get {
url: {{base_url}}/me
body: none
auth: none
}
headers {
Authorization: Bearer {{access_token}}
}

View File

@ -7,9 +7,5 @@ meta {
get {
url: {{base_url}}/profiles
body: none
auth: bearer
}
auth:bearer {
token: {{access_token}}
auth: inherit
}

View File

@ -0,0 +1,11 @@
meta {
name: Status
type: http
seq: 5
}
get {
url: {{base_url}}/status
body: none
auth: none
}

View File

@ -16,6 +16,16 @@ script:pre-request {
await checkAuth();
}
net = require("net")
console.log("Testing GET /me");
try {
const resp = await axios.get(baseUrl + "/me", {headers: {"Authorization": "Bearer " + bru.getEnvVar("access_token")}});
} catch (err) {
console.log(err);
console.log(err.stack);
}
async function checkAuth() {
const access_token = bru.getEnvVar("access_token");
if (!access_token || access_token === "null") {
@ -29,6 +39,7 @@ script:pre-request {
headers: {"Authorization": "Bearer " + access_token}
});
if (resp.status === 200) {
console.info("Current access token is valid.")
return;
} else if (resp.status === 401) {
await refreshAuth();
@ -49,7 +60,7 @@ script:pre-request {
headers: {"Content-Type": "application/json"}
});
if (resp.status === 200) {
bru.setEnvVar("access_token", resp.data.token);
bru.setEnvVar("access_token", resp.data);
console.info("Refreshed access token.");
} else {
throw resp;

View File

@ -4,6 +4,3 @@ vars {
profile: test-profile-0
base_url: http://localhost:8080/api
}
vars:secret [
access_token
]

View File

@ -4,14 +4,10 @@
],
"copyright": "Copyright © 2024, Andrew Lalis",
"dependencies": {
"asdf": "~>0.7.17",
"botan": "~>1.13.6",
"d2sqlite3": "~>1.0.0",
"handy-httpd": {
"path": "/home/andrew/Code/github-andrewlalis/handy-httpd"
},
"jwt": "~>0.4.0",
"slf4d": "~>3.0.1"
"d2sqlite3": "~>1.0",
"handy-http-starter": "~>1.5",
"jwt4d": "~>0.0.2",
"secured": "~>3.0"
},
"description": "Backend API for Finnow.",
"license": "proprietary",

View File

@ -2,18 +2,22 @@
"fileVersion": 1,
"versions": {
"asdf": "0.7.17",
"botan": "1.13.6",
"botan-math": "1.0.4",
"d2sqlite3": "1.0.0",
"handy-httpd": {"path":"../../../github-andrewlalis/handy-httpd"},
"httparsed": "1.2.1",
"jwt": "0.4.0",
"memutils": "1.0.10",
"mir-algorithm": "3.22.1",
"mir-core": "1.7.1",
"dxml": "0.4.4",
"handy-http-data": "1.3.0",
"handy-http-handlers": "1.1.0",
"handy-http-primitives": "1.8.0",
"handy-http-starter": "1.5.0",
"handy-http-transport": "1.7.0",
"handy-http-websockets": "1.2.0",
"jwt4d": "0.0.2",
"mir-algorithm": "3.22.4",
"mir-core": "1.7.3",
"openssl": "3.3.4",
"path-matcher": "1.2.0",
"secured": "3.0.0",
"silly": "1.1.1",
"slf4d": "3.0.1",
"streams": "3.5.0"
"slf4d": "4.1.1",
"streams": "3.6.0"
}
}

View File

@ -0,0 +1,43 @@
Amazon
eBay
Walmart
Target
Best Buy
Costco
Home Depot
Lowe's
Kroger
CVS
Walgreens
Starbucks
McDonald's
Burger King
Subway
Pizza Hut
Domino's
Chipotle
Taco Bell
Panera Bread
Dunkin'
Chick-fil-A
Advance Auto Parts
AutoZone
Delta Air Lines
American Airlines
United Airlines
Squarespace
DigitalOcean
GitHub
Heroku
Stripe
PayPal
Verizon
ALDI
IKEA
Primark
H&M
Petco
China King
GEICO
Bank of America
Citi
1 Amazon
2 eBay
3 Walmart
4 Target
5 Best Buy
6 Costco
7 Home Depot
8 Lowe's
9 Kroger
10 CVS
11 Walgreens
12 Starbucks
13 McDonald's
14 Burger King
15 Subway
16 Pizza Hut
17 Domino's
18 Chipotle
19 Taco Bell
20 Panera Bread
21 Dunkin'
22 Chick-fil-A
23 Advance Auto Parts
24 AutoZone
25 Delta Air Lines
26 American Airlines
27 United Airlines
28 Squarespace
29 DigitalOcean
30 GitHub
31 Heroku
32 Stripe
33 PayPal
34 Verizon
35 ALDI
36 IKEA
37 Primark
38 H&M
39 Petco
40 China King
41 GEICO
42 Bank of America
43 Citi

View File

@ -4,12 +4,13 @@
*/
module account.api;
import handy_httpd;
import handy_http_primitives;
import handy_http_data.json;
import handy_http_handlers.path_handler;
import profile.service;
import account.model;
import util.money;
import util.json;
/// The data the API provides for an Account entity.
struct AccountResponse {
@ -36,21 +37,21 @@ struct AccountResponse {
}
}
void handleGetAccounts(ref HttpRequestContext ctx) {
void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import std.algorithm;
import std.array;
auto ds = getProfileDataSource(ctx);
auto ds = getProfileDataSource(request);
auto accounts = ds.getAccountRepository().findAll()
.map!(a => AccountResponse.of(a)).array;
writeJsonBody(ctx, accounts);
writeJsonBody(response, accounts);
}
void handleGetAccount(ref HttpRequestContext ctx) {
ulong accountId = ctx.request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(ctx);
void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
auto account = ds.getAccountRepository().findById(accountId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(ctx, AccountResponse.of(account));
writeJsonBody(response, AccountResponse.of(account));
}
// The data provided by a user to create a new account.
@ -62,9 +63,9 @@ struct AccountCreationPayload {
string description;
}
void handleCreateAccount(ref HttpRequestContext ctx) {
auto ds = getProfileDataSource(ctx);
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request);
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
// TODO: Validate the account creation payload.
AccountType type = AccountType.fromId(payload.type);
Currency currency = Currency.ofCode(payload.currency);
@ -75,11 +76,11 @@ void handleCreateAccount(ref HttpRequestContext ctx) {
currency,
payload.description
);
writeJsonBody(ctx, AccountResponse.of(account));
writeJsonBody(response, AccountResponse.of(account));
}
void handleDeleteAccount(ref HttpRequestContext ctx) {
ulong accountId = ctx.request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(ctx);
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
ds.getAccountRepository().deleteById(accountId);
}

View File

@ -1,6 +1,6 @@
module account.data;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import account.model;
import util.money;

View File

@ -3,7 +3,7 @@ module account.data_impl_sqlite;
import std.datetime;
import d2sqlite3;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import account.data;
import account.model;

View File

@ -1,81 +1,125 @@
module api_mapping;
import handy_httpd;
import handy_httpd.handlers.path_handler;
import handy_httpd.handlers.filtered_handler;
import handy_http_primitives;
import handy_http_handlers.path_handler;
import handy_http_handlers.filtered_handler;
/// The base path to all API endpoints.
private const API_PATH = "/api";
/**
* Defines the Finnow API mapping with a main PathHandler.
* Returns: The handler to plug into an HttpServer.
*/
PathHandler mapApiHandlers() {
/// The base path to all API endpoints.
const API_PATH = "/api";
HttpRequestHandler mapApiHandlers() {
PathHandler h = new PathHandler();
// Generic, public endpoints:
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
h.map(HttpMethod.GET, "/status", &getStatus);
h.map(HttpMethod.OPTIONS, "/**", &getOptions);
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
h.addMapping(Method.POST, API_PATH ~ "/sample-data", &sampleDataEndpoint);
h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint);
// Auth endpoints:
import auth.api;
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
h.addMapping(Method.POST, API_PATH ~ "/register", &postRegister);
h.addMapping(Method.GET, API_PATH ~ "/register/username-availability", &getUsernameAvailability);
h.map(HttpMethod.POST, "/login", &postLogin);
h.map(HttpMethod.POST, "/register", &postRegister);
h.map(HttpMethod.GET, "/register/username-availability", &getUsernameAvailability);
// Authenticated endpoints:
PathHandler a = new PathHandler();
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
a.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
a.map(HttpMethod.GET, "/me", &getMyUser);
a.map(HttpMethod.DELETE, "/me", &deleteMyUser);
a.map(HttpMethod.GET, "/me/token", &getNewToken);
import profile.api;
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile);
a.map(HttpMethod.GET, "/profiles", &handleGetProfiles);
a.map(HttpMethod.POST, "/profiles", &handleCreateNewProfile);
/// URL path to a specific profile, with the :profile path parameter.
const PROFILE_PATH = API_PATH ~ "/profiles/:profile";
a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile);
a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
const PROFILE_PATH = "/profiles/:profile";
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
// Account endpoints:
import account.api;
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
import transaction.api;
// Transaction vendor endpoints:
a.addMapping(Method.GET, PROFILE_PATH ~ "/vendors", &getVendors);
a.addMapping(Method.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor);
a.addMapping(Method.POST, PROFILE_PATH ~ "/vendors", &createVendor);
a.addMapping(Method.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor);
a.addMapping(Method.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &getVendors);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor);
a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &createVendor);
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor);
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor);
a.addMapping(Method.GET, PROFILE_PATH ~ "/transactions", &getTransactions);
a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &getTransactions);
// Protect all authenticated paths with a token filter.
import auth.service : TokenAuthenticationFilter, SECRET;
HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET);
h.addMapping(API_PATH ~ "/**", new FilteredRequestHandler(
a,
[tokenAuthenticationFilter]
// Protect all authenticated paths with a filter.
import auth.service : AuthenticationFilter;
HttpRequestFilter authenticationFilter = new AuthenticationFilter();
h.addMapping(API_PATH ~ "/**", new FilteredHandler(
[authenticationFilter],
a
));
return h;
return new CorsHandler(h);
}
private void getStatus(ref HttpRequestContext ctx) {
ctx.response.writeBodyString("online");
private void getStatus(ref ServerHttpRequest request, ref ServerHttpResponse response) {
response.writeBodyString("online", ContentTypes.TEXT_PLAIN);
}
private void getOptions(ref HttpRequestContext ctx) {}
private void getOptions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
// Do nothing, just return 200 OK.
}
private void sampleDataEndpoint(ref HttpRequestContext ctx) {
private void addCorsHeaders(ref ServerHttpResponse response) {
response.headers.add("Access-Control-Allow-Origin", "*");
response.headers.add("Access-Control-Allow-Methods", "*");
response.headers.add("Access-Control-Allow-Headers", "*");
}
private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import slf4d;
import util.sample_data;
import core.thread;
Thread t = new Thread(() => generateSampleData());
Thread t = new Thread(() {
try {
generateSampleData();
} catch (Exception e) {
error("Error while generating sample data.", e);
}
});
t.start();
info("Started new thread to generate sample data.");
}
private void map(
PathHandler handler,
HttpMethod method,
string subPath,
void function(ref ServerHttpRequest, ref ServerHttpResponse) fn
) {
handler.addMapping(method, API_PATH ~ subPath, HttpRequestHandler.of(fn));
}
private class CorsHandler : HttpRequestHandler {
private HttpRequestHandler handler;
this(HttpRequestHandler handler) {
this.handler = handler;
}
void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) {
response.headers.add("Access-Control-Allow-Origin", "*");
response.headers.add("Access-Control-Allow-Methods", "*");
response.headers.add("Access-Control-Allow-Headers", "*");
this.handler.handle(request, response);
}
}

View File

@ -1,15 +1,13 @@
import handy_httpd;
import handy_http_transport;
import slf4d;
import slf4d.default_provider;
import api_mapping;
void main() {
auto provider = new DefaultProvider(true, Levels.INFO);
auto provider = new DefaultProvider(Levels.INFO);
configureLoggingProvider(provider);
ServerConfig cfg;
cfg.workerPoolSize = 5;
cfg.port = 8080;
HttpServer server = new HttpServer(mapApiHandlers(), cfg);
server.start();
HttpTransport transport = new TaskPoolHttp1Transport(mapApiHandlers());
transport.start();
}

View File

@ -1,6 +1,6 @@
module attachment.data;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import attachment.model;
import std.datetime;

View File

@ -1,6 +1,6 @@
module attachment.data_impl_sqlite;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import d2sqlite3;
import attachment.model;

View File

@ -1,64 +1,46 @@
/// API endpoints for authentication-related functions, like registration and login.
module auth.api;
import handy_httpd;
import handy_httpd.components.optional;
import handy_http_primitives;
import handy_http_data.json;
import slf4d;
import auth.model;
import auth.data;
import auth.service;
import auth.data_impl_fs;
import util.json;
/// The credentials provided by a user to login.
struct LoginCredentials {
string username;
string password;
}
/// A token response sent to the user if they've been authenticated.
struct TokenResponse {
const string token;
}
void postLogin(ref HttpRequestContext ctx) {
LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx);
if (!validateUsername(loginCredentials.username)) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
ctx.response.writeBodyString("Username is not valid.");
return;
void postLogin(ref ServerHttpRequest request, ref ServerHttpResponse response) {
struct LoginData {
string username;
string password;
}
UserRepository userRepo = new FileSystemUserRepository();
Optional!User optionalUser = userRepo.findByUsername(loginCredentials.username);
if (optionalUser.isNull) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
return;
}
import botan.passhash.bcrypt : checkBcrypt;
if (!checkBcrypt(loginCredentials.password, optionalUser.value.passwordHash)) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
return;
}
string token = generateAccessToken(optionalUser.value);
writeJsonBody(ctx, TokenResponse(token));
LoginData data = readJsonBodyAs!LoginData(request);
string token = generateTokenForLogin(data.username, data.password);
response.writeBodyString(token);
infoF!"Generated token for user: %s"(data.username);
}
struct UsernameAvailabilityResponse {
const bool available;
}
void getUsernameAvailability(ref HttpRequestContext ctx) {
Optional!string username = ctx.request.queryParams.getFirst("username");
if (username.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Missing username parameter.");
void getUsernameAvailability(ref ServerHttpRequest request, ref ServerHttpResponse response) {
string username = null;
foreach (param; request.queryParams) {
if (param.key == "username" && param.values.length > 0) {
username = param.values[0];
break;
}
}
if (username is null || username.length == 0) {
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Missing username parameter.");
return;
}
UserRepository userRepo = new FileSystemUserRepository();
bool available = userRepo.findByUsername(username.value).isNull;
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
bool available = userRepo.findByUsername(username).isNull;
writeJsonBody(response, UsernameAvailabilityResponse(available));
}
struct RegistrationData {
@ -66,39 +48,45 @@ struct RegistrationData {
string password;
}
void postRegister(ref HttpRequestContext ctx) {
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
void postRegister(ref ServerHttpRequest request, ref ServerHttpResponse response) {
RegistrationData registrationData = readJsonBodyAs!RegistrationData(request);
if (!validateUsername(registrationData.username)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid username.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Invalid username.");
return;
}
if (!validatePassword(registrationData.password)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid password.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Invalid password.");
return;
}
UserRepository userRepo = new FileSystemUserRepository();
if (!userRepo.findByUsername(registrationData.username).isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Username is taken.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Username is taken.");
return;
}
User user = createNewUser(userRepo, registrationData.username, registrationData.password);
infoF!"Created user: %s"(registrationData.username);
string token = generateAccessToken(user);
writeJsonBody(ctx, TokenResponse(token));
response.writeBodyString(user.username);
}
void getMyUser(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
ctx.response.writeBodyString(auth.user.username);
void getMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request);
response.writeBodyString(auth.user.username);
}
void deleteMyUser(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
void deleteMyUser(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request);
UserRepository userRepo = new FileSystemUserRepository();
deleteUser(auth.user, userRepo);
infoF!"Deleted user: %s"(auth.user.username);
}
void getNewToken(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request);
string token = generateTokenForUser(auth.user);
response.writeBodyString(token);
infoF!"Generated token for user: %s"(auth.user.username);
}

View File

@ -1,6 +1,6 @@
module auth.data;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import auth.model;
interface UserRepository {

View File

@ -1,6 +1,6 @@
module auth.data_impl_fs;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import auth.data;
import auth.model;
@ -31,6 +31,7 @@ class FileSystemUserRepository : UserRepository {
}
User[] findAll() {
if (!exists(this.usersDir)) return [];
User[] users;
foreach (DirEntry entry; dirEntries(this.usersDir, SpanMode.shallow, false)) {
string username = baseName(entry.name);

View File

@ -1,22 +1,20 @@
module auth.service;
import handy_httpd;
import handy_httpd.components.optional;
import handy_httpd.handlers.filtered_handler;
import slf4d;
import handy_http_primitives;
import handy_http_handlers.filtered_handler;
import auth.model;
import auth.data;
import auth.data_impl_fs;
import auth.tokens;
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
const ubyte[] PASSWORD_HASH_PEPPER = []; // Example pepper for password hashing
User createNewUser(UserRepository repo, string username, string password) {
import botan.passhash.bcrypt : generateBcrypt;
import botan.rng.auto_rng;
RandomNumberGenerator rng = new AutoSeededRNG();
string passwordHash = generateBcrypt(password, rng, 12);
return repo.createUser(username, passwordHash);
import secured.kdf;
HashedPassword hash = securePassword(password, PASSWORD_HASH_PEPPER);
return repo.createUser(username, hash.toString());
}
void deleteUser(User user, UserRepository repo) {
@ -24,50 +22,67 @@ void deleteUser(User user, UserRepository repo) {
}
/**
* Generates a new JWT access token for a user.
* Generates a new token for a user who's logging in with the given credentials.
* Params:
* username = The user's username.
* password = The user's password.
* Returns: A JWT the user may use to authenticate requests to the API.
*/
string generateTokenForLogin(string username, string password) {
import secured.kdf;
UserRepository userRepo = new FileSystemUserRepository();
Optional!User optionalUser = userRepo.findByUsername(username);
if (optionalUser.isNull) {
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
}
User user = optionalUser.value;
auto verificationResult = verifyPassword(password, HashedPassword(user.passwordHash), PASSWORD_HASH_PEPPER);
if (verificationResult == VerifyPasswordResult.Failure) {
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
}
return generateTokenForUser(user);
}
/**
* Generates a new token for a user.
* Params:
* user = The user to generate the token for.
* Returns: The token.
* Returns: A JWT the user may use to authenticate requests to the API.
*/
string generateAccessToken(User user) {
import jwt.jwt : Token;
import jwt.algorithms : JWTAlgorithm;
string generateTokenForUser(in User user) {
import jwt4d;
import std.datetime;
const TIMEOUT_MINUTES = 30;
Token token = new Token(JWTAlgorithm.HS512);
token.claims.aud("finnow-api");
token.claims.sub(user.username);
token.claims.exp(Clock.currTime().toUnixTime() + TIMEOUT_MINUTES * 60);
token.claims.iss("finnow-api");
return token.encode(SECRET);
JwtClaims claims = JwtClaims()
.issuer("finnow")
.subject(user.username)
.expiresIn(minutes(30))
.issuedAtNow();
return writeJwt(claims, "test");
}
/**
* A request filter that only permits authenticated requests to be processed.
*/
class TokenAuthenticationFilter : HttpRequestFilter {
class AuthenticationFilter : HttpRequestFilter {
private static const AUTH_METADATA_KEY = "AuthContext";
private immutable string secret;
this(string secret) {
this.secret = secret;
}
void apply(ref HttpRequestContext ctx, FilterChain fc) {
Optional!AuthContext optionalAuth = validateAuthContext(ctx);
void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain fc) {
Optional!AuthContext optionalAuth = extractAuthContextFromBearerToken(request, response);
debugF!"Extracted auth context from bearer token: %s"(optionalAuth.value);
if (!optionalAuth.isNull) {
ctx.metadata[AUTH_METADATA_KEY] = optionalAuth.value;
fc.doFilter(ctx); // Only continue the filter chain if a valid auth context was obtained.
debugF!"Request was authenticated for user: %s"(optionalAuth.value.user.username);
request.contextData[AUTH_METADATA_KEY] = optionalAuth.value;
fc.doFilter(request, response); // Only continue the filter chain if a valid auth context was obtained.
debug_("Filter chain was called.");
}
}
}
/// Information about the current request's authentication status.
class AuthContext {
string token;
User user;
this(string token, User user) {
this.token = token;
this(User user) {
this.user = user;
}
}
@ -76,46 +91,47 @@ class AuthContext {
* Helper method to get the authentication context from a request context
* that was previously passed through this filter.
* Params:
* ctx = The request context to get.
* request = The request to get.
* Returns: The auth context that has been set.
*/
AuthContext getAuthContext(in HttpRequestContext ctx) {
return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY];
AuthContext getAuthContext(in ServerHttpRequest request) {
return cast(AuthContext) request.contextData[AuthenticationFilter.AUTH_METADATA_KEY];
}
private Optional!AuthContext validateAuthContext(ref HttpRequestContext ctx) {
import jwt.jwt : verify, Token;
import jwt.algorithms : JWTAlgorithm;
import std.typecons;
private Optional!AuthContext extractAuthContextFromBearerToken(
ref ServerHttpRequest request,
ref ServerHttpResponse response
) {
import jwt4d;
const HEADER_NAME = "Authorization";
if (!ctx.request.headers.contains(HEADER_NAME)) {
return setUnauthorized(ctx, "Missing Authorization header.");
if (!(HEADER_NAME in request.headers)) {
return setUnauthorized(response, "Missing Authorization header.");
}
string authorizationHeader = ctx.request.headers.getFirst(HEADER_NAME).orElse("");
string authorizationHeader = request.headers[HEADER_NAME][0];
if (authorizationHeader.length < 7 || authorizationHeader[0..7] != "Bearer ") {
return setUnauthorized(ctx, "Invalid Authorization header format. Expected bearer token.");
return setUnauthorized(response, "Invalid Authorization header format. Expected bearer token.");
}
string rawToken = authorizationHeader[7..$];
JwtClaims claims;
try {
Token token = verify(rawToken, SECRET, [JWTAlgorithm.HS512]);
string username = token.claims.sub;
UserRepository userRepo = new FileSystemUserRepository();
Optional!User optionalUser = userRepo.findByUsername(username);
if (optionalUser.isNull) {
return setUnauthorized(ctx, "User does not exist.");
}
return Optional!AuthContext.of(new AuthContext(rawToken, optionalUser.value));
} catch (Exception e) {
warn("Failed to verify user token.", e);
return setUnauthorized(ctx, "Invalid or malformed token.");
claims = readJwt(rawToken, "test");
} catch (JwtException e) {
return setUnauthorized(response, e.message.idup);
}
UserRepository userRepo = new FileSystemUserRepository();
Optional!User optionalUser = userRepo.findByUsername(claims.subject);
if (optionalUser.isNull) {
return setUnauthorized(response, "Invalid user.");
}
return Optional!AuthContext.of(new AuthContext(optionalUser.value));
}
private Optional!AuthContext setUnauthorized(ref HttpRequestContext ctx, string msg) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
ctx.response.writeBodyString(msg);
private Optional!AuthContext setUnauthorized(ref ServerHttpResponse response, string msg) {
response.status = HttpStatus.UNAUTHORIZED;
response.writeBodyString(msg, ContentTypes.TEXT_PLAIN);
return Optional!AuthContext.empty;
}

View File

@ -0,0 +1,101 @@
module auth.tokens;
import slf4d;
import secured.rsa;
import secured.util;
import std.datetime;
import std.base64;
import std.file;
import asdf : serializeToJson, deserialize;
import handy_http_primitives : Optional, HttpStatusException, HttpStatus;
import streams : Either;
const TOKEN_EXPIRATION = minutes(60);
/**
* Definition of the token's payload.
*/
private struct TokenData {
string username;
string issuedAt;
}
/**
* Definition for the entire token JSON object, including the payload, and a
* signature of the payload generated with the server's private key.
*/
private struct TokenObject {
/// The token's data.
TokenData data;
/// The base64-encoded cryptographic signature of `data`.
string sig;
}
/**
* Generates a new token for the given user.
* Params:
* username = The username to generate the token for.
* Returns: A new token that the user can provide to authenticate requests.
*/
string generateToken(in string username) {
auto data = TokenData(username, Clock.currTime(UTC()).toISOExtString());
RSA rsa = getPrivateKey();
try {
string dataJson = serializeToJson(data);
ubyte[] signature = rsa.sign(cast(ubyte[]) dataJson);
TokenObject obj = TokenObject(data, Base64.encode(signature));
string jsonStr = serializeToJson(obj);
return Base64.encode(cast(ubyte[]) jsonStr);
} catch (CryptographicException e) {
error("Failed to sign token data.", e);
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to generate token.");
}
}
/// Possible errors that can occur when verifying a token.
enum TokenVerificationFailure {
InvalidSignature,
Expired
}
/**
* Result of token verification, which is the user's username if the token is
* valid, or a failure reason if not.
*/
alias TokenVerificationResult = Either!(string, "username", TokenVerificationFailure, "failure");
/**
* Verifies a token and returns the username, if it's valid.
* Params:
* token = The token to verify.
* Returns: A token verification result.
*/
TokenVerificationResult verifyToken(in string token) {
string jsonStr = cast(string) Base64.decode(cast(ubyte[]) token);
TokenObject decodedToken = deserialize!TokenObject(jsonStr);
string dataJson = serializeToJson(decodedToken.data);
ubyte[] signature = Base64.decode(decodedToken.sig);
RSA rsa = getPrivateKey();
if (!rsa.verify(cast(ubyte[]) dataJson, signature)) {
warnF!"Failed to verify token signature for user: %s"(decodedToken.data.username);
return TokenVerificationResult(TokenVerificationFailure.InvalidSignature);
}
// We have verified the signature, so now we can check various properties of the token.
// Check that the token is not expired.
SysTime issuedAt = SysTime.fromISOExtString(decodedToken.data.issuedAt, UTC());
SysTime now = Clock.currTime(UTC());
Duration diff = now - issuedAt;
if (diff > TOKEN_EXPIRATION) {
warnF!"Token for user %s has expired."(decodedToken.data.username);
return TokenVerificationResult(TokenVerificationFailure.Expired);
}
return TokenVerificationResult(decodedToken.data.username);
}
private RSA getPrivateKey() {
ubyte[] pkData = cast(ubyte[]) readText("test-key");
return new RSA(pkData, null);
}

View File

@ -1,8 +1,7 @@
module history.data;
import std.datetime;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import history.model;

View File

@ -1,8 +1,7 @@
module history.data_impl_sqlite;
import std.datetime;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import d2sqlite3;
import history.data;

View File

@ -1,9 +1,10 @@
module profile.api;
import std.json;
import handy_httpd;
import asdf;
import handy_http_primitives;
import handy_http_data.json;
import handy_http_handlers.path_handler : getPathParamAs;
import profile.model;
import profile.service;
@ -11,45 +12,48 @@ import profile.data;
import profile.data_impl_sqlite;
import auth.model;
import auth.service;
import util.json;
void handleCreateNewProfile(ref HttpRequestContext ctx) {
JSONValue obj = ctx.request.readBodyAsJson();
string name = obj.object["name"].str;
struct NewProfilePayload {
string name;
}
void handleCreateNewProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto payload = readJsonBodyAs!NewProfilePayload(request);
string name = payload.name;
if (!validateProfileName(name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid profile name.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Invalid profile name.");
return;
}
AuthContext auth = getAuthContext(ctx);
AuthContext auth = getAuthContext(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
profileRepo.createProfile(name);
}
void handleGetProfiles(ref HttpRequestContext ctx) {
AuthContext auth = getAuthContext(ctx);
void handleGetProfiles(ref ServerHttpRequest request, ref ServerHttpResponse response) {
AuthContext auth = getAuthContext(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
Profile[] profiles = profileRepo.findAll();
writeJsonBody(ctx, profiles);
writeJsonBody(response, profiles);
}
void handleDeleteProfile(ref HttpRequestContext ctx) {
string name = ctx.request.getPathParamAs!string("name");
void handleDeleteProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) {
string name = request.getPathParamAs!string("profile");
if (!validateProfileName(name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid profile name.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Invalid profile name.");
return;
}
AuthContext auth = getAuthContext(ctx);
AuthContext auth = getAuthContext(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
profileRepo.deleteByName(name);
}
void handleGetProperties(ref HttpRequestContext ctx) {
ProfileContext profileCtx = getProfileContextOrThrow(ctx);
void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileContext profileCtx = getProfileContextOrThrow(request);
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
ProfileDataSource ds = profileRepo.getDataSource(profileCtx.profile);
auto propsRepo = ds.getPropertiesRepository();
ProfileProperty[] props = propsRepo.findAll();
writeJsonBody(ctx, props);
writeJsonBody(response, props);
}

View File

@ -1,6 +1,7 @@
module profile.data;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import profile.model;
/// Repository for interacting with the set of profiles belonging to a user.

View File

@ -1,10 +1,8 @@
module profile.data_impl_sqlite;
import slf4d;
import handy_httpd.components.optional;
import handy_httpd.components.handler : HttpStatusException;
import handy_httpd.components.response : HttpStatus;
import d2sqlite3;
import handy_http_primitives;
import profile.data;
import profile.model;

View File

@ -1,7 +1,7 @@
module profile.service;
import handy_httpd;
import handy_httpd.components.optional;
import handy_http_primitives;
import handy_http_handlers.path_handler;
import profile.model;
import profile.data;
@ -29,35 +29,39 @@ struct ProfileContext {
}
/**
* Tries to get a profile context from a request context. This will attempt to
* Tries to get a profile context from a request. This will attempt to
* extract a "profile" path parameter and the authenticated user, and combine
* them into the ProfileContext.
* Params:
* ctx = The request context to read.
* request = The request to read.
* Returns: An optional profile context.
*/
Optional!ProfileContext getProfileContext(in HttpRequestContext ctx) {
Optional!ProfileContext getProfileContext(in ServerHttpRequest request) {
import auth.service : AuthContext, getAuthContext;
if ("profile" !in ctx.request.pathParams) return Optional!ProfileContext.empty;
string profileName = ctx.request.pathParams["profile"];
if (!validateProfileName(profileName)) return Optional!ProfileContext.empty;
AuthContext authCtx = getAuthContext(ctx);
if (authCtx is null) return Optional!ProfileContext.empty;
User user = authCtx.user;
ProfileRepository repo = new FileSystemProfileRepository(user.username);
return repo.findByName(profileName)
.mapIfPresent!(p => ProfileContext(p, user));
foreach (param; getPathParams(request)) {
if (param.name == "profile") {
string profileName = param.value;
if (!validateProfileName(profileName)) return Optional!ProfileContext.empty;
AuthContext authCtx = getAuthContext(request);
if (authCtx is null) return Optional!ProfileContext.empty;
User user = authCtx.user;
ProfileRepository repo = new FileSystemProfileRepository(user.username);
return repo.findByName(profileName)
.mapIfPresent!(p => ProfileContext(p, user));
}
}
return Optional!ProfileContext.empty;
}
/**
* Similar to `getProfileContext`, but throws an HttpStatusException with a
* 404 NOT FOUND status if no profile context could be obtained.
* Params:
* ctx = The request context to read.
* request = The request to read.
* Returns: The profile context that was obtained.
*/
ProfileContext getProfileContextOrThrow(in HttpRequestContext ctx) {
return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ProfileContext getProfileContextOrThrow(in ServerHttpRequest request) {
return getProfileContext(request).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
}
/**
@ -76,10 +80,10 @@ ProfileDataSource getProfileDataSource(in ProfileContext pc) {
* Obtains a ProfileDataSource from an HTTP request context. Use this to
* access all profile data when handling the request.
* Params:
* ctx = The request context.
* request = The request.
* Returns: The profile data source.
*/
ProfileDataSource getProfileDataSource(in HttpRequestContext ctx) {
ProfileContext pc = getProfileContextOrThrow(ctx);
ProfileDataSource getProfileDataSource(in ServerHttpRequest request) {
ProfileContext pc = getProfileContextOrThrow(request);
return getProfileDataSource(pc);
}

View File

@ -1,43 +1,45 @@
module transaction.api;
import handy_httpd;
import handy_http_primitives;
import handy_http_data.json;
import handy_http_handlers.path_handler;
import transaction.model;
import transaction.data;
import transaction.service;
import profile.data;
import profile.service;
import util.json;
import util.money;
import util.pagination;
immutable DEFAULT_TRANSACTION_PAGE = PageRequest(0, 10, [Sort("created_at", SortDir.DESC)]);
void getTransactions(ref HttpRequestContext ctx) {
ProfileDataSource ds = getProfileDataSource(ctx);
PageRequest pr = PageRequest.parse(ctx, DEFAULT_TRANSACTION_PAGE);
Page!Transaction page = searchTransactions(ds, pr);
void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE);
Page!Transaction page = ds.getTransactionRepository().findAll(pr);
writeJsonBody(response, page);
}
void getVendors(ref HttpRequestContext ctx) {
ProfileDataSource ds = getProfileDataSource(ctx);
void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository();
TransactionVendor[] vendors = vendorRepo.findAll();
writeJsonBody(ctx, vendors);
writeJsonBody(response, vendors);
}
void getVendor(ref HttpRequestContext ctx) {
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
void getVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
long vendorId = request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("Missing vendorId path parameter.");
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(ctx);
ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository();
TransactionVendor vendor = vendorRepo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(ctx, vendor);
writeJsonBody(response, vendor);
}
struct VendorPayload {
@ -45,51 +47,52 @@ struct VendorPayload {
string description;
}
void createVendor(ref HttpRequestContext ctx) {
VendorPayload payload = readJsonPayload!VendorPayload(ctx);
ProfileDataSource ds = getProfileDataSource(ctx);
void createVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request);
ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository();
if (vendorRepo.existsByName(payload.name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Vendor name is already in use.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Vendor name is already in use.");
return;
}
TransactionVendor vendor = vendorRepo.insert(payload.name, payload.description);
writeJsonBody(ctx, vendor);
writeJsonBody(response, vendor);
}
void updateVendor(ref HttpRequestContext ctx) {
VendorPayload payload = readJsonPayload!VendorPayload(ctx);
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
void updateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
VendorPayload payload = readJsonBodyAs!VendorPayload(request);
long vendorId = request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("Missing vendorId path parameter.");
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(ctx);
ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository();
TransactionVendor existingVendor = vendorRepo.findById(vendorId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
if (payload.name != existingVendor.name && vendorRepo.existsByName(payload.name)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Vendor name is already in use.");
response.status = HttpStatus.BAD_REQUEST;
response.writeBodyString("Vendor name is already in use.");
return;
}
TransactionVendor updated = vendorRepo.updateById(
vendorId,
payload.name,
payload.description
);
writeJsonBody(ctx, updated);
writeJsonBody(response, updated);
}
void deleteVendor(ref HttpRequestContext ctx) {
long vendorId = ctx.request.getPathParamAs!long("vendorId", -1);
void deleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) {
long vendorId = request.getPathParamAs!long("vendorId", -1);
if (vendorId == -1) {
ctx.response.status = HttpStatus.NOT_FOUND;
ctx.response.writeBodyString("Missing vendorId path parameter.");
response.status = HttpStatus.NOT_FOUND;
response.writeBodyString("Missing vendorId path parameter.");
return;
}
ProfileDataSource ds = getProfileDataSource(ctx);
ProfileDataSource ds = getProfileDataSource(request);
auto vendorRepo = ds.getTransactionVendorRepository();
vendorRepo.deleteById(vendorId);
}

View File

@ -1,10 +1,11 @@
module transaction.data;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import std.datetime;
import transaction.model;
import util.money;
import util.pagination;
interface TransactionVendorRepository {
Optional!TransactionVendor findById(ulong id);
@ -32,6 +33,7 @@ interface TransactionTagRepository {
}
interface TransactionRepository {
Page!Transaction findAll(PageRequest pr);
Optional!Transaction findById(ulong id);
Transaction insert(
SysTime timestamp,

View File

@ -1,6 +1,6 @@
module transaction.data_impl_sqlite;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import std.datetime;
import d2sqlite3;
@ -8,6 +8,8 @@ import transaction.model;
import transaction.data;
import util.sqlite;
import util.money;
import util.pagination;
import util.data;
class SqliteTransactionVendorRepository : TransactionVendorRepository {
private Database db;
@ -95,7 +97,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
"INSERT INTO transaction_category
(parent_id, name, description, color)
VALUES (?, ?, ?, ?)",
parentId.asNullable(), name, description, color
toNullable(parentId), name, description, color
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
@ -120,7 +122,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
import std.typecons;
return TransactionCategory(
row.peek!ulong(0),
Optional!ulong.of(row.peek!(Nullable!ulong)(1)),
toOptional(row.peek!(Nullable!ulong)(1)),
row.peek!string(2),
row.peek!string(3),
row.peek!string(4)
@ -187,6 +189,18 @@ class SqliteTransactionRepository : TransactionRepository {
this.db = db;
}
Page!Transaction findAll(PageRequest pr) {
// TODO: Implement filtering or something!
import std.array;
auto sqlBuilder = appender!string;
sqlBuilder ~= "SELECT * FROM " ~ TABLE_NAME;
sqlBuilder ~= " ";
sqlBuilder ~= pr.toSql();
string query = sqlBuilder[];
Transaction[] results = util.sqlite.findAll(db, query, &parseTransaction);
return Page!Transaction(results, pr);
}
Optional!Transaction findById(ulong id) {
return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id);
}
@ -210,8 +224,8 @@ class SqliteTransactionRepository : TransactionRepository {
amount,
currency.code,
description,
vendorId.asNullable,
categoryId.asNullable
toNullable(vendorId),
toNullable(categoryId)
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
@ -230,8 +244,8 @@ class SqliteTransactionRepository : TransactionRepository {
row.peek!ulong(3),
Currency.ofCode(row.peek!(string, PeekMode.slice)(4)),
row.peek!string(5),
Optional!(ulong).of(row.peek!(Nullable!ulong)(6)),
Optional!(ulong).of(row.peek!(Nullable!ulong)(7))
toOptional(row.peek!(Nullable!ulong)(6)),
toOptional(row.peek!(Nullable!ulong)(7))
);
}
}

View File

@ -1,6 +1,6 @@
module transaction.model;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import std.datetime;
import util.money;

View File

@ -1,6 +1,6 @@
module transaction.service;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import std.datetime;
import transaction.model;
@ -61,7 +61,3 @@ void addTransaction(
}
});
}
Page!Transaction searchTransactions(ProfileDataSource ds, PageRequest pr) {
}

View File

@ -0,0 +1,20 @@
module util.data;
import handy_http_primitives;
import std.typecons;
Optional!T toOptional(T)(Nullable!T value) {
if (value.isNull) {
return Optional!T.empty;
} else {
return Optional!T.of(value.get);
}
}
Nullable!T toNullable(T)(Optional!T value) {
if (value.isNull) {
return Nullable!T.init;
} else {
return Nullable!T(value.value);
}
}

View File

@ -1,41 +0,0 @@
/// Utilities for reading and writing JSON in HTTP request contexts.
module util.json;
import handy_httpd;
import slf4d;
import asdf;
/**
* Reads a JSON payload into a type T. Throws an `HttpStatusException` if
* the data cannot be read or converted to the given type, with a 400 BAD
* REQUEST status. The type T should not have `const` members.
* Params:
* ctx = The request context to read from.
* Returns: The data that was read.
*/
T readJsonPayload(T)(ref HttpRequestContext ctx) {
try {
string requestBody = ctx.request.readBodyAsString();
return deserialize!T(requestBody);
} catch (SerdeException e) {
debug_("Got an exception while deserializing a request body.", e);
throw new HttpStatusException(HttpStatus.BAD_REQUEST);
}
}
/**
* Writes data of type T to a JSON response body. Throws an `HttpStatusException`
* with status 501 INTERNAL SERVER ERROR if serialization fails.
* Params:
* ctx = The request context to write to.
* data = The data to write.
*/
void writeJsonBody(T)(ref HttpRequestContext ctx, in T data) {
try {
string jsonStr = serializeToJson(data);
ctx.response.writeBodyString(jsonStr, "application/json");
} catch (SerdeException e) {
debug_("Exception while serializing a response body.", e);
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}

View File

@ -1,8 +1,9 @@
/**
* Defines various components useful for paginated operations.
*/
module util.pagination;
import handy_httpd;
import handy_httpd.components.multivalue_map;
import handy_httpd.components.optional;
import handy_http_primitives;
import std.conv;
@ -47,14 +48,14 @@ struct PageRequest {
return PageRequest(0, 0, []);
}
static PageRequest parse(ref HttpRequestContext ctx, PageRequest defaults) {
static PageRequest parse(in ServerHttpRequest request, PageRequest defaults) {
import std.algorithm;
import std.array;
const(StringMultiValueMap) params = ctx.request.queryParams;
uint pg = ctx.request.getParamAs!uint("page", defaults.page);
ushort sz = ctx.request.getParamAs!ushort("size", defaults.size);
Sort[] s = params.getAll("sort")
.map!(Sort.parse)
uint pg = request.getParamAs!uint("page", defaults.page);
ushort sz = request.getParamAs!ushort("size", defaults.size);
Sort[] s = request.queryParams
.filter!(p => p.key == "sort" && p.values.length > 0)
.map!(p => Sort.parse(p.values[0]))
.filter!(o => !o.isNull)
.map!(o => o.value)
.array;

View File

@ -1,6 +1,6 @@
module util.repository;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import d2sqlite3;
import util.sqlite;

View File

@ -1,7 +1,7 @@
module util.sample_data;
import slf4d;
import handy_httpd.components.optional;
import handy_http_primitives : Optional;
import auth;
import profile;

View File

@ -3,8 +3,8 @@ module util.sqlite;
import std.datetime;
import slf4d;
import handy_httpd.components.optional;
import d2sqlite3;
import handy_http_primitives : Optional;
/**
* Tries to find a single row from a database.

View File

@ -0,0 +1,46 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class Account {
final int id;
final DateTime createdAt;
final bool archived;
final String type;
final String numberSuffix;
final String name;
final String currency;
final String description;
const Account(this.id, this.createdAt, this.archived, this.type,
this.numberSuffix, this.name, this.currency, this.description);
factory Account.fromJson(Map<String, dynamic> data) {
final id = data['id'] as int;
final createdAtStr = data['createdAt'] as String;
final createdAt = DateTime.parse(createdAtStr);
final archived = data['archived'] as bool;
final type = data['type'] as String;
final numberSuffix = data['numberSuffix'] as String;
final name = data['name'] as String;
final currency = data['currency'] as String;
final description = data['description'] as String;
return Account(id, createdAt, archived, type, numberSuffix, name, currency,
description);
}
}
Future<List<Account>> getAccounts(String token, String profileName) async {
final http.Response response = await http.get(
Uri.parse('http://localhost:8080/api/profiles/$profileName/accounts'),
headers: {
'Authorization': 'Bearer $token'
}
);
if (response.statusCode == 200) {
if (jsonDecode(response.body) == null) return [];
final data = jsonDecode(response.body) as List<dynamic>;
return data.map((obj) => Account.fromJson(obj)).toList();
} else {
throw Exception('Failed to get accounts.');
}
}

View File

@ -29,3 +29,15 @@ Future<List<Profile>> getProfiles(String token) async {
throw Exception('Failed to get profiles.');
}
}
Future<void> deleteProfile(String token, Profile profile) async {
final http.Response response = await http.delete(
Uri.parse('http://localhost:8080/api/profiles/${profile.name}'),
headers: {
'Authorization': 'Bearer $token'
}
);
if (response.statusCode != 200) {
throw Exception('Failed to delete profile ${profile.name}');
}
}

View File

@ -1,4 +1,5 @@
import 'package:finnow_app/api/profile.dart';
import 'package:finnow_app/auth/model.dart';
import 'package:finnow_app/main.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -6,27 +7,61 @@ import 'package:go_router/go_router.dart';
/// A list item that shows a profile in the user's list of all profiles.
class ProfileListItem extends StatelessWidget {
final Profile profile;
const ProfileListItem(this.profile, {super.key});
final Function onDeletedCallback;
const ProfileListItem(this.profile, this.onDeletedCallback, {super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
getIt<GoRouter>().go('/profiles/${profile.name}');
},
child: Container(
constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(top: 10, bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10), color: const Color.fromARGB(255, 168, 233, 170)),
child: Row(children: [
Expanded(child: Text(profile.name)),
TextButton(
onPressed: () {
print('Removing profile: ${profile.name}');
},
child: const Text('Remove'))
])));
return Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(top: 10, bottom: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: const Color.fromARGB(255, 236, 236, 236)),
constraints: const BoxConstraints(maxWidth: 200),
child: Row(children: [
Expanded(child: Text(profile.name)),
ElevatedButton(
child: const Text('View'),
onPressed: () => getIt<GoRouter>().go('/profiles/${profile.name}'),
),
const SizedBox(width: 5),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => attemptDeleteProfile(context),
)
]));
}
void attemptDeleteProfile(BuildContext ctx) async {
bool confirmed = false;
await showDialog(
context: ctx,
builder: (context) {
return AlertDialog(
title: const Text('Confirm Profile Deletion'),
content: const Text(
'Are you sure you want to delete this profile? This will permamently delete all accounts, transactions, and other data in this profile. This data is not recoverable.'),
actions: [
FilledButton(
onPressed: () {
confirmed = true;
Navigator.of(context).pop();
},
child: const Text('Ok')),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('Cancel'))
]);
});
if (confirmed) {
final auth = getIt<AuthenticationModel>();
if (auth.state is Authenticated) {
await deleteProfile((auth.state as Authenticated).token, profile);
onDeletedCallback();
}
}
}
}

View File

@ -14,14 +14,13 @@ class ProfilesPage extends StatelessWidget {
body: const Padding(
padding: EdgeInsets.all(10),
child: Column(children: [
Text('Select a Profile', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
Text('Select a Profile',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
SizedBox(height: 10),
Expanded(child: _ProfilesListView()),
])),
floatingActionButton: FloatingActionButton(
onPressed: () => print('pressed'),
child: const Icon(Icons.add)
),
onPressed: () => print('pressed'), child: const Icon(Icons.add)),
);
}
}
@ -40,7 +39,7 @@ class __ProfilesListViewState extends State<_ProfilesListView> {
Widget build(BuildContext context) {
return ListView(
scrollDirection: Axis.vertical,
children: profiles.map(ProfileListItem.new).toList(),
children: profiles.map((p) => ProfileListItem(p, () => refreshProfiles())).toList(),
);
}

View File

@ -21,7 +21,7 @@ class UserAccountPage extends StatelessWidget {
Text(auth.username),
const SizedBox(height: 10),
Row(children: [
TextButton(
FilledButton(
onPressed: () => attemptDeleteUser(context),
child: const Text('Delete my user'))
])
@ -36,19 +36,19 @@ class UserAccountPage extends StatelessWidget {
return AlertDialog(
title: const Text('Confirm User Deletion'),
content: const Text(
'Are you sure you want to delete your user? This is a permanent action that cannot be undone.'),
'Are you sure you want to delete your user? This is a permanent action that cannot be undone. All your profiles will be deleted.'),
actions: [
FilledButton(
onPressed: () {
confirmed = true;
Navigator.of(context).pop();
},
child: Text('Ok')),
child: const Text('Ok')),
FilledButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text('Cancel'))
child: const Text('Cancel'))
]);
});
if (confirmed) {

View File

@ -47,9 +47,9 @@ GoRouter getRouterConfig() {
}),
]),
GoRoute(
path: '/user-account',
builder: (ctx, state) => UserAccountPage(getIt<AuthenticationModel>().state as Authenticated)
)
path: '/user-account',
builder: (ctx, state) => UserAccountPage(
getIt<AuthenticationModel>().state as Authenticated))
]),
]);
}
@ -64,12 +64,13 @@ Widget getAppScaffold(BuildContext context, GoRouterState state, Widget child) {
backgroundColor: Colors.grey,
actions: [
IconButton(
onPressed: () {
final router = getIt<GoRouter>();
router.push('/user-account');
},
icon: const Icon(Icons.account_circle)
),
onPressed: () {
final router = GoRouter.of(context);
if (state.fullPath != '/user-account') {
router.push('/user-account');
}
},
icon: const Icon(Icons.account_circle)),
IconButton(
onPressed: () =>
getIt<AuthenticationModel>().state = Unauthenticated(),

8
web-app/.editorconfig Normal file
View File

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
web-app/.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
* text=auto eol=lf

30
web-app/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

6
web-app/.prettierrc.json Normal file
View File

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

8
web-app/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode"
]
}

39
web-app/README.md Normal file
View File

@ -0,0 +1,39 @@
# web-app
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

1
web-app/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

22
web-app/eslint.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
skipFormatting,
)

13
web-app/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Finnow</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

5187
web-app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
web-app/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "web-app",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"pinia": "^3.0.3",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@tsconfig/node22": "^22.0.2",
"@types/node": "^22.16.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.31.0",
"eslint-plugin-vue": "~10.3.0",
"jiti": "^2.4.2",
"npm-run-all2": "^8.0.4",
"prettier": "3.6.2",
"typescript": "~5.8.0",
"vite": "^7.0.6",
"vite-plugin-vue-devtools": "^8.0.0",
"vue-tsc": "^3.0.4"
}
}

BIN
web-app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

23
web-app/src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { AuthApiClient } from './api/auth';
onMounted(async () => {
console.log('mounted!')
const client = new AuthApiClient()
console.log(await client.getApiStatus())
const token = await client.login('testuser0', 'testpass')
console.log('logged in with token', token)
})
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
</template>
<style scoped></style>

18
web-app/src/api/auth.ts Normal file
View File

@ -0,0 +1,18 @@
import { ApiClient, ApiError, type ApiResponse } from './base'
export class AuthApiClient extends ApiClient {
async login(username: string, password: string): ApiResponse<string> {
return await super.postText('/login', { username, password })
}
async register(username: string, password: string): ApiResponse<void> {
const r = await super.post('/register', { username, password })
if (r instanceof ApiError) return r
}
async getUsernameAvailability(username: string): ApiResponse<boolean> {
const r = await super.post('/register/username-availability?username=' + username)
if (r instanceof ApiError) return r
return (await r.json()).available
}
}

77
web-app/src/api/base.ts Normal file
View File

@ -0,0 +1,77 @@
export abstract class ApiError {
readonly message: string
constructor(message: string) {
this.message = message
}
}
export class NetworkError extends ApiError {}
export class StatusError extends ApiError {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
export type ApiResponse<T> = Promise<T | ApiError>
export class ApiClient {
private baseUrl: string = import.meta.env.VITE_API_BASE_URL
async getJson<R>(path: string): ApiResponse<R> {
const r = await this.get(path)
if (r instanceof ApiError) return r
return await r.json()
}
async postJson<R>(path: string, body: object | undefined = undefined): ApiResponse<R> {
const r = await this.post(path, body)
if (r instanceof ApiError) return r
return await r.json()
}
async postText(path: string, body: object | undefined = undefined): ApiResponse<string> {
const r = await this.post(path, body)
if (r instanceof ApiError) return r
return await r.text()
}
async get(path: string): Promise<Response | ApiError> {
try {
const response = await fetch(this.baseUrl + path)
if (!response.ok) {
throw new StatusError(response.status, 'Status error')
}
return response
} catch (error) {
console.error(error)
throw new NetworkError('Request to ' + path + ' failed.')
}
}
async post(path: string, body: object | undefined = undefined): Promise<Response | ApiError> {
try {
const response = await fetch(this.baseUrl + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
throw new StatusError(response.status, 'Status error')
}
return response
} catch (error) {
console.error(error)
throw new NetworkError('Request to ' + path + ' failed.')
}
}
async getApiStatus(): ApiResponse<boolean> {
const resp = await this.get('/status')
return !(resp instanceof ApiError)
}
}

12
web-app/src/main.ts Normal file
View File

@ -0,0 +1,12 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
})
export default router

View File

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

12
web-app/tsconfig.app.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}

11
web-app/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,19 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

18
web-app/vite.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})