Tech Tools section with a code generator, toner report that names the part, toner forecasting, warranty proof of cover, application uploads
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
"""Applications API endpoints."""
|
||||
|
||||
from flask import Blueprint, request
|
||||
import glob
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, current_app, send_from_directory
|
||||
from flask_jwt_extended import jwt_required
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from shopdb.extensions import db
|
||||
from shopdb.core.models import (
|
||||
@@ -17,6 +21,46 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
from shopdb.utils.import_mode import apply_import_timestamps
|
||||
|
||||
|
||||
# Uploads live in the instance dir, like model images and part photos, so a
|
||||
# site's files are not mixed into the code tree and survive a redeploy.
|
||||
APP_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico'}
|
||||
APP_IMAGE_URL_PREFIX = '/api/applications/image/'
|
||||
|
||||
# Installers are whatever a vendor ships. The list is a guard against a browser
|
||||
# handing us something unintended, not a security boundary - the download route
|
||||
# always sends as an attachment and never executes anything.
|
||||
APP_PACKAGE_EXTENSIONS = {'.exe', '.msi', '.msp', '.zip', '.7z', '.cab', '.iso',
|
||||
'.appx', '.msix', '.ps1', '.bat', '.txt', '.pdf'}
|
||||
APP_PACKAGE_URL_PREFIX = '/api/applications/package/'
|
||||
|
||||
# A real installer runs to hundreds of megabytes; an ISO runs to gigabytes and
|
||||
# does not belong in an instance directory. 500MB is the line: enough for the
|
||||
# msi/exe this is for, small enough that the disk cannot vanish behind it.
|
||||
MAX_PACKAGE_BYTES = 500 * 1024 * 1024
|
||||
|
||||
|
||||
def _appimage_dir():
|
||||
return os.path.join(current_app.instance_path, 'applicationimages')
|
||||
|
||||
|
||||
def _apppackage_dir():
|
||||
return os.path.join(current_app.instance_path, 'applicationpackages')
|
||||
|
||||
|
||||
def _replace_upload(directory, stem, ext, upload):
|
||||
"""Save one file per application, replacing any prior extension.
|
||||
|
||||
Returns the stored filename. Without the glob a re-upload as .png would
|
||||
orphan the old .jpg and the two would fight over which is current.
|
||||
"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
for old in glob.glob(os.path.join(directory, secure_filename(stem) + '.*')):
|
||||
os.remove(old)
|
||||
filename = secure_filename(f'{stem}{ext}')
|
||||
upload.save(os.path.join(directory, filename))
|
||||
return filename
|
||||
|
||||
|
||||
def _computer_models():
|
||||
"""Lazily import the computers plugin models, or None if unavailable.
|
||||
|
||||
@@ -460,3 +504,157 @@ def update_installed_app(machine_id: int, app_id: int):
|
||||
|
||||
# Support teams + contacts now live in the supportteams blueprint
|
||||
# (/api/supportteams), replacing the legacy appowners pair.
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image and installer uploads
|
||||
#
|
||||
# Both follow the model-image trio (upload / serve / delete). The image is
|
||||
# public because application pages render it before anything else loads; the
|
||||
# installer is not, because it is a binary a site pays for.
|
||||
# =============================================================================
|
||||
|
||||
@applications_bp.route('/<int:app_id>/image', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def upload_application_image(app_id: int):
|
||||
"""Upload (or replace) an application's image.
|
||||
|
||||
multipart/form-data: file=<image>. Stored as application-<id><ext>, one per
|
||||
application, and application.image is pointed at the served URL.
|
||||
"""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in APP_IMAGE_EXTENSIONS:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'Unsupported image type {ext}')
|
||||
|
||||
filename = _replace_upload(_appimage_dir(), f'application-{app_id}', ext, upload)
|
||||
app.image = f'{APP_IMAGE_URL_PREFIX}{filename}'
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Application', entityid=app_id,
|
||||
entityname=app.appname, changes={'image': {'new': app.image}})
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Application image uploaded')
|
||||
|
||||
|
||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
def serve_application_image(filename):
|
||||
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||
return send_from_directory(_appimage_dir(), filename)
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/image', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def delete_application_image(app_id: int):
|
||||
"""Remove an application's image and clear the field."""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
for old in glob.glob(os.path.join(_appimage_dir(),
|
||||
secure_filename(f'application-{app_id}') + '.*')):
|
||||
os.remove(old)
|
||||
app.image = None
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Application image removed')
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/package', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def upload_application_package(app_id: int):
|
||||
"""Upload (or replace) the installer for an application.
|
||||
|
||||
multipart/form-data: file=<installer>. The original filename is kept in
|
||||
installpath so the download arrives named the way the vendor shipped it,
|
||||
and the stored copy is application-<id><ext>.
|
||||
"""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
upload = request.files.get('file')
|
||||
if not upload or not upload.filename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'No file provided')
|
||||
ext = os.path.splitext(upload.filename)[1].lower()
|
||||
if ext not in APP_PACKAGE_EXTENSIONS:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Unsupported installer type {}. Allowed: {}'.format(
|
||||
ext, ', '.join(sorted(APP_PACKAGE_EXTENSIONS))))
|
||||
|
||||
# Measure by seeking the stream, not Content-Length: a chunked upload has
|
||||
# no length header, and trusting the header lets a client understate size.
|
||||
upload.stream.seek(0, os.SEEK_END)
|
||||
size = upload.stream.tell()
|
||||
upload.stream.seek(0)
|
||||
if size > MAX_PACKAGE_BYTES:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Installer is {:.0f}MB; the limit is {:.0f}MB. Put larger payloads '
|
||||
'on the share and link to them with Install Path.'.format(
|
||||
size / 1048576, MAX_PACKAGE_BYTES / 1048576))
|
||||
|
||||
filename = _replace_upload(_apppackage_dir(), f'application-{app_id}', ext, upload)
|
||||
app.installpath = '{}{}'.format(APP_PACKAGE_URL_PREFIX, filename)
|
||||
db.session.commit()
|
||||
|
||||
AuditLog.log('updated', 'Application', entityid=app_id,
|
||||
entityname=app.appname,
|
||||
changes={'installpath': {'new': app.installpath},
|
||||
'uploadedfilename': {'new': upload.filename},
|
||||
'bytes': {'new': size}})
|
||||
db.session.commit()
|
||||
return success_response(
|
||||
{**app.to_dict(), 'uploadedfilename': upload.filename, 'bytes': size},
|
||||
message='Installer uploaded')
|
||||
|
||||
|
||||
@applications_bp.route('/package/<path:filename>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.view')
|
||||
def serve_application_package(filename):
|
||||
"""Download an uploaded installer.
|
||||
|
||||
Authenticated, unlike the image: this is licensed vendor software, and an
|
||||
open URL would publish it to anything that can reach the site. Always sent
|
||||
as an attachment so a browser saves it rather than trying to render it.
|
||||
"""
|
||||
return send_from_directory(_apppackage_dir(), filename, as_attachment=True)
|
||||
|
||||
|
||||
@applications_bp.route('/<int:app_id>/package', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('applications.edit')
|
||||
def delete_application_package(app_id: int):
|
||||
"""Remove an uploaded installer and clear the path it set."""
|
||||
app = db.session.get(Application, app_id)
|
||||
if not app:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'Application with ID {app_id} not found',
|
||||
http_code=404)
|
||||
|
||||
for old in glob.glob(os.path.join(_apppackage_dir(),
|
||||
secure_filename(f'application-{app_id}') + '.*')):
|
||||
os.remove(old)
|
||||
# Only clear the path if it pointed at the upload; a site's own share path
|
||||
# was typed by a person and is not ours to wipe.
|
||||
if (app.installpath or '').startswith(APP_PACKAGE_URL_PREFIX):
|
||||
app.installpath = None
|
||||
db.session.commit()
|
||||
return success_response(app.to_dict(), message='Installer removed')
|
||||
|
||||
Reference in New Issue
Block a user