/** * 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)); }