Add the ability to download attachments.
Build and Deploy Web App / build-and-deploy (push) Successful in 24s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m21s Details

This commit is contained in:
andrewlalis 2025-09-06 19:07:57 -04:00
parent da01198e0c
commit 5bda6812d6
9 changed files with 130 additions and 11 deletions

View File

@ -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;

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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),

View File

@ -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);

View File

@ -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");
}

View File

@ -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.
}

View File

@ -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')
}
}

View File

@ -1,6 +1,9 @@
<script setup lang="ts">
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
import AppButton from '@/components/common/AppButton.vue';
import { AttachmentApiClient } from '@/api/attachment';
import { useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth-store';
interface ExistingFile {
id: number
@ -37,10 +40,14 @@ interface Props {
disabled?: boolean,
initialFiles?: ExistingFile[]
}
const route = useRoute()
const authStore = useAuthStore()
const props = withDefaults(defineProps<Props>(), {
disabled: false,
initialFiles: () => []
})
const previousInitialFiles: Ref<ExistingFile[]> = ref([])
const fileInput = useTemplateRef('fileInput')
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
@ -51,9 +58,13 @@ const files: Ref<FileListItem[]> = ref([])
onMounted(() => {
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
previousInitialFiles.value = [...props.initialFiles]
// If input initial files change, reset the file selector to just those.
watch(() => props.initialFiles, () => {
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
if (previousInitialFiles.value !== props.initialFiles) {
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
previousInitialFiles.value = [...props.initialFiles]
}
})
// When our internal model changes, update the defined uploaded/removed files models.
watch(() => files, () => {
@ -94,6 +105,12 @@ function onFileDeleteClicked(idx: number) {
}
files.value.splice(idx, 1)
}
function downloadFile(attachmentId: number) {
const api = new AttachmentApiClient(route)
if (!authStore.state) return
api.downloadAttachment(attachmentId, authStore.state.token)
}
</script>
<template>
<div class="file-selector">
@ -106,7 +123,8 @@ function onFileDeleteClicked(idx: number) {
</div>
</div>
<div>
<AppButton v-if="!disabled" icon="trash" button-type="button" @click="onFileDeleteClicked(idx)" />
<AppButton icon="download" @click="downloadFile(file.id)" v-if="(file instanceof ExistingFileListItem)" />
<AppButton v-if="!disabled" icon="trash" @click="onFileDeleteClicked(idx)" />
</div>
</div>
</div>
@ -115,7 +133,7 @@ function onFileDeleteClicked(idx: number) {
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
:disabled="disabled" />
<label for="fileInput">
<AppButton icon="upload" button-type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
<AppButton icon="upload" type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
</AppButton>
</label>
</div>