Files
TieMeasureFlow/src/frontend/flask_app/app.py
T
Adriano Dal Pastro c4a429d952 feat(offline): la policy arriva dove le pagine si aprono davvero
Punto 12. Le cinque librerie erano già state portate in casa (28ee44b): nessun
template carica più niente dalla rete, il worker di PDF.js è locale in tutti e
quattro i file che lo impostano, i font sono woff2 nel pacchetto. Verificato riga
per riga, e le impronte SHA-256 in VERSIONS.md corrispondono ancora.

Mancava però la seconda metà dell'intervento, e mancava dove conta. La
Content-Security-Policy a sola origine locale esisteva sul backend, cioè sulle
risposte API; le pagine HTML le serve il client Flask, che non mandava alcuna
policy. La regola stava scritta dove non poteva essere infranta e assente dove
poteva. Ora il client la manda su ogni risposta.

Serve meno a difendere e più a non far tornare indietro il punto: un tag verso un
CDN aggiunto fra sei mesi viene rifiutato dal browser alla scrivania, dove c'è la
rete e l'errore si legge in console, invece che in reparto dove la rete non c'è.

Tailwind era ancora agganciato a `tailwindcss@3` nel Dockerfile: stesso difetto
che il documento cita per le librerie del browser, un gradino più in basso. Fissato
a 3.4.19, che è la versione con cui l'immagine in esercizio è stata costruita.

I test non renderizzano niente: leggono i sorgenti, perché il difetto che devono
impedire si scrive in un template e non si vede finché non si stacca la rete.
Controllano anche le impronte — una libreria sostituita sul posto tiene lo stesso
nome e la stessa riga in tabella, e l'hash è l'unica parte che se ne accorge.

Annotato in VERSIONS.md che `html5-qrcode` è l'unica libreria dichiarata e mai
caricata: lo scanner da fotocamera non è incluso da nessuna pagina, il lettore che
l'operatore usa è un campo di testo. Va portata in casa prima di accenderlo.

README aggiornato a V3.0.0: novità della versione punto per punto, ruolo
Supervisor, librerie locali al posto dei CDN, stato dei test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 21:58:13 +00:00

182 lines
6.7 KiB
Python

"""TieMeasureFlow Client - Flask Entry Point."""
import json
import os
import re
from datetime import date
from urllib.parse import urlparse
from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel
from flask_wtf.csrf import CSRFProtect
from markupsafe import Markup, escape
from werkzeug.middleware.proxy_fix import ProxyFix
from config import Config
# **bold**, the whole of the markup a task description understands besides the
# line break. DOTALL so a phrase that wraps onto the next line still closes.
_BOLD_RX = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
# Punto 12. Nothing loads from outside: every library ships with the install
# (static/vendor/), because the shop floor network has no way out and a page that
# waits for a CDN there is a white screen, not a slow one.
#
# unsafe-inline the templates carry their Alpine components inline
# unsafe-eval Alpine 3 and Plotly both compile expressions at runtime
# blob: PDF.js runs its worker from a blob, Fabric exports canvases
# data: thumbnails and canvas exports are inlined
CSP = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"font-src 'self'; "
"img-src 'self' data: blob:; "
"connect-src 'self'; "
"worker-src 'self' blob:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'"
)
def get_locale():
"""Get user's preferred language from session or Accept-Language header."""
# 1. User preference in session
if "language" in session:
return session["language"]
# 2. Browser Accept-Language
return request.accept_languages.best_match(
Config.LANGUAGES.keys(), default="it"
)
def create_app() -> Flask:
"""Application factory."""
app = Flask(__name__)
app.config.from_object(Config)
# Trust one reverse-proxy hop (Nginx in dev, Traefik in prod) so that
# request.remote_addr returns the real tablet IP rather than the proxy IP.
# The APIClient forwards that IP to FastAPI for accurate rate limiting.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Initialize CSRF protection
csrf = CSRFProtect(app)
# Initialize Flask-Babel
Babel(app, locale_selector=get_locale)
# Register blueprints
from blueprints.auth import auth_bp
from blueprints.measure import measure_bp
from blueprints.maker import maker_bp
from blueprints.statistics import statistics_bp
from blueprints.admin import admin_bp
app.register_blueprint(auth_bp)
app.register_blueprint(measure_bp, url_prefix="/measure")
app.register_blueprint(maker_bp, url_prefix="/maker")
app.register_blueprint(statistics_bp, url_prefix="/statistics")
app.register_blueprint(admin_bp, url_prefix="/admin")
@app.route("/")
def index():
"""Root redirect to login or dashboard based on session."""
if "user" in session:
return redirect(url_for("measure.select_recipe"))
return redirect(url_for("auth.login"))
@app.route("/set-language/<lang>")
def set_language(lang):
"""Set user's preferred language and store in session."""
if lang in Config.LANGUAGES:
session["language"] = lang
# Only follow the referrer if it points back to this host
# (prevents open redirect via a forged Referer header).
referrer = request.referrer
if referrer:
parsed = urlparse(referrer)
if parsed.netloc and parsed.netloc != request.host:
referrer = None
return redirect(referrer or url_for("auth.login"))
@app.template_filter("rich_text")
def rich_text_filter(value):
"""Render a task description keeping its line breaks and its bold.
Whoever writes a recipe pastes from the PDF of the technical sheet, and the
text used to arrive flattened. Two conventions carry it: a blank line is a
line break, **like this** is bold.
No HTML is ever stored or trusted - the text is escaped first and the only
tags in the result are the ones produced here. That is the sanitisation:
there is nothing to sanitise, because nothing is accepted.
"""
if not value:
return Markup("")
escaped = str(escape(str(value)))
bolded = _BOLD_RX.sub(r"<strong>\1</strong>", escaped)
return Markup(bolded.replace("\n", "<br>"))
@app.template_filter("tojson_attr")
def tojson_attr_filter(value):
"""JSON encode safe for HTML attributes (x-data, etc.).
Unlike |tojson, this escapes double quotes to &#34; so the output
can be safely embedded inside double-quoted HTML attributes.
The browser decodes the entities before Alpine.js evaluates them.
"""
rv = json.dumps(value, ensure_ascii=False)
rv = (
rv.replace("&", "\\u0026")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("'", "\\u0027")
.replace('"', "&#34;")
)
return Markup(rv)
@app.after_request
def security_headers(response):
"""Same-origin only, on the pages the browser actually loads.
The policy existed on the backend, which serves the API; the HTML comes
from here and carried no policy at all. So the rule that says «nothing
from outside» was written where it could not be broken and absent where
it could.
It is also the guard that makes point 12 stay done: a CDN tag added to a
template months from now is refused by the browser here, at a desk with a
network, instead of on a shop floor that has none.
"""
response.headers.setdefault("Content-Security-Policy", CSP)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return response
@app.context_processor
def inject_globals():
"""Inject global variables into all templates."""
return {
"current_user": session.get("user"),
"current_theme": session.get("theme", "light"),
"current_language": get_locale(),
"languages": Config.LANGUAGES,
"company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
}
return app
if __name__ == "__main__":
app = create_app()
app.run(
host=os.getenv("CLIENT_HOST", "0.0.0.0"),
port=int(os.getenv("CLIENT_PORT", "5000")),
debug=True,
)