attachment download

This commit is contained in:
Hayden 2022-09-19 14:33:48 -08:00
parent c61ac295ba
commit 12dee6fcc5
13 changed files with 444 additions and 50 deletions

View file

@ -224,7 +224,7 @@ const docTemplate = `{
}
}
},
"/v1/items/{id}/attachment": {
"/v1/items/{id}/attachments": {
"post": {
"security": [
{
@ -278,7 +278,44 @@ const docTemplate = `{
}
}
},
"/v1/items/{id}/attachment/{attachment_id}": {
"/v1/items/{id}/attachments/download": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/octet-stream"
],
"tags": [
"Items"
],
"summary": "retrieves an attachment for an item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Attachment token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": ""
}
}
}
},
"/v1/items/{id}/attachments/{attachment_id}": {
"get": {
"security": [
{
@ -310,7 +347,10 @@ const docTemplate = `{
],
"responses": {
"200": {
"description": ""
"description": "OK",
"schema": {
"$ref": "#/definitions/types.ItemAttachmentToken"
}
}
}
}
@ -980,6 +1020,14 @@ const docTemplate = `{
}
}
},
"types.ItemAttachmentToken": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"types.ItemCreate": {
"type": "object",
"properties": {

View file

@ -216,7 +216,7 @@
}
}
},
"/v1/items/{id}/attachment": {
"/v1/items/{id}/attachments": {
"post": {
"security": [
{
@ -270,7 +270,44 @@
}
}
},
"/v1/items/{id}/attachment/{attachment_id}": {
"/v1/items/{id}/attachments/download": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/octet-stream"
],
"tags": [
"Items"
],
"summary": "retrieves an attachment for an item",
"parameters": [
{
"type": "string",
"description": "Item ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Attachment token",
"name": "token",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": ""
}
}
}
},
"/v1/items/{id}/attachments/{attachment_id}": {
"get": {
"security": [
{
@ -302,7 +339,10 @@
],
"responses": {
"200": {
"description": ""
"description": "OK",
"schema": {
"$ref": "#/definitions/types.ItemAttachmentToken"
}
}
}
}
@ -972,6 +1012,14 @@
}
}
},
"types.ItemAttachmentToken": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"types.ItemCreate": {
"type": "object",
"properties": {

View file

@ -60,6 +60,11 @@ definitions:
updatedAt:
type: string
type: object
types.ItemAttachmentToken:
properties:
token:
type: string
type: object
types.ItemCreate:
properties:
description:
@ -501,7 +506,7 @@ paths:
summary: updates a item
tags:
- Items
/v1/items/{id}/attachment:
/v1/items/{id}/attachments:
post:
parameters:
- description: Item ID
@ -536,7 +541,7 @@ paths:
summary: imports items into the database
tags:
- Items
/v1/items/{id}/attachment/{attachment_id}:
/v1/items/{id}/attachments/{attachment_id}:
get:
parameters:
- description: Item ID
@ -551,6 +556,31 @@ paths:
type: string
produces:
- application/octet-stream
responses:
"200":
description: OK
schema:
$ref: '#/definitions/types.ItemAttachmentToken'
security:
- Bearer: []
summary: retrieves an attachment for an item
tags:
- Items
/v1/items/{id}/attachments/download:
get:
parameters:
- description: Item ID
in: path
name: id
required: true
type: string
- description: Attachment token
in: query
name: token
required: true
type: string
produces:
- application/octet-stream
responses:
"200":
description: ""

View file

@ -54,6 +54,10 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration())
r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin())
// Attachment download URl needs a `token` query param to be passed in the request.
// and also needs to be outside of the `auth` middleware.
r.Get(v1Base("/items/{id}/attachments/download"), v1Ctrl.HandleItemAttachmentDownload())
r.Group(func(r chi.Router) {
r.Use(a.mwAuthToken)
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf())
@ -82,8 +86,8 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate())
r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
r.Post(v1Base("/items/{id}/attachment"), v1Ctrl.HandleItemAttachmentCreate())
r.Get(v1Base("/items/{id}/attachment/{attachment_id}"), v1Ctrl.HandleItemAttachmentGet())
r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate())
r.Get(v1Base("/items/{id}/attachments/{attachment_id}"), v1Ctrl.HandleItemAttachmentToken())
})
}

View file

@ -205,7 +205,7 @@ func (ctrl *V1Controller) HandleItemsImport() http.HandlerFunc {
// @Param type formData string true "Type of file"
// @Param name formData string true "name of the file including extension"
// @Success 200 {object} types.ItemOut
// @Router /v1/items/{id}/attachment [POST]
// @Router /v1/items/{id}/attachments [POST]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@ -261,12 +261,39 @@ func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
// @Summary retrieves an attachment for an item
// @Tags Items
// @Produce application/octet-stream
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Param id path string true "Item ID"
// @Param token query string true "Attachment token"
// @Success 200
// @Router /v1/items/{id}/attachment/{attachment_id} [GET]
// @Router /v1/items/{id}/attachments/download [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
func (ctrl *V1Controller) HandleItemAttachmentDownload() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := server.GetParam(r, "token", "")
path, err := ctrl.svc.Items.GetAttachment(r.Context(), token)
if err != nil {
log.Err(err).Msg("failed to get attachment")
server.RespondServerError(w)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path)))
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, path)
}
}
// HandleItemAttachmentToken godocs
// @Summary retrieves an attachment for an item
// @Tags Items
// @Produce application/octet-stream
// @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID"
// @Success 200 {object} types.ItemAttachmentToken
// @Router /v1/items/{id}/attachments/{attachment_id} [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentToken() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil {
@ -280,7 +307,7 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
return
}
path, err := ctrl.svc.Items.GetAttachment(r.Context(), user.GroupID, uid, attachmentId)
token, err := ctrl.svc.Items.NewAttachmentToken(r.Context(), user.GroupID, uid, attachmentId)
if err != nil {
log.Err(err).Msg("failed to get attachment")
@ -288,8 +315,9 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path)))
w.Header().Set("Content-Type", "application/octet-stream")
http.ServeFile(w, r, path)
server.Respond(w, http.StatusOK, types.ItemAttachmentToken{
Token: token,
})
}
}

View file

@ -19,6 +19,7 @@ func NewServices(repos *repo.AllRepos, root string) *AllServices {
Items: &ItemService{
repo: repos,
filepath: root,
at: attachmentTokens{},
},
}
}

View file

@ -2,25 +2,61 @@ package services
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
"github.com/hay-kot/homebox/backend/ent/attachment"
"github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services/mappers"
"github.com/hay-kot/homebox/backend/internal/types"
"github.com/hay-kot/homebox/backend/pkgs/hasher"
"github.com/hay-kot/homebox/backend/pkgs/pathlib"
"github.com/rs/zerolog/log"
)
var (
ErrNotFound = errors.New("not found")
)
// TODO: this isn't a scalable solution, tokens should be stored in the database
type attachmentTokens map[string]uuid.UUID
func (at attachmentTokens) Add(token string, id uuid.UUID) {
at[token] = id
log.Debug().Str("token", token).Str("uuid", id.String()).Msg("added token")
go func() {
ch := time.After(1 * time.Minute)
<-ch
at.Delete(token)
log.Debug().Str("token", token).Msg("deleted token")
}()
}
func (at attachmentTokens) Get(token string) (uuid.UUID, bool) {
id, ok := at[token]
return id, ok
}
func (at attachmentTokens) Delete(token string) {
delete(at, token)
}
type ItemService struct {
repo *repo.AllRepos
// filepath is the root of the storage location that will be used to store all files from.
filepath string
// at is a map of tokens to attachment IDs. This is used to store the attachment ID
// for issued URLs
at attachmentTokens
}
func (svc *ItemService) GetOne(ctx context.Context, gid uuid.UUID, id uuid.UUID) (*types.ItemOut, error) {
@ -100,18 +136,28 @@ func (svc *ItemService) attachmentPath(gid, itemId uuid.UUID, filename string) s
return pathlib.Safe(path)
}
func (svc *ItemService) GetAttachment(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) {
// Get the Item
func (svc *ItemService) NewAttachmentToken(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) {
item, err := svc.repo.Items.GetOne(ctx, itemId)
if err != nil {
return "", err
}
if item.Edges.Group.ID != gid {
return "", ErrNotOwner
}
// Get the attachment
token := hasher.GenerateToken()
svc.at.Add(token.Raw, attachmentId)
return token.Raw, nil
}
func (svc *ItemService) GetAttachment(ctx context.Context, token string) (string, error) {
attachmentId, ok := svc.at.Get(token)
if !ok {
return "", ErrNotFound
}
attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
if err != nil {
return "", err

View file

@ -106,3 +106,7 @@ type ItemAttachment struct {
Type string `json:"type"`
Document DocumentOut `json:"document"`
}
type ItemAttachmentToken struct {
Token string `json:"token"`
}

View file

@ -0,0 +1,56 @@
<template>
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
<li
v-for="attachment in attachments"
:key="attachment.id"
class="flex items-center justify-between py-3 pl-3 pr-4 text-sm"
>
<div class="flex w-0 flex-1 items-center">
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
<span class="ml-2 w-0 flex-1 truncate"> {{ attachment.document.title }}</span>
</div>
<div class="ml-4 flex-shrink-0">
<button class="font-medium" @click="getAttachmentUrl(attachment)">Download</button>
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
const props = defineProps({
attachments: {
type: Object as () => ItemAttachment[],
required: true,
},
itemId: {
type: String,
required: true,
},
});
const api = useUserApi();
const toast = useNotifier();
async function getAttachmentUrl(attachment: ItemAttachment) {
const url = await api.items.getAttachmentUrl(props.itemId, attachment.id);
if (!url) {
toast.error("Failed to get attachment url");
return;
}
if (!document) {
window.open(url, "_blank");
return;
}
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.setAttribute("download", attachment.document.title);
link.click();
}
</script>
<style scoped></style>

View file

@ -1,8 +1,10 @@
import { BaseAPI, route } from "../base";
import { parseDate } from "../base/base-api";
import { ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
import { ItemAttachmentToken, ItemCreate, ItemOut, ItemSummary, ItemUpdate } from "../types/data-contracts";
import { Results } from "./types";
export type AttachmentType = "photo" | "manual" | "warranty" | "attachment";
export class ItemsApi extends BaseAPI {
getAll() {
return this.http.get<Results<ItemOut>>({ url: route("/items") });
@ -45,6 +47,32 @@ export class ItemsApi extends BaseAPI {
const formData = new FormData();
formData.append("csv", file);
return this.http.post<FormData, void>({ url: route("/items/import"), data: formData });
return this.http.post<FormData, void>({
url: route("/items/import"),
data: formData,
});
}
addAttachment(id: string, file: File, type: AttachmentType) {
const formData = new FormData();
formData.append("file", file);
formData.append("type", type);
return this.http.post<FormData, ItemOut>({
url: route(`/items/${id}/attachments`),
data: formData,
});
}
async getAttachmentUrl(id: string, attachmentId: string): Promise<string> {
const payload = await this.http.get<ItemAttachmentToken>({
url: route(`/items/${id}/attachments/${attachmentId}`),
});
if (!payload.data) {
return "";
}
return route(`/items/${id}/attachments/download`, { token: payload.data.token });
}
}

View file

@ -49,6 +49,10 @@ export interface ItemAttachment {
updatedAt: Date;
}
export interface ItemAttachmentToken {
token: string;
}
export interface ItemCreate {
description: string;
labelIds: string[];

View file

@ -97,11 +97,15 @@ export class Requests {
return {} as T;
}
try {
return await response.json();
} catch (e) {
return {} as T;
if (response.headers.get("Content-Type")?.startsWith("application/json")) {
try {
return await response.json();
} catch (e) {
return {} as T;
}
}
return response.body as unknown as T;
})();
return {

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { ItemAttachment } from "~~/lib/api/types/data-contracts";
definePageMeta({
layout: "home",
});
@ -23,6 +25,45 @@
refresh();
});
type FilteredAttachments = {
photos: ItemAttachment[];
attachments: ItemAttachment[];
warranty: ItemAttachment[];
manuals: ItemAttachment[];
};
const attachments = computed<FilteredAttachments>(() => {
if (!item.value) {
return {
photos: [],
attachments: [],
manuals: [],
warranty: [],
};
}
return item.value.attachments.reduce(
(acc, attachment) => {
if (attachment.type === "photo") {
acc.photos.push(attachment);
} else if (attachment.type === "warranty") {
acc.warranty.push(attachment);
} else if (attachment.type === "manual") {
acc.manuals.push(attachment);
} else {
acc.attachments.push(attachment);
}
return acc;
},
{
photos: [] as ItemAttachment[],
attachments: [] as ItemAttachment[],
warranty: [] as ItemAttachment[],
manuals: [] as ItemAttachment[],
}
);
});
const itemSummary = computed(() => {
return {
Description: item.value?.description || "",
@ -31,10 +72,53 @@
Manufacturer: item.value?.manufacturer || "",
Notes: item.value?.notes || "",
Insured: item.value?.insured ? "Yes" : "No",
Attachments: "", // TODO: Attachments
};
});
const showAttachments = computed(() => {
if (preferences.value?.showEmpty) {
return true;
}
return (
attachments.value.photos.length > 0 ||
attachments.value.attachments.length > 0 ||
attachments.value.warranty.length > 0 ||
attachments.value.manuals.length > 0
);
});
const itemAttachments = computed(() => {
const val: Record<string, string> = {};
if (preferences.value.showEmpty) {
return {
Photos: "",
Manuals: "",
Warranty: "",
Attachments: "",
};
}
if (attachments.value.photos.length > 0) {
val.Photos = "";
}
if (attachments.value.manuals.length > 0) {
val.Manuals = "";
}
if (attachments.value.warranty.length > 0) {
val.Warranty = "";
}
if (attachments.value.attachments.length > 0) {
val.Attachments = "";
}
return val;
});
const showWarranty = computed(() => {
if (preferences.value.showEmpty) {
return true;
@ -148,27 +232,36 @@
</template>
</BaseSectionHeader>
</template>
</BaseDetails>
<BaseDetails v-if="showAttachments" :details="itemAttachments">
<template #title> Attachments </template>
<template #Manuals>
<ItemAttachmentsList
v-if="attachments.manuals.length > 0"
:attachments="attachments.manuals"
:item-id="item.id"
/>
</template>
<template #Attachments>
<ul role="list" class="divide-y divide-gray-400 rounded-md border border-gray-400">
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div class="flex w-0 flex-1 items-center">
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
<span class="ml-2 w-0 flex-1 truncate">User Guide.pdf</span>
</div>
<div class="ml-4 flex-shrink-0">
<a href="#" class="font-medium">Download</a>
</div>
</li>
<li class="flex items-center justify-between py-3 pl-3 pr-4 text-sm">
<div class="flex w-0 flex-1 items-center">
<Icon name="mdi-paperclip" class="h-5 w-5 flex-shrink-0 text-gray-400" aria-hidden="true" />
<span class="ml-2 w-0 flex-1 truncate">Purchase Receipts.pdf</span>
</div>
<div class="ml-4 flex-shrink-0">
<a href="#" class="font-medium">Download</a>
</div>
</li>
</ul>
<ItemAttachmentsList
v-if="attachments.attachments.length > 0"
:attachments="attachments.attachments"
:item-id="item.id"
/>
</template>
<template #Warranty>
<ItemAttachmentsList
v-if="attachments.warranty.length > 0"
:attachments="attachments.warranty"
:item-id="item.id"
/>
</template>
<template #Photos>
<ItemAttachmentsList
v-if="attachments.photos.length > 0"
:attachments="attachments.photos"
:item-id="item.id"
/>
</template>
</BaseDetails>
<BaseDetails v-if="showPurchase" :details="purchaseDetails">