215 lines
7.0 KiB
D
215 lines
7.0 KiB
D
|
/**
|
||
|
* This module contains everything related to a user's Profiles, including
|
||
|
* data repositories, API endpoints, models, and logic.
|
||
|
*/
|
||
|
module profile;
|
||
|
|
||
|
import handy_httpd;
|
||
|
import handy_httpd.components.optional;
|
||
|
import auth.model : User;
|
||
|
import auth.service : AuthContext, getAuthContext;
|
||
|
import data;
|
||
|
import model;
|
||
|
|
||
|
import std.json;
|
||
|
import asdf;
|
||
|
|
||
|
const DEFAULT_USERS_DIR = "users";
|
||
|
|
||
|
/**
|
||
|
* A profile is a complete set of Finnow financial data, all stored in one
|
||
|
* single database file. The profile's name is used to lookup the database
|
||
|
* partition for its data. A user may own multiple profiles.
|
||
|
*/
|
||
|
class Profile {
|
||
|
string name;
|
||
|
|
||
|
this(string name) {
|
||
|
this.name = name;
|
||
|
}
|
||
|
|
||
|
override int opCmp(Object other) const {
|
||
|
if (Profile p = cast(Profile) other) {
|
||
|
return this.name < p.name;
|
||
|
}
|
||
|
return 1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Validates a profile name.
|
||
|
* Params:
|
||
|
* name = The name to check.
|
||
|
* Returns: True if the profile name is valid.
|
||
|
*/
|
||
|
bool validateProfileName(string name) {
|
||
|
import std.regex;
|
||
|
import std.uni : toLower;
|
||
|
if (name is null || name.length < 3) return false;
|
||
|
auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`);
|
||
|
return !matchFirst(name, r).empty;
|
||
|
}
|
||
|
|
||
|
interface ProfileRepository {
|
||
|
Optional!Profile findByName(string name);
|
||
|
Profile createProfile(string name);
|
||
|
Profile[] findAll();
|
||
|
void deleteByName(string name);
|
||
|
DataSource getDataSource(in Profile profile);
|
||
|
}
|
||
|
|
||
|
class FileSystemProfileRepository : ProfileRepository {
|
||
|
import std.path;
|
||
|
import std.file;
|
||
|
import data;
|
||
|
import data.sqlite;
|
||
|
|
||
|
private const string usersDir;
|
||
|
private const string username;
|
||
|
|
||
|
this(string usersDir, string username) {
|
||
|
this.usersDir = usersDir;
|
||
|
this.username = username;
|
||
|
}
|
||
|
|
||
|
this(string username) {
|
||
|
this(DEFAULT_USERS_DIR, username);
|
||
|
}
|
||
|
|
||
|
Optional!Profile findByName(string name) {
|
||
|
string path = getProfilePath(name);
|
||
|
if (!exists(path)) return Optional!Profile.empty;
|
||
|
return Optional!Profile.of(new Profile(name));
|
||
|
}
|
||
|
|
||
|
Profile createProfile(string name) {
|
||
|
string path = getProfilePath(name);
|
||
|
if (exists(path)) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Profile already exists.");
|
||
|
if (!exists(getProfilesDir())) mkdir(getProfilesDir());
|
||
|
DataSource ds = new SqliteDataSource(path);
|
||
|
import std.datetime;
|
||
|
auto propsRepo = ds.getPropertiesRepository();
|
||
|
propsRepo.setProperty("name", name);
|
||
|
propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString());
|
||
|
propsRepo.setProperty("user", username);
|
||
|
return new Profile(name);
|
||
|
}
|
||
|
|
||
|
Profile[] findAll() {
|
||
|
string profilesDir = getProfilesDir();
|
||
|
if (!exists(profilesDir)) return [];
|
||
|
Profile[] profiles;
|
||
|
foreach (DirEntry entry; dirEntries(profilesDir, SpanMode.shallow, false)) {
|
||
|
import std.string : endsWith;
|
||
|
const suffix = ".sqlite";
|
||
|
if (endsWith(entry.name, suffix)) {
|
||
|
string profileName = baseName(entry.name, suffix);
|
||
|
profiles ~= new Profile(profileName);
|
||
|
}
|
||
|
}
|
||
|
import std.algorithm.sorting : sort;
|
||
|
sort(profiles);
|
||
|
return profiles;
|
||
|
}
|
||
|
|
||
|
void deleteByName(string name) {
|
||
|
string path = getProfilePath(name);
|
||
|
if (exists(path)) {
|
||
|
std.file.remove(path);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
DataSource getDataSource(in Profile profile) {
|
||
|
return new SqliteDataSource(getProfilePath(profile.name));
|
||
|
}
|
||
|
|
||
|
private string getProfilesDir() {
|
||
|
return buildPath(this.usersDir, username, "profiles");
|
||
|
}
|
||
|
|
||
|
private string getProfilePath(string name) {
|
||
|
return buildPath(this.usersDir, username, "profiles", name ~ ".sqlite");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// API Endpoints Below Here!
|
||
|
|
||
|
void handleCreateNewProfile(ref HttpRequestContext ctx) {
|
||
|
JSONValue obj = ctx.request.readBodyAsJson();
|
||
|
string name = obj.object["name"].str;
|
||
|
if (!validateProfileName(name)) {
|
||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||
|
ctx.response.writeBodyString("Invalid profile name.");
|
||
|
return;
|
||
|
}
|
||
|
AuthContext auth = getAuthContext(ctx);
|
||
|
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
||
|
profileRepo.createProfile(name);
|
||
|
}
|
||
|
|
||
|
void handleGetProfiles(ref HttpRequestContext ctx) {
|
||
|
AuthContext auth = getAuthContext(ctx);
|
||
|
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
||
|
Profile[] profiles = profileRepo.findAll();
|
||
|
ctx.response.writeBodyString(serializeToJson(profiles), "application/json");
|
||
|
}
|
||
|
|
||
|
void handleDeleteProfile(ref HttpRequestContext ctx) {
|
||
|
string name = ctx.request.getPathParamAs!string("name");
|
||
|
if (!validateProfileName(name)) {
|
||
|
ctx.response.status = HttpStatus.BAD_REQUEST;
|
||
|
ctx.response.writeBodyString("Invalid profile name.");
|
||
|
return;
|
||
|
}
|
||
|
AuthContext auth = getAuthContext(ctx);
|
||
|
ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username);
|
||
|
profileRepo.deleteByName(name);
|
||
|
}
|
||
|
|
||
|
void handleGetProperties(ref HttpRequestContext ctx) {
|
||
|
ProfileContext profileCtx = getProfileContextOrThrow(ctx);
|
||
|
ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username);
|
||
|
DataSource ds = profileRepo.getDataSource(profileCtx.profile);
|
||
|
auto propsRepo = ds.getPropertiesRepository();
|
||
|
ProfileProperty[] props = propsRepo.findAll();
|
||
|
ctx.response.writeBodyString(serializeToJson(props), "application/json");
|
||
|
}
|
||
|
|
||
|
/// Contextual information that's available when handling requests under a profile.
|
||
|
struct ProfileContext {
|
||
|
const Profile profile;
|
||
|
const User user;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Tries to get a profile context from a request context. 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.
|
||
|
* Returns: An optional profile context.
|
||
|
*/
|
||
|
Optional!ProfileContext getProfileContext(ref HttpRequestContext ctx) {
|
||
|
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("users", user.username);
|
||
|
return repo.findByName(profileName)
|
||
|
.mapIfPresent!(p => ProfileContext(p, user));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* 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.
|
||
|
* Returns: The profile context that was obtained.
|
||
|
*/
|
||
|
ProfileContext getProfileContextOrThrow(ref HttpRequestContext ctx) {
|
||
|
return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||
|
}
|