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": { "post": {
"security": [ "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": { "get": {
"security": [ "security": [
{ {
@ -310,7 +347,10 @@ const docTemplate = `{
], ],
"responses": { "responses": {
"200": { "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": { "types.ItemCreate": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -216,7 +216,7 @@
} }
} }
}, },
"/v1/items/{id}/attachment": { "/v1/items/{id}/attachments": {
"post": { "post": {
"security": [ "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": { "get": {
"security": [ "security": [
{ {
@ -302,7 +339,10 @@
], ],
"responses": { "responses": {
"200": { "200": {
"description": "" "description": "OK",
"schema": {
"$ref": "#/definitions/types.ItemAttachmentToken"
}
} }
} }
} }
@ -972,6 +1012,14 @@
} }
} }
}, },
"types.ItemAttachmentToken": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"types.ItemCreate": { "types.ItemCreate": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -60,6 +60,11 @@ definitions:
updatedAt: updatedAt:
type: string type: string
type: object type: object
types.ItemAttachmentToken:
properties:
token:
type: string
type: object
types.ItemCreate: types.ItemCreate:
properties: properties:
description: description:
@ -501,7 +506,7 @@ paths:
summary: updates a item summary: updates a item
tags: tags:
- Items - Items
/v1/items/{id}/attachment: /v1/items/{id}/attachments:
post: post:
parameters: parameters:
- description: Item ID - description: Item ID
@ -536,7 +541,7 @@ paths:
summary: imports items into the database summary: imports items into the database
tags: tags:
- Items - Items
/v1/items/{id}/attachment/{attachment_id}: /v1/items/{id}/attachments/{attachment_id}:
get: get:
parameters: parameters:
- description: Item ID - description: Item ID
@ -551,6 +556,31 @@ paths:
type: string type: string
produces: produces:
- application/octet-stream - 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: responses:
"200": "200":
description: "" 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/register"), v1Ctrl.HandleUserRegistration())
r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin()) 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.Group(func(r chi.Router) {
r.Use(a.mwAuthToken) r.Use(a.mwAuthToken)
r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf()) 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.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate())
r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete()) r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete())
r.Post(v1Base("/items/{id}/attachment"), v1Ctrl.HandleItemAttachmentCreate()) r.Post(v1Base("/items/{id}/attachments"), v1Ctrl.HandleItemAttachmentCreate())
r.Get(v1Base("/items/{id}/attachment/{attachment_id}"), v1Ctrl.HandleItemAttachmentGet()) 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 type formData string true "Type of file"
// @Param name formData string true "name of the file including extension" // @Param name formData string true "name of the file including extension"
// @Success 200 {object} types.ItemOut // @Success 200 {object} types.ItemOut
// @Router /v1/items/{id}/attachment [POST] // @Router /v1/items/{id}/attachments [POST]
// @Security Bearer // @Security Bearer
func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc { func (ctrl *V1Controller) HandleItemAttachmentCreate() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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 // @Summary retrieves an attachment for an item
// @Tags Items // @Tags Items
// @Produce application/octet-stream // @Produce application/octet-stream
// @Param id path string true "Item ID" // @Param id path string true "Item ID"
// @Param attachment_id path string true "Attachment ID" // @Param token query string true "Attachment token"
// @Success 200 // @Success 200
// @Router /v1/items/{id}/attachment/{attachment_id} [GET] // @Router /v1/items/{id}/attachments/download [GET]
// @Security Bearer // @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) { return func(w http.ResponseWriter, r *http.Request) {
uid, user, err := ctrl.partialParseIdAndUser(w, r) uid, user, err := ctrl.partialParseIdAndUser(w, r)
if err != nil { if err != nil {
@ -280,7 +307,7 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
return 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 { if err != nil {
log.Err(err).Msg("failed to get attachment") log.Err(err).Msg("failed to get attachment")
@ -288,8 +315,9 @@ func (ctrl *V1Controller) HandleItemAttachmentGet() http.HandlerFunc {
return return
} }
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(path))) server.Respond(w, http.StatusOK, types.ItemAttachmentToken{
w.Header().Set("Content-Type", "application/octet-stream") Token: token,
http.ServeFile(w, r, path) })
} }
} }

View file

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

View file

@ -2,25 +2,61 @@ package services
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/hay-kot/homebox/backend/ent/attachment" "github.com/hay-kot/homebox/backend/ent/attachment"
"github.com/hay-kot/homebox/backend/internal/repo" "github.com/hay-kot/homebox/backend/internal/repo"
"github.com/hay-kot/homebox/backend/internal/services/mappers" "github.com/hay-kot/homebox/backend/internal/services/mappers"
"github.com/hay-kot/homebox/backend/internal/types" "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/hay-kot/homebox/backend/pkgs/pathlib"
"github.com/rs/zerolog/log" "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 { type ItemService struct {
repo *repo.AllRepos repo *repo.AllRepos
// filepath is the root of the storage location that will be used to store all files from. // filepath is the root of the storage location that will be used to store all files from.
filepath string 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) { 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) return pathlib.Safe(path)
} }
func (svc *ItemService) GetAttachment(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) { func (svc *ItemService) NewAttachmentToken(ctx context.Context, gid, itemId, attachmentId uuid.UUID) (string, error) {
// Get the Item
item, err := svc.repo.Items.GetOne(ctx, itemId) item, err := svc.repo.Items.GetOne(ctx, itemId)
if err != nil { if err != nil {
return "", err return "", err
} }
if item.Edges.Group.ID != gid { if item.Edges.Group.ID != gid {
return "", ErrNotOwner 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) attachment, err := svc.repo.Attachments.Get(ctx, attachmentId)
if err != nil { if err != nil {
return "", err return "", err

View file

@ -106,3 +106,7 @@ type ItemAttachment struct {
Type string `json:"type"` Type string `json:"type"`
Document DocumentOut `json:"document"` 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 { BaseAPI, route } from "../base";
import { parseDate } from "../base/base-api"; 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"; import { Results } from "./types";
export type AttachmentType = "photo" | "manual" | "warranty" | "attachment";
export class ItemsApi extends BaseAPI { export class ItemsApi extends BaseAPI {
getAll() { getAll() {
return this.http.get<Results<ItemOut>>({ url: route("/items") }); return this.http.get<Results<ItemOut>>({ url: route("/items") });
@ -45,6 +47,32 @@ export class ItemsApi extends BaseAPI {
const formData = new FormData(); const formData = new FormData();
formData.append("csv", file); 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; updatedAt: Date;
} }
export interface ItemAttachmentToken {
token: string;
}
export interface ItemCreate { export interface ItemCreate {
description: string; description: string;
labelIds: string[]; labelIds: string[];

View file

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

View file

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