"""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/") 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"\1", escaped) return Markup(bolded.replace("\n", "
")) @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 " 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('"', """) ) 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, )