An SVG is an XML document that may carry a script, and it is an accepted image type because floor-plan maps and branding genuinely want vector. Loaded through an img tag that script never runs, so the tiles and maps were never the risk. Opening the file's own URL is - and the application image route is public, so that URL needs no session. Every route that serves an upload now goes through one helper that sends Content-Security-Policy: default-src 'none'; sandbox, and nosniff. Seven routes across core and five plugins, so a new one added later starts from the same place rather than repeating the reasoning. Banning the format instead would have cost the maps their only sensible one. The app also sent no security headers at all. It now sets nosniff, frame-ancestors self (as X-Frame-Options too, for the display bays' browsers) and a referrer policy. Deliberately NOT a page-wide CSP: this serves an SPA with inline styles, so a real script-src policy is a change worth making with the frontend in front of you, and a permissive header claiming one would be worse than having none. Contract 0.19.0. send_upload is on the shopdb.api surface, because a plugin serving user-supplied bytes should not have to remember these headers. The same bump records that get_dashboard_widgets has taken data and shape rather than a component name since the dashboard was rebuilt - that shipped without a bump, while BasePlugin and PLUGIN-HOOKS.md both still documented the shape nothing renders, which is how five plugins came to declare widgets pointing at components nobody had written.
214 lines
7.7 KiB
Python
214 lines
7.7 KiB
Python
"""Slides API: lobby-display + shopfloor-screensaver slideshows.
|
|
|
|
Image files live on disk at instance/slides/<surface>/; TvSlide rows hold play
|
|
order + per-slide seconds. Feed + image routes are PUBLIC (kiosks/screensaver
|
|
have no auth); management routes require the slides.manage permission (admins
|
|
have it by default).
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
|
|
from flask import Blueprint, request, current_app, jsonify
|
|
from flask_jwt_extended import jwt_required
|
|
from werkzeug.utils import secure_filename
|
|
|
|
from shopdb.api import (db, success_response, error_response, ErrorCodes,
|
|
require_permission)
|
|
from shopdb.api import send_upload
|
|
|
|
from ..models import TvSlide
|
|
|
|
slides_bp = Blueprint('slides', __name__)
|
|
|
|
SURFACES = frozenset({'lobby', 'shopfloor'})
|
|
VALID_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'}
|
|
DEFAULT_INTERVAL = 10 # seconds per slide when a slide's own seconds is 0
|
|
|
|
|
|
def _surface_dir(surface):
|
|
return os.path.join(current_app.instance_path, 'slides', surface)
|
|
|
|
|
|
def _natkey(name):
|
|
"""Natural sort key so Slide1..Slide11 order numerically, not lexically."""
|
|
return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', name)]
|
|
|
|
|
|
def _valid_surface(surface):
|
|
return surface in SURFACES
|
|
|
|
|
|
def _is_image(filename):
|
|
return os.path.splitext(filename)[1].lower() in VALID_EXTENSIONS
|
|
|
|
|
|
# =============================================================================
|
|
# PUBLIC - consumed by the lobby dashboard + the screensaver
|
|
# =============================================================================
|
|
|
|
@slides_bp.route('/feed', methods=['GET'])
|
|
def feed():
|
|
"""Flat playlist for a surface. NOT wrapped in the success_response envelope
|
|
so the screensaver's existing parser needs no change."""
|
|
surface = request.args.get('surface', 'lobby')
|
|
if not _valid_surface(surface):
|
|
surface = 'lobby'
|
|
directory = _surface_dir(surface)
|
|
rows = (TvSlide.query.filter_by(surface=surface)
|
|
.order_by(TvSlide.sortorder, TvSlide.slideid).all())
|
|
slides = [
|
|
{'filename': r.filename, 'seconds': r.seconds or DEFAULT_INTERVAL}
|
|
for r in rows
|
|
if os.path.isfile(os.path.join(directory, r.filename))
|
|
]
|
|
return jsonify({
|
|
'success': True,
|
|
'surface': surface,
|
|
'basepath': f'/api/slides/img/{surface}/',
|
|
'interval': DEFAULT_INTERVAL,
|
|
'slides': slides,
|
|
})
|
|
|
|
|
|
@slides_bp.route('/img/<surface>/<path:filename>', methods=['GET'])
|
|
def serve_image(surface, filename):
|
|
"""Serve a slide image with a path-traversal guard."""
|
|
if not _valid_surface(surface):
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Unknown surface', http_code=404)
|
|
safe = os.path.basename(filename)
|
|
if safe != filename or not safe:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Invalid filename', http_code=404)
|
|
directory = _surface_dir(surface)
|
|
if not os.path.isfile(os.path.join(directory, safe)):
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404)
|
|
return send_upload(directory, safe)
|
|
|
|
|
|
# =============================================================================
|
|
# ADMIN - the Vue slide manager
|
|
# =============================================================================
|
|
|
|
@slides_bp.route('/<surface>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('slides.manage')
|
|
def list_slides(surface):
|
|
if not _valid_surface(surface):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
|
directory = _surface_dir(surface)
|
|
rows = (TvSlide.query.filter_by(surface=surface)
|
|
.order_by(TvSlide.sortorder, TvSlide.slideid).all())
|
|
out = []
|
|
for r in rows:
|
|
if os.path.isfile(os.path.join(directory, r.filename)):
|
|
item = r.to_dict()
|
|
item['url'] = f'/api/slides/img/{surface}/{r.filename}'
|
|
out.append(item)
|
|
return success_response(out)
|
|
|
|
|
|
@slides_bp.route('/<surface>/upload', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('slides.manage')
|
|
def upload_slides(surface):
|
|
if not _valid_surface(surface):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
|
|
|
files = request.files.getlist('files')
|
|
if not files and 'file' in request.files:
|
|
files = [request.files['file']]
|
|
if not files:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'No files uploaded')
|
|
|
|
directory = _surface_dir(surface)
|
|
os.makedirs(directory, exist_ok=True)
|
|
|
|
# Append after the current max order; upload in natural filename order.
|
|
maxorder = db.session.query(db.func.max(TvSlide.sortorder)).filter_by(surface=surface).scalar()
|
|
order = (maxorder or 0)
|
|
existing = {r.filename for r in TvSlide.query.filter_by(surface=surface).all()}
|
|
|
|
added = []
|
|
for f in sorted(files, key=lambda x: _natkey(x.filename or '')):
|
|
if not f or not f.filename or not _is_image(f.filename):
|
|
continue
|
|
base = secure_filename(os.path.basename(f.filename))
|
|
if not base:
|
|
continue
|
|
# Unique-rename if the name is taken (on disk or in db).
|
|
stem, ext = os.path.splitext(base)
|
|
name = base
|
|
n = 1
|
|
while name in existing or os.path.exists(os.path.join(directory, name)):
|
|
name = f"{stem}_{n}{ext}"
|
|
n += 1
|
|
f.save(os.path.join(directory, name))
|
|
existing.add(name)
|
|
order += 1
|
|
db.session.add(TvSlide(surface=surface, filename=name, sortorder=order, seconds=0))
|
|
added.append(name)
|
|
|
|
db.session.commit()
|
|
return success_response({'added': added}, message=f'{len(added)} slide(s) uploaded')
|
|
|
|
|
|
@slides_bp.route('/<surface>/order', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('slides.manage')
|
|
def reorder_slides(surface):
|
|
if not _valid_surface(surface):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
|
data = request.get_json() or {}
|
|
order = data.get('order') or []
|
|
for i, filename in enumerate(order):
|
|
row = TvSlide.query.filter_by(surface=surface, filename=filename).first()
|
|
if row:
|
|
row.sortorder = i
|
|
db.session.commit()
|
|
return success_response(message='Order saved')
|
|
|
|
|
|
@slides_bp.route('/<surface>/delete', methods=['POST'])
|
|
@jwt_required()
|
|
@require_permission('slides.manage')
|
|
def delete_slides(surface):
|
|
if not _valid_surface(surface):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
|
data = request.get_json() or {}
|
|
files = data.get('files') or []
|
|
directory = _surface_dir(surface)
|
|
removed = 0
|
|
for filename in files:
|
|
safe = os.path.basename(filename)
|
|
row = TvSlide.query.filter_by(surface=surface, filename=safe).first()
|
|
fpath = os.path.join(directory, safe)
|
|
if os.path.isfile(fpath):
|
|
try:
|
|
os.remove(fpath)
|
|
except OSError:
|
|
pass
|
|
if row:
|
|
db.session.delete(row)
|
|
removed += 1
|
|
db.session.commit()
|
|
return success_response(message=f'{removed} slide(s) deleted')
|
|
|
|
|
|
@slides_bp.route('/<surface>/<int:slideid>', methods=['PATCH'])
|
|
@jwt_required()
|
|
@require_permission('slides.manage')
|
|
def update_slide(surface, slideid):
|
|
if not _valid_surface(surface):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown surface')
|
|
row = db.session.get(TvSlide, slideid)
|
|
if not row or row.surface != surface:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404)
|
|
data = request.get_json() or {}
|
|
if 'seconds' in data:
|
|
try:
|
|
row.seconds = max(0, int(data['seconds']))
|
|
except (TypeError, ValueError):
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, 'seconds must be an integer')
|
|
db.session.commit()
|
|
return success_response(row.to_dict(), message='Slide updated')
|