import logging import os from datetime import datetime from logging.handlers import RotatingFileHandler import bleach from bleach.css_sanitizer import CSSSanitizer from flask import Flask, render_template from flask_sqlalchemy import SQLAlchemy from flask_wtf import CSRFProtect from markupsafe import Markup from werkzeug.middleware.proxy_fix import ProxyFix from config import Config db = SQLAlchemy() csrf = CSRFProtect() class Section(db.Model): __tablename__ = "section" id = db.Column(db.Integer, primary_key=True) num = db.Column(db.Integer, nullable=False, unique=True) title = db.Column(db.String(160), nullable=False) subtitle = db.Column(db.String(255)) sort_order = db.Column(db.Integer, nullable=False, default=0) topics = db.relationship( "Topic", backref="section", order_by="Topic.sort_order", cascade="all, delete-orphan", ) @property def published_topics(self): # Topics are already ordered by sort_order via the relationship. return [t for t in self.topics if t.is_published] class Topic(db.Model): __tablename__ = "topic" id = db.Column(db.Integer, primary_key=True) section_id = db.Column( db.Integer, db.ForeignKey("section.id", ondelete="CASCADE"), nullable=False ) slug = db.Column(db.String(80), nullable=False, unique=True) title = db.Column(db.String(200), nullable=False) body_html = db.Column(db.Text) link_url = db.Column(db.String(500)) link_label = db.Column(db.String(120)) media_type = db.Column(db.String(16), nullable=False, default="none") media_url = db.Column(db.String(500)) media_caption = db.Column(db.String(255)) sort_order = db.Column(db.Integer, nullable=False, default=0) is_published = db.Column(db.Boolean, nullable=False, default=True) @property def body(self): # Content is admin-authored and trusted; render as-is. return Markup(self.body_html or "") @property def is_youtube(self): u = (self.media_url or "").lower() return "youtube.com" in u or "youtu.be" in u class AuditLog(db.Model): __tablename__ = "audit_log" id = db.Column(db.Integer, primary_key=True) actor = db.Column(db.String(80)) action = db.Column(db.String(40), nullable=False) # create / update / delete entity = db.Column(db.String(40), nullable=False) # section / topic entity_id = db.Column(db.Integer) detail = db.Column(db.String(255)) created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) def log_action(actor, action, entity, entity_id=None, detail=None): """Record an audit row. MUST be called AFTER db.session.commit() of the change it describes, so a failed transaction never leaves an orphan log.""" entry = AuditLog( actor=actor, action=action, entity=entity, entity_id=entity_id, detail=detail, ) db.session.add(entry) db.session.commit() # Tags/attributes the rich-text editor (Quill) can emit. Everything else is # stripped on save so a WYSIWYG paste can't inject markup into the public page. ALLOWED_TAGS = [ "p", "br", "strong", "b", "em", "i", "u", "s", "strike", "ul", "ol", "li", "a", "h2", "h3", "blockquote", # images (inserted via the /admin/upload endpoint or a URL) "img", # tables (quill-table-better: resizable cols, aligned/styled cells) "table", "thead", "tbody", "tr", "td", "th", "col", "colgroup", ] # quill-table-better carries column widths, borders and background as inline # `style`, plus data-* bookkeeping attributes. Cell alignment is Quill's # `ql-align-*` CSS class on the cell's
block, and quill-table-better also # tags blots with `ql-table-block`/`table-th-block` classes — all of which must # survive so the table (and its alignment) round-trips back into the editor. _TABLE_CELL_ATTRS = [ "data-row", "data-cell", "data-class", "colspan", "rowspan", "width", "height", "style", "class", ] ALLOWED_ATTRS = { "a": ["href", "title", "target", "rel"], "img": ["src", "alt", "width", "height"], "p": ["style", "class", "data-cell", "data-row"], "table": ["class", "style", "align", "width", "height", "data-class"], "colgroup": ["style", "class"], "col": ["width", "span", "style", "class"], "tr": ["data-row", "style", "class"], "td": _TABLE_CELL_ATTRS, "th": _TABLE_CELL_ATTRS, } # Only these CSS properties survive on a `style` attribute — enough for the # table editor's sizing/alignment/borders, nothing that can smuggle script. ALLOWED_CSS_PROPS = [ "width", "height", "min-width", "padding", "text-align", "vertical-align", "background-color", "border", "border-style", "border-color", "border-width", "border-collapse", ] _css_sanitizer = CSSSanitizer(allowed_css_properties=ALLOWED_CSS_PROPS) def sanitize_html(raw): """Clean editor HTML against the allowlist. Returns None for empty content so blank bodies stay NULL. bleach also restricts URL protocols to http/https/mailto for both links and images, blocking javascript: and data: URLs (uploaded images are served from a relative /static path). Inline `style` is filtered to the ALLOWED_CSS_PROPS allowlist via CSSSanitizer.""" if not raw: return None cleaned = bleach.clean( raw, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, strip=True, css_sanitizer=_css_sanitizer, ).strip() # Quill leaves an empty paragraph for a blank editor. if cleaned in ("", "
", "