import logging
import os
import re
import threading
import time
from collections import defaultdict
from datetime import date, datetime, timedelta
from logging.handlers import RotatingFileHandler
import bleach
from bleach.css_sanitizer import CSSSanitizer
from flask import (
Flask, flash, jsonify, redirect, render_template, request, url_for,
)
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
from mailer import send_email_async
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 DemoRequest(db.Model):
"""A demo appointment requested from the public page. `preferred_date` /
`preferred_time` are what the customer picked (no timezone conversion —
they're read back as the site's advertised local business hours)."""
__tablename__ = "demo_request"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
company = db.Column(db.String(160))
email = db.Column(db.String(200), nullable=False)
phone = db.Column(db.String(40))
preferred_date = db.Column(db.Date, nullable=False)
preferred_time = db.Column(db.String(5), nullable=False) # 'HH:MM'
message = db.Column(db.Text)
status = db.Column(db.String(16), nullable=False, default="new")
source_ip = db.Column(db.String(45))
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
@property
def when(self):
return f"{self.preferred_date:%a %b %d, %Y} at {self.preferred_time}"
DEMO_STATUSES = ("new", "scheduled", "done", "cancelled")
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 ("", "
", "
"):
return None
return cleaned
# ---------------------------------------------------------------- demo form
# Loose sanity check only — the real proof an address works is the confirmation
# mail the customer receives. Rejecting exotic-but-valid addresses is worse than
# accepting a typo the owner can see and follow up on.
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
# In-memory per-IP throttle. Per gunicorn worker (not shared), which is fine:
# it exists to stop a bored visitor spamming the form, not a distributed flood.
# nginx limit_req is the right tool if that ever matters.
_demo_hits = defaultdict(list)
_demo_hits_lock = threading.Lock()
def demo_slots(config):
"""Bookable clock times as 'HH:MM', e.g. 08:00, 08:30, ... 16:30."""
start = max(0, min(23, config.get("DEMO_HOUR_START", 8)))
end = max(start + 1, min(24, config.get("DEMO_HOUR_END", 17)))
step = max(5, config.get("DEMO_SLOT_MINUTES", 30))
slots, minute = [], start * 60
while minute < end * 60:
slots.append(f"{minute // 60:02d}:{minute % 60:02d}")
minute += step
return slots
def taken_slots(day):
"""Times already booked on `day`. Cancelled requests free their slot."""
rows = (
DemoRequest.query
.with_entities(DemoRequest.preferred_time)
.filter(DemoRequest.preferred_date == day,
DemoRequest.status != "cancelled")
.all()
)
return {row[0] for row in rows}
def available_slots(config, day):
"""Bookable times left on `day` — the full grid minus what's taken."""
if day is None:
return demo_slots(config)
booked = taken_slots(day)
return [slot for slot in demo_slots(config) if slot not in booked]
def demo_day_status(config, day):
"""Why a date can't be booked, or None if it's open. Shared by the form and
the /demo/slots endpoint so both give the same answer."""
today = date.today()
if day < today:
return "That date has already passed."
if day > today + timedelta(days=config["DEMO_MAX_DAYS_AHEAD"]):
return (f"Please pick a date within the next "
f"{config['DEMO_MAX_DAYS_AHEAD']} days.")
if day.weekday() >= 5:
return "Demonstrations run Monday through Friday."
return None
def _rate_limited(ip, limit):
"""True if `ip` has already submitted `limit` requests in the last hour."""
if limit <= 0:
return False
now = time.time()
with _demo_hits_lock:
hits = [t for t in _demo_hits[ip] if now - t < 3600]
if len(hits) >= limit:
_demo_hits[ip] = hits
return True
hits.append(now)
_demo_hits[ip] = hits
return False
def _notify_demo(app, req):
"""Mail the owner about a new appointment, and (optionally) confirm to the
customer. Both are fire-and-forget: the row is already committed."""
cfg = app.config
admin_link = request.url_root.rstrip("/") + url_for("admin.demos")
owner_body = (
"A new demo appointment was requested from the JQC features site.\n\n"
f"When: {req.when} ({cfg['DEMO_TIMEZONE_LABEL']})\n"
f"Name: {req.name}\n"
f"Company: {req.company or '—'}\n"
f"Email: {req.email}\n"
f"Phone: {req.phone or '—'}\n\n"
f"Message:\n{req.message or '—'}\n\n"
f"Manage requests: {admin_link}\n"
)
send_email_async(
cfg, cfg.get("DEMO_NOTIFY_EMAIL"),
f"Demo request — {req.name} · {req.when}",
owner_body,
reply_to=req.email, reply_name=req.name,
)
if cfg.get("DEMO_CONFIRM_CUSTOMER"):
send_email_async(
cfg, req.email,
"We received your demo request",
(
f"Hi {req.name},\n\n"
"Thanks for your interest in JQC — LT Services' Janitorial "
"Quality Control program.\n\n"
f"You asked for a demonstration on {req.when} "
f"({cfg['DEMO_TIMEZONE_LABEL']}). We'll confirm that time by "
"email or phone shortly; if it turns out not to work on our "
"side we'll suggest the nearest alternative.\n\n"
"— LT Services Inc.\n"
),
)
def _configure_auth_logger(app):
"""A dedicated 'jqc.auth' logger writing one line per login attempt to a
file fail2ban watches. Kept separate from the app log so the filter regex
stays tight and rotation is self-contained (no logrotate needed)."""
log_path = app.config.get("AUTH_LOG_PATH") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "logs", "auth.log"
)
os.makedirs(os.path.dirname(log_path), exist_ok=True)
auth_log = logging.getLogger("jqc.auth")
auth_log.setLevel(logging.INFO)
auth_log.propagate = False
# Guard against duplicate handlers if create_app runs more than once.
if not any(isinstance(h, RotatingFileHandler) for h in auth_log.handlers):
handler = RotatingFileHandler(
log_path, maxBytes=1_000_000, backupCount=5, encoding="utf-8"
)
handler.setFormatter(
logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s")
)
auth_log.addHandler(handler)
return auth_log
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
# Behind nginx: trust ONE proxy hop so request.remote_addr / scheme reflect
# the real client (nginx sets X-Forwarded-For / -Proto). gunicorn binds
# 127.0.0.1 only, so these headers can't be spoofed from outside.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app)
csrf.init_app(app)
_configure_auth_logger(app)
# Deferred import avoids a circular import: admin.py imports the models and
# log_action defined above, which are ready by the time create_app() runs.
from admin import admin_bp
app.register_blueprint(admin_bp)
@app.route("/")
def index():
all_sections = (
Section.query.order_by(Section.sort_order, Section.num).all()
)
# Hide sections whose topics are all drafts (or which have no topics).
sections = [s for s in all_sections if s.published_topics]
return render_template(
"index.html",
sections=sections,
demo_url=app.config["DEMO_CONTACT_URL"],
)
@app.route("/demo", methods=["GET", "POST"])
def demo():
"""Public demo-appointment form. On success the request is stored and
the owner is notified by mail (best-effort, on a background thread)."""
cfg = app.config
slots = demo_slots(cfg)
today = date.today()
max_date = today + timedelta(days=cfg["DEMO_MAX_DAYS_AHEAD"])
form = {}
if request.method == "POST":
f = request.form
form = {k: f.get(k, "").strip() for k in (
"name", "company", "email", "phone", "preferred_date",
"preferred_time", "message",
)}
errors = []
# Honeypot: a hidden field only a bot fills in. Pretend success so
# the bot doesn't learn to work around it.
if f.get("website", "").strip():
return redirect(url_for("demo_thanks"))
client_ip = request.remote_addr or "-"
if _rate_limited(client_ip, cfg["DEMO_RATE_LIMIT"]):
errors.append(
"Too many requests from this connection. Please try again "
"later or email us directly."
)
if not form["name"]:
errors.append("Please tell us your name.")
if not EMAIL_RE.match(form["email"] or ""):
errors.append("Please enter a valid email address.")
try:
wanted = datetime.strptime(form["preferred_date"], "%Y-%m-%d").date()
except ValueError:
wanted = None
errors.append("Please pick a date for the demonstration.")
if wanted:
closed = demo_day_status(cfg, wanted)
if closed:
errors.append(closed)
if form["preferred_time"] not in slots:
errors.append("Please pick an available time.")
# One appointment per slot. Cancelled ones free the slot again.
if not errors and DemoRequest.query.filter(
DemoRequest.preferred_date == wanted,
DemoRequest.preferred_time == form["preferred_time"],
DemoRequest.status != "cancelled",
).first():
errors.append("That time was just taken — please pick another.")
if errors:
for msg in errors:
flash(msg, "error")
else:
req = DemoRequest(
name=form["name"][:120],
company=form["company"][:160] or None,
email=form["email"][:200],
phone=form["phone"][:40] or None,
preferred_date=wanted,
preferred_time=form["preferred_time"],
message=form["message"][:2000] or None,
status="new",
source_ip=client_ip[:45],
)
db.session.add(req)
db.session.commit()
log_action(
"public", "create", "demo", req.id,
f"{req.name} · {req.when}",
)
_notify_demo(app, req)
return redirect(url_for("demo_thanks"))
# Show only what's still free on the date in hand, so a re-render after
# an error can't offer a slot that was taken in the meantime. With no
# date chosen yet the full grid is listed and the JS narrows it as soon
# as one is picked (and without JS the checks above are the backstop).
chosen = None
if form.get("preferred_date"):
try:
chosen = datetime.strptime(form["preferred_date"], "%Y-%m-%d").date()
except ValueError:
chosen = None
return render_template(
"demo.html",
slots=available_slots(cfg, chosen) if chosen else slots,
form=form,
min_date=today.isoformat(),
max_date=max_date.isoformat(),
tz_label=cfg["DEMO_TIMEZONE_LABEL"],
demo_url=cfg["DEMO_CONTACT_URL"],
)
@app.route("/demo/slots")
def demo_slots_api():
"""Times still bookable on ?date=YYYY-MM-DD, for the form's time picker.
Public and read-only: it exposes which slots are free, which is exactly
what the form shows anyway, and nothing about who booked them."""
cfg = app.config
raw = request.args.get("date", "")
try:
day = datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return jsonify(ok=False, error="Invalid date."), 400
closed = demo_day_status(cfg, day)
if closed:
return jsonify(ok=True, date=raw, open=False, reason=closed, slots=[])
return jsonify(ok=True, date=raw, open=True, slots=available_slots(cfg, day))
@app.route("/demo/thanks")
def demo_thanks():
return render_template("demo_thanks.html", demo_url=app.config["DEMO_CONTACT_URL"])
@app.route("/healthz")
def healthz():
return {"status": "ok"}
@app.context_processor
def inject_asset_url():
"""`asset_url('css/style.css')` → the static URL with a `?v=`
cache-buster, so an edited CSS/JS file is always re-fetched instead of
being served stale from the browser/proxy cache."""
from flask import url_for
def asset_url(filename):
try:
version = int(os.path.getmtime(
os.path.join(app.static_folder, filename)))
except OSError:
version = 0
return url_for("static", filename=filename, v=version)
return {"asset_url": asset_url}
return app
app = create_app()
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000, debug=True)