From c28b02e45bf6d15dbf4dcd2d005782c59c2ac9b9 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 12 Aug 2026 11:45:17 -0400 Subject: [PATCH] Upload an application's image and installer instead of typing paths Adding an application meant typing an image FILENAME and trusting someone had dropped the file into the frontend's own directory by hand, and typing an install path from memory. Both are uploads now, following the model-image trio that models and part photos already use. The two differ deliberately. The image is public, because application tiles render before anything is authenticated. The installer is not: it is licensed vendor software, an open URL would publish it to anything that can reach the site, and it is always sent as an attachment rather than rendered. Installers are capped at 500MB and the size is measured by seeking the stream rather than trusting Content-Length, which a chunked upload does not send and a client can understate. Anything larger belongs on the share, and the error says so rather than just refusing. Files are chosen before a new application exists, so they are held and uploaded once there is an id to attach them to. A failed upload leaves the saved record alone and reports, rather than losing what saved fine. Removing an installer only clears installpath when it pointed at the upload - a share path was typed by a person and is not ours to wipe. The detail page reads both shapes, since entries from the classic site hold a bare filename that is still served from /images/applications/. --- frontend/src/api/index.js | 39 + .../frontend/views/ApplicationDetail.vue | 893 +++++++++--------- .../frontend/views/ApplicationForm.vue | 183 +++- shopdb/core/api/applications.py | 200 +++- tests/test_core/test_application_uploads.py | 165 ++++ 5 files changed, 1033 insertions(+), 447 deletions(-) create mode 100644 tests/test_core/test_application_uploads.py diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index b488b84..d95d150 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -306,6 +306,9 @@ export const printersApi = { getDrivers(id) { return api.get(`/printers/${id}/drivers`) }, + supplyForecast(days = 90) { + return api.get('/printers/supplies/forecast', { params: { days } }) + }, lowSupplies() { return api.get('/printers/lowsupplies') }, @@ -477,6 +480,33 @@ export const applicationsApi = { delete(id) { return api.delete(`/applications/${id}`) }, + // multipart image upload; backend sets image to the served URL + uploadImage(id, file) { + const form = new FormData() + form.append('file', file) + return api.post(`/applications/${id}/image`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + removeImage(id) { + return api.delete(`/applications/${id}/image`) + }, + // installer upload; backend sets installpath to the download URL. + // onProgress gets 0-100 - an installer is big enough that a silent wait + // reads as a hang. + uploadPackage(id, file, onProgress) { + const form = new FormData() + form.append('file', file) + return api.post(`/applications/${id}/package`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: event => { + if (onProgress && event.total) { + onProgress(Math.round((event.loaded * 100) / event.total)) + } + } + }) + }, + removePackage(id) { + return api.delete(`/applications/${id}/package`) + }, // Versions getVersions(appId) { return api.get(`/applications/${appId}/versions`) @@ -1088,6 +1118,15 @@ export const warrantyApi = { }, report() { return api.get('/warranty/report') + }, + // proof of cover: invoice, certificate, whatever the vendor sent + uploadProof(id, file) { + const form = new FormData() + form.append('file', file) + return api.post(`/warranty/${id}/proof`, form, { headers: { 'Content-Type': 'multipart/form-data' } }) + }, + removeProof(id) { + return api.delete(`/warranty/${id}/proof`) } } diff --git a/plugins/applications/frontend/views/ApplicationDetail.vue b/plugins/applications/frontend/views/ApplicationDetail.vue index 833df5c..88fb573 100644 --- a/plugins/applications/frontend/views/ApplicationDetail.vue +++ b/plugins/applications/frontend/views/ApplicationDetail.vue @@ -1,441 +1,452 @@ - - - - - + + + + + diff --git a/plugins/applications/frontend/views/ApplicationForm.vue b/plugins/applications/frontend/views/ApplicationForm.vue index d6fc1a6..5a7736a 100644 --- a/plugins/applications/frontend/views/ApplicationForm.vue +++ b/plugins/applications/frontend/views/ApplicationForm.vue @@ -111,18 +111,59 @@ class="form-control" placeholder="Network path or URL to install files" /> + + A share path or URL, or upload the installer below and this fills + itself in. +
- + +
+ {{ uploadedPackage }} + +
+ +
+
+ {{ packageProgress }}% +
+ + Up to 500MB. Anything larger belongs on the share - put its path in + Install Path instead. Downloads require a login. + +
+ +
+ +
+ + +
+ + + PNG, JPG, GIF, WEBP, SVG or ICO. Replaces whatever is there now. + +
+ +
+ - Image should be placed in /images/applications/ + + Set by the upload. Older entries hold a bare filename served from + /images/applications/, and those still work - leave them alone + unless you are replacing the image. +

Notes

@@ -184,6 +225,76 @@ const form = ref({ const supportTeams = ref([]) +// Files are chosen before the application exists, so they are held here and +// uploaded once there is an id to attach them to (see saveApplication). +const pendingImage = ref(null) +const pendingPackage = ref(null) +const imagePreview = ref('') +const uploadedPackage = ref('') +const packageProgress = ref(null) +const busyImage = ref(false) +const busyPackage = ref(false) + +const PACKAGE_ACCEPT = '.exe,.msi,.msp,.zip,.7z,.cab,.iso,.appx,.msix,.ps1,.bat,.txt,.pdf' +const MAX_PACKAGE_BYTES = 500 * 1024 * 1024 + +function onImagePicked(event) { + const file = event.target.files?.[0] + if (!file) return + pendingImage.value = file + // Show the chosen file immediately rather than after a round trip. + imagePreview.value = URL.createObjectURL(file) +} + +function onPackagePicked(event) { + const file = event.target.files?.[0] + if (!file) return + if (file.size > MAX_PACKAGE_BYTES) { + error.value = `${file.name} is ${(file.size / 1048576).toFixed(0)}MB; the limit is 500MB. ` + + 'Put it on the share and use Install Path instead.' + event.target.value = '' + return + } + error.value = '' + pendingPackage.value = file + uploadedPackage.value = file.name +} + +async function removeImage() { + pendingImage.value = null + imagePreview.value = '' + form.value.image = '' + if (isEdit.value) { + busyImage.value = true + try { await applicationsApi.removeImage(route.params.id) } + catch (err) { error.value = apiError(err, 'Failed to remove the image') } + finally { busyImage.value = false } + } +} + +async function removePackage() { + pendingPackage.value = null + uploadedPackage.value = '' + if (isEdit.value) { + busyPackage.value = true + try { + const response = await applicationsApi.removePackage(route.params.id) + form.value.installpath = response.data.data.installpath || '' + } catch (err) { + error.value = apiError(err, 'Failed to remove the installer') + } finally { busyPackage.value = false } + } +} + +// The image field holds either a served URL (new) or a bare filename from the +// classic site, which is still rendered out of /images/applications/. +function imageSrcFor(value) { + if (!value) return '' + return value.startsWith('/api/') || value.startsWith('http') + ? value + : `/images/applications/${value}` +} + onMounted(async () => { try { // Load support teams @@ -210,6 +321,10 @@ onMounted(async () => { image: app.image || '', applicationnotes: app.applicationnotes || '' } + imagePreview.value = imageSrcFor(app.image) + if ((app.installpath || '').startsWith('/api/applications/package/')) { + uploadedPackage.value = app.installpath.split('/').pop() + } } } catch (err) { console.error('Error loading data:', err) @@ -240,16 +355,34 @@ async function saveApplication() { applicationnotes: form.value.applicationnotes || null } + let appid = route.params.id if (isEdit.value) { - await applicationsApi.update(route.params.id, appData) + await applicationsApi.update(appid, appData) } else { - await applicationsApi.create(appData) + const created = await applicationsApi.create(appData) + appid = created.data.data.appid + } + + // Uploads come after the save: a new application has no id until it exists, + // and both endpoints key on it. A failed upload must not lose the record + // that saved fine, so it reports and stays on the form. + if (pendingImage.value) { + await applicationsApi.uploadImage(appid, pendingImage.value) + pendingImage.value = null + } + if (pendingPackage.value) { + packageProgress.value = 0 + await applicationsApi.uploadPackage(appid, pendingPackage.value, + percent => { packageProgress.value = percent }) + pendingPackage.value = null + packageProgress.value = null } router.push('/applications') } catch (err) { console.error('Error saving application:', err) error.value = apiError(err, 'Failed to save application') + packageProgress.value = null } finally { saving.value = false } @@ -257,6 +390,46 @@ async function saveApplication() {