Add the ability to download attachments.
This commit is contained in:
parent
da01198e0c
commit
5bda6812d6
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue