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.GET, PROFILE_PATH, &handleGetProfile);
|
||||||
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
a.map(HttpMethod.DELETE, PROFILE_PATH, &handleDeleteProfile);
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/properties", &handleGetProperties);
|
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:
|
// Account endpoints:
|
||||||
import account.api;
|
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);
|
Attachment[] findAllByValueRecordId(ulong valueRecordId);
|
||||||
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
|
||||||
void remove(ulong id);
|
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) {
|
static Attachment parseAttachment(Row row) {
|
||||||
return Attachment(
|
return Attachment(
|
||||||
row.peek!ulong(0),
|
row.peek!ulong(0),
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ AuthContext getAuthContext(in ServerHttpRequest request) {
|
||||||
return cast(AuthContext) request.contextData[AuthenticationFilter.AUTH_METADATA_KEY];
|
return cast(AuthContext) request.contextData[AuthenticationFilter.AUTH_METADATA_KEY];
|
||||||
}
|
}
|
||||||
|
|
||||||
private Optional!AuthContext extractAuthContextFromBearerToken(
|
Optional!AuthContext extractAuthContextFromBearerToken(
|
||||||
ref ServerHttpRequest request,
|
ref ServerHttpRequest request,
|
||||||
ref ServerHttpResponse response
|
ref ServerHttpResponse response
|
||||||
) {
|
) {
|
||||||
|
|
@ -144,6 +144,28 @@ private Optional!AuthContext extractAuthContextFromBearerToken(
|
||||||
return Optional!AuthContext.of(new AuthContext(optionalUser.value));
|
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) {
|
private Optional!AuthContext setUnauthorized(ref ServerHttpResponse response, string msg) {
|
||||||
response.status = HttpStatus.UNAUTHORIZED;
|
response.status = HttpStatus.UNAUTHORIZED;
|
||||||
response.writeBodyString(msg, ContentTypes.TEXT_PLAIN);
|
response.writeBodyString(msg, ContentTypes.TEXT_PLAIN);
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse resp
|
||||||
}
|
}
|
||||||
|
|
||||||
private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
|
private ulong getTransactionIdOrThrow(in ServerHttpRequest request) {
|
||||||
return getPathParamOrThrow(request, "transactionId");
|
return getPathParamOrThrow!ulong(request, "transactionId");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vendors API
|
// Vendors API
|
||||||
|
|
@ -110,7 +110,7 @@ void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse re
|
||||||
}
|
}
|
||||||
|
|
||||||
private ulong getVendorId(in ServerHttpRequest request) {
|
private ulong getVendorId(in ServerHttpRequest request) {
|
||||||
return getPathParamOrThrow(request, "vendorId");
|
return getPathParamOrThrow!ulong(request, "vendorId");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Categories API
|
// Categories API
|
||||||
|
|
@ -154,5 +154,5 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
private ulong getCategoryId(in ServerHttpRequest request) {
|
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);
|
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 handy_http_handlers.path_handler;
|
||||||
import std.conv;
|
import std.conv : to, ConvException;
|
||||||
foreach (param; getPathParams(req)) {
|
foreach (param; getPathParams(req)) {
|
||||||
if (param.name == name) {
|
if (param.name == name) {
|
||||||
try {
|
try {
|
||||||
return param.value.to!T;
|
return to!T(param.value);
|
||||||
} catch (ConvException e) {
|
} catch (ConvException e) {
|
||||||
// Skip and throw if no params match.
|
// 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">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
|
||||||
import AppButton from '@/components/common/AppButton.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 {
|
interface ExistingFile {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -37,10 +40,14 @@ interface Props {
|
||||||
disabled?: boolean,
|
disabled?: boolean,
|
||||||
initialFiles?: ExistingFile[]
|
initialFiles?: ExistingFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
initialFiles: () => []
|
initialFiles: () => []
|
||||||
})
|
})
|
||||||
|
const previousInitialFiles: Ref<ExistingFile[]> = ref([])
|
||||||
const fileInput = useTemplateRef('fileInput')
|
const fileInput = useTemplateRef('fileInput')
|
||||||
|
|
||||||
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
|
||||||
|
|
@ -51,9 +58,13 @@ const files: Ref<FileListItem[]> = ref([])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
|
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.
|
// If input initial files change, reset the file selector to just those.
|
||||||
watch(() => props.initialFiles, () => {
|
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.
|
// When our internal model changes, update the defined uploaded/removed files models.
|
||||||
watch(() => files, () => {
|
watch(() => files, () => {
|
||||||
|
|
@ -94,6 +105,12 @@ function onFileDeleteClicked(idx: number) {
|
||||||
}
|
}
|
||||||
files.value.splice(idx, 1)
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="file-selector">
|
<div class="file-selector">
|
||||||
|
|
@ -106,7 +123,8 @@ function onFileDeleteClicked(idx: number) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,7 +133,7 @@ function onFileDeleteClicked(idx: number) {
|
||||||
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
|
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
|
||||||
:disabled="disabled" />
|
:disabled="disabled" />
|
||||||
<label for="fileInput">
|
<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>
|
</AppButton>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue