From bed9562d6b1263dfd97f42ec0f544e543eed9bb6 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Wed, 3 Dec 2025 11:01:33 -0500 Subject: [PATCH] Added ability to download entire profile SQLite file. --- finnow-api/source/api_mapping.d | 7 ++ finnow-api/source/profile/api.d | 38 +++++++++ finnow-api/source/profile/data.d | 9 +++ finnow-api/source/profile/data_impl_sqlite.d | 21 +++++ web-app/src/api/base.ts | 22 +++++ web-app/src/api/profile.ts | 4 + web-app/src/pages/home/AnalyticsModule.vue | 81 +++++++++++++++---- web-app/src/pages/home/ProfileModule.vue | 13 +++ .../home/analytics/BalanceTimeSeriesChart.vue | 54 ++++++++----- 9 files changed, 216 insertions(+), 33 deletions(-) diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 220a99e..b2d8ef6 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -47,6 +47,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { a.map(HttpMethod.GET, PROFILE_PATH, &handleGetProfile); a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile); a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties); + a.map(HttpMethod.GET, PROFILE_PATH ~ "/download", &handleDownloadProfile); import attachment.api; // Note: the download endpoint is public! We authenticate via token in query params instead of header here. h.map(HttpMethod.GET, PROFILE_PATH ~ "/attachments/:attachmentId/download", &handleDownloadAttachment); @@ -161,6 +162,7 @@ private class CorsFilter : HttpRequestFilter { response.headers.add("Access-Control-Allow-Origin", webOrigin); response.headers.add("Access-Control-Allow-Methods", "*"); response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type"); + response.headers.add("Access-Control-Expose-Headers", "Content-Disposition"); filterChain.doFilter(request, response); } } @@ -200,6 +202,11 @@ private class ExceptionHandlingFilter : HttpRequestFilter { error(e); response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.writeBodyString("An error occurred: " ~ e.msg); + } catch (Throwable e) { + errorF!"A throwable was caught! %s %s"(e.msg, e.info); + response.status = HttpStatus.INTERNAL_SERVER_ERROR; + response.writeBodyString("An error occurred."); + throw e; } } } diff --git a/finnow-api/source/profile/api.d b/finnow-api/source/profile/api.d index 752e2b2..5dcfee3 100644 --- a/finnow-api/source/profile/api.d +++ b/finnow-api/source/profile/api.d @@ -5,6 +5,7 @@ import asdf; import handy_http_primitives; import handy_http_data.json; import handy_http_handlers.path_handler : getPathParamAs; +import slf4d; import profile.model; import profile.service; @@ -62,4 +63,41 @@ void handleGetProperties(ref ServerHttpRequest request, ref ServerHttpResponse r auto propsRepo = ds.getPropertiesRepository(); ProfileProperty[] props = propsRepo.findAll(); writeJsonBody(response, props); +} + +void handleDownloadProfile(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileContext profileCtx = getProfileContextOrThrow(request); + ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); + Optional!ProfileDownloadData data = profileRepo.getProfileData(profileCtx.profile.name); + if (data.isNull) { + response.status = HttpStatus.NOT_FOUND; + response.writeBodyString("Profile data not found."); + return; + } + + import streams : StreamResult; + import std.conv : to; + response.headers.add("Content-Type", data.value.contentType); + response.headers.add("Content-Disposition", "attachment; filename=" ~ data.value.filename); + response.headers.add("Content-Length", data.value.size.to!string); + // Transfer the file: + StreamResult result; + ubyte[4096] buffer; + ulong bytesWritten = 0; + while (bytesWritten < data.value.size) { + result = data.value.inputStream.readFromStream(buffer); + if (result.hasError) { + errorF!"Failed to read from stream: %s"(result.error); + return; + } + if (result.count == 0) { + return; // Done! + } + result = response.outputStream.writeToStream(buffer[0 .. result.count]); + if (result.hasError) { + errorF!"Failed to write to stream: %s"(result.error); + return; + } + bytesWritten += result.count; + } } \ No newline at end of file diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index e60be79..905b956 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -1,9 +1,17 @@ module profile.data; import handy_http_primitives : Optional; +import streams.interfaces : InputStream; import profile.model; +struct ProfileDownloadData { + string filename; + string contentType; + ulong size; + InputStream!ubyte inputStream; +} + /// Repository for interacting with the set of profiles belonging to a user. interface ProfileRepository { Optional!Profile findByName(string name); @@ -12,6 +20,7 @@ interface ProfileRepository { void deleteByName(string name); ProfileDataSource getDataSource(in Profile profile); string getFilesPath(in Profile profile); + Optional!ProfileDownloadData getProfileData(string name); } /// Repository for accessing the properties of a profile. diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index b4376e4..65e7ac0 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -3,6 +3,8 @@ module profile.data_impl_sqlite; import slf4d; import d2sqlite3; import handy_http_primitives; +import streams.interfaces : InputStream, inputStreamObjectFor; +import streams.types : FileInputStream; import profile.data; import profile.model; @@ -78,6 +80,25 @@ class FileSystemProfileRepository : ProfileRepository { return buildPath(getProfilesDir(), profile.name ~ "_files"); } + Optional!ProfileDownloadData getProfileData(string name) { + import std.string : toStringz, format; + import std.datetime; + + string path = getProfilePath(name); + if (!exists(path)) return Optional!ProfileDownloadData.empty; + ProfileDownloadData data; + const now = Clock.currTime(UTC()); + data.filename = format!"%s_%02d-%02d-%02d_%02d-%02d-%02dz.sqlite"( + name, + now.year, now.month, now.day, + now.hour, now.minute, now.second + ); + data.contentType = "application/vnd.sqlite3"; + data.size = std.file.getSize(path); + data.inputStream = inputStreamObjectFor(FileInputStream(toStringz(path))); + return Optional!ProfileDownloadData.of(data); + } + private string getProfilesDir() { return buildPath(this.usersDir, username, "profiles"); } diff --git a/web-app/src/api/base.ts b/web-app/src/api/base.ts index 7e43aa6..3eebe0e 100644 --- a/web-app/src/api/base.ts +++ b/web-app/src/api/base.ts @@ -44,6 +44,28 @@ export abstract class ApiClient { return await r.text() } + protected async getFile(path: string): Promise { + const r = await this.doRequest('GET', path) + const blob = await r.blob() + const objURL = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = objURL + a.download = this.extractFileName(r) ?? 'file.dat' + document.body.appendChild(a) + a.click() // Trigger the download. + document.body.removeChild(a) + URL.revokeObjectURL(objURL) + } + + private extractFileName(response: Response): string | null { + const contentDisposition = response.headers.get('Content-Disposition') + if (!contentDisposition) return null + const chunks = contentDisposition.split(';').map((c) => c.trim()) + const filenameChunk = chunks.find((c) => c.startsWith('filename=')) + if (!filenameChunk) return null + return filenameChunk.split('=')[1].trim() + } + protected async postJson(path: string, body: object | undefined = undefined): Promise { const r = await this.doRequest('POST', path, body) return await r.json() diff --git a/web-app/src/api/profile.ts b/web-app/src/api/profile.ts index 240e03e..affb6f2 100644 --- a/web-app/src/api/profile.ts +++ b/web-app/src/api/profile.ts @@ -30,6 +30,10 @@ export class ProfileApiClient extends ApiClient { getProperties(profileName: string): Promise { return super.getJson(`/profiles/${profileName}/properties`) } + + downloadData(profileName: string): Promise { + return super.getFile(`/profiles/${profileName}/download`) + } } /** diff --git a/web-app/src/pages/home/AnalyticsModule.vue b/web-app/src/pages/home/AnalyticsModule.vue index d3cf23b..4e25793 100644 --- a/web-app/src/pages/home/AnalyticsModule.vue +++ b/web-app/src/pages/home/AnalyticsModule.vue @@ -1,8 +1,7 @@