diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index a05edb4..1ddaa90 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -29,7 +29,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { h.map(HttpMethod.POST, "/register", &postRegister); h.map(HttpMethod.GET, "/register/username-availability", &getUsernameAvailability); - + // Authenticated endpoints: PathHandler a = new PathHandler(); @@ -46,6 +46,9 @@ 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); + 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); // Account endpoints: import account.api; diff --git a/finnow-api/source/attachment/api.d b/finnow-api/source/attachment/api.d new file mode 100644 index 0000000..662e345 --- /dev/null +++ b/finnow-api/source/attachment/api.d @@ -0,0 +1,47 @@ +module attachment.api; + +import handy_http_primitives; +import handy_http_data.json; +import std.conv; + +import profile.data; +import profile.data_impl_sqlite; +import profile.service; +import auth.service; +import auth.model; +import util.data; +import attachment.data; +import attachment.model; + +/** + * Handles downloading of an attachment. Because the browser is doing the + * downloading instead of a Javascript client, this means we have to embed the + * user's auth token in the query parameters and deal with it differently from + * normal authenticated requests. + * Params: + * request = The HTTP request. + * response = The HTTP response. + */ +void handleDownloadAttachment(ref ServerHttpRequest request, ref ServerHttpResponse response) { + Optional!AuthContext authCtx = extractAuthContextFromQueryParam(request, response); + if (authCtx.isNull) return; + User user = authCtx.value.user; + string profileName = request.getPathParamOrThrow!string("profile"); + ProfileRepository repo = new FileSystemProfileRepository(user.username); + ProfileContext profileCtx = repo.findByName(profileName) + .mapIfPresent!(p => ProfileContext(p, user)) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + ProfileDataSource ds = getProfileDataSource(profileCtx); + + ulong attachmentId = request.getPathParamOrThrow!ulong("attachmentId"); + AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); + Attachment attachment = attachmentRepo.findById(attachmentId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND, "Attachment not found.")); + ubyte[] content = attachmentRepo.getContent(attachment.id) + .orElseThrow(() => new HttpStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Couldn't get content for attachment." + )); + response.headers.add("Content-Disposition", "attachment; filename=\"" ~ attachment.filename ~ "\""); + response.writeBodyBytes(content, attachment.contentType); +} \ No newline at end of file diff --git a/finnow-api/source/attachment/data.d b/finnow-api/source/attachment/data.d index 9644364..30413f4 100644 --- a/finnow-api/source/attachment/data.d +++ b/finnow-api/source/attachment/data.d @@ -11,4 +11,5 @@ interface AttachmentRepository { Attachment[] findAllByValueRecordId(ulong valueRecordId); ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content); void remove(ulong id); + Optional!(ubyte[]) getContent(ulong id); } diff --git a/finnow-api/source/attachment/data_impl_sqlite.d b/finnow-api/source/attachment/data_impl_sqlite.d index b4a64a4..96ec5fc 100644 --- a/finnow-api/source/attachment/data_impl_sqlite.d +++ b/finnow-api/source/attachment/data_impl_sqlite.d @@ -71,6 +71,15 @@ SQL", ); } + Optional!(ubyte[]) getContent(ulong id) { + return util.sqlite.findOne( + db, + "SELECT content FROM attachment WHERE id = ?", + (row) => row.peek!(ubyte[])(0), + id + ); + } + static Attachment parseAttachment(Row row) { return Attachment( row.peek!ulong(0), diff --git a/finnow-api/source/auth/service.d b/finnow-api/source/auth/service.d index 3a0d0c9..9734212 100644 --- a/finnow-api/source/auth/service.d +++ b/finnow-api/source/auth/service.d @@ -112,7 +112,7 @@ AuthContext getAuthContext(in ServerHttpRequest request) { return cast(AuthContext) request.contextData[AuthenticationFilter.AUTH_METADATA_KEY]; } -private Optional!AuthContext extractAuthContextFromBearerToken( +Optional!AuthContext extractAuthContextFromBearerToken( ref ServerHttpRequest request, ref ServerHttpResponse response ) { @@ -144,6 +144,28 @@ private Optional!AuthContext extractAuthContextFromBearerToken( return Optional!AuthContext.of(new AuthContext(optionalUser.value)); } +Optional!AuthContext extractAuthContextFromQueryParam( + ref ServerHttpRequest request, + ref ServerHttpResponse response +) { + import jwt4d; + + string rawToken = request.getParamAs!(string)("t"); + JwtClaims claims; + try { + 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 ServerHttpResponse response, string msg) { response.status = HttpStatus.UNAUTHORIZED; response.writeBodyString(msg, ContentTypes.TEXT_PLAIN); diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 262ce2b..4a5acf8 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -68,7 +68,7 @@ void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse resp } private ulong getTransactionIdOrThrow(in ServerHttpRequest request) { - return getPathParamOrThrow(request, "transactionId"); + return getPathParamOrThrow!ulong(request, "transactionId"); } // Vendors API @@ -110,7 +110,7 @@ void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse re } private ulong getVendorId(in ServerHttpRequest request) { - return getPathParamOrThrow(request, "vendorId"); + return getPathParamOrThrow!ulong(request, "vendorId"); } // Categories API @@ -154,5 +154,5 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse } private ulong getCategoryId(in ServerHttpRequest request) { - return getPathParamOrThrow(request, "categoryId"); + return getPathParamOrThrow!ulong(request, "categoryId"); } diff --git a/finnow-api/source/util/data.d b/finnow-api/source/util/data.d index cf92fc1..b28a1b0 100644 --- a/finnow-api/source/util/data.d +++ b/finnow-api/source/util/data.d @@ -27,13 +27,13 @@ auto serializeOptional(T)(Optional!T value) { return Nullable!T(value.value); } -ulong getPathParamOrThrow(T = ulong)(in ServerHttpRequest req, string name) { +T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) { import handy_http_handlers.path_handler; - import std.conv; + import std.conv : to, ConvException; foreach (param; getPathParams(req)) { if (param.name == name) { try { - return param.value.to!T; + return to!T(param.value); } catch (ConvException e) { // Skip and throw if no params match. } diff --git a/web-app/src/api/attachment.ts b/web-app/src/api/attachment.ts new file mode 100644 index 0000000..4e3e4f1 --- /dev/null +++ b/web-app/src/api/attachment.ts @@ -0,0 +1,19 @@ +import type { RouteLocation } from 'vue-router' +import { ApiClient } from './base' +import { getSelectedProfile } from './profile' + +export class AttachmentApiClient extends ApiClient { + readonly profileName: string + + constructor(route: RouteLocation) { + super() + this.profileName = getSelectedProfile(route) + } + + downloadAttachment(attachmentId: number, token: string) { + const url = + import.meta.env.VITE_API_BASE_URL + + `/profiles/${this.profileName}/attachments/${attachmentId}/download?t=${token}` + window.open(url, '_blank') + } +} diff --git a/web-app/src/components/common/FileSelector.vue b/web-app/src/components/common/FileSelector.vue index 528f2a0..37db825 100644 --- a/web-app/src/components/common/FileSelector.vue +++ b/web-app/src/components/common/FileSelector.vue @@ -1,6 +1,9 @@