"""ApprovalFlow — self-hosted email approval workflows.""" import functools import json import os import re import secrets from flask import (Flask, abort, flash, g, redirect, render_template, request, send_file, session, url_for) from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.utils import secure_filename import db import pipeline from mailer import send_mail from db import DEFAULT_TEMPLATES app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) # 15 MB attachment limit (+ small allowance for the rest of the form) app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 @app.errorhandler(413) def too_large(e): flash("The uploaded file is too large — the limit is 15 MB.", "error") return redirect(url_for("index")) # ---------------------------------------------------------------- helpers @app.template_filter("fromjson") def fromjson_filter(s): try: return json.loads(s or "[]") except Exception: return [] def slugify(name): s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") return s or "workflow" def login_required(view): @functools.wraps(view) def wrapped(*a, **kw): if "user_id" not in session: return redirect(url_for("login", next=request.path)) return view(*a, **kw) return wrapped def admin_required(view): @functools.wraps(view) def wrapped(*a, **kw): if "user_id" not in session: return redirect(url_for("login", next=request.path)) if not session.get("is_admin"): abort(403) return view(*a, **kw) return wrapped @app.before_request def open_db(): g.db = db.get_db() @app.teardown_request def close_db(exc): d = g.pop("db", None) if d: d.close() def user_workflows(user_id): if session.get("is_admin"): return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() return g.db.execute( "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() # ---------------------------------------------------------------- auth @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": user = g.db.execute("SELECT * FROM users WHERE username=?", (request.form.get("username", "").strip(),)).fetchone() if user and check_password_hash(user["password_hash"], request.form.get("password", "")): session.clear() session["user_id"] = user["id"] session["username"] = user["username"] session["is_admin"] = bool(user["is_admin"]) return redirect(request.args.get("next") or url_for("index")) flash("Wrong username or password.", "error") return render_template("login.html") @app.route("/logout") def logout(): session.clear() return redirect(url_for("login")) # ---------------------------------------------------------------- main page @app.route("/", methods=["GET", "POST"]) @login_required def index(): workflows = user_workflows(session["user_id"]) user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() if request.method == "POST": wf_id = request.form.get("workflow_id", type=int) wf = next((w for w in workflows if w["id"] == wf_id), None) if wf is None: abort(403) # not allowed to use this workflow requester = request.form.get("request_email", "").strip().lower() subject = request.form.get("subject", "").strip() body = request.form.get("body", "").strip() upload = request.files.get("attachment") file_data = upload.read() if upload and upload.filename else b"" if not requester or "@" not in requester: flash("Request's e-mail is required and must be a valid address.", "error") elif not subject or not body: flash("Subject and message are both required.", "error") elif len(file_data) > db.MAX_ATTACHMENT_BYTES: flash("The uploaded file is too large — the limit is 15 MB.", "error") else: rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") if file_data: stored = f"{rid}_{secure_filename(upload.filename) or 'attachment'}" os.makedirs(db.ATTACH_DIR, exist_ok=True) with open(db.attachment_path(stored), "wb") as fh: fh.write(file_data) g.db.execute("UPDATE requests SET attachment=? WHERE id=?", (stored, rid)) db.log(g.db, rid, "attachment saved", f"'{upload.filename}' ({len(file_data)} bytes) — included in every approval mail") g.db.commit() pipeline.send_approval_request(rid) flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") return redirect(url_for("index")) my_requests = g.db.execute( "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", (session["username"], (user["email"] or "-").lower())).fetchall() return render_template("index.html", workflows=workflows, my_requests=my_requests, user_email=user["email"] or "") # ---------------------------------------------------------------- decision links (from email) @app.route("/decision//", methods=["GET", "POST"]) def decision(token, action): if action not in ("approve", "deny"): abort(404) req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() if req is None: return render_template("decision.html", stage="notfound"), 404 chain = pipeline.approver_chain(req) approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" if request.method == "POST": decided_by = request.form.get("decided_by", "").strip() or approver reason = request.form.get("reason", "").strip() if action == "deny" and not reason: return render_template("decision.html", stage="confirm", req=req, action=action, approver=approver, chain=chain, error="A reason for denial is required."), 400 status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) return render_template("decision.html", stage="done", req=req2, message=message, action=action, status=status) return render_template("decision.html", stage="confirm", req=req, action=action, approver=approver, chain=chain) # ---------------------------------------------------------------- admin: dashboard & requests @app.route("/admin") @admin_required def admin(): rows = g.db.execute( "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " "ORDER BY r.id DESC LIMIT 200").fetchall() stats = g.db.execute( "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() return render_template("admin.html", rows=rows, workflows=workflows, stats={s["status"]: s["c"] for s in stats}) @app.route("/admin/request/", methods=["GET", "POST"]) @admin_required def admin_request(rid): req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() if req is None: abort(404) if request.method == "POST" and req["status"] == "pending": action = request.form.get("action") reason = request.form.get("reason", "").strip() if action == "deny" and not reason: flash("A reason for denial is required.", "error") return redirect(url_for("admin_request", rid=rid)) if action in ("approve", "deny"): pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) return redirect(url_for("admin_request", rid=rid)) req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() participants = json.loads(req["participants"]) chain = pipeline.approver_chain(req) return render_template("request_detail.html", req=req, logs=logs, participants=participants, chain=chain) @app.route("/admin/request//attachment") @admin_required def request_attachment(rid): req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() if req is None or not req["attachment"]: abort(404) path = db.attachment_path(req["attachment"]) if not os.path.exists(path): abort(404) return send_file(path, as_attachment=True, download_name=req["attachment"].split("_", 1)[-1]) @app.route("/admin/request//send-log", methods=["POST"]) @admin_required def send_request_log(rid): req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() if req is None: abort(404) to = request.form.get("email", "").strip() if not to or "@" not in to: flash("A valid e-mail address is required to send the log.", "error") return redirect(url_for("admin_request", rid=rid)) logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() chain = pipeline.approver_chain(req) participants = json.loads(req["participants"]) lines = [ f"Full log of request #{req['id']} — {req['subject']}", "=" * 60, f"Workflow: {req['wf_name']}", f"Requester: {req['requester']}", f"Created: {req['created_at']}", f"Status: {req['status'].upper()}", ] if req["decided_by"]: lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") if req["deny_reason"]: lines.append(f"Denial reason: {req['deny_reason']}") lines += [ f"Approval chain: {' -> '.join(chain)}", f"People involved: {', '.join(participants)}", "", "Message:", req["body"], "", "Event log:", "-" * 60, ] for l in logs: lines.append(f"{l['at']} | {l['event']}") if l["detail"]: lines.append(f"{'':25} {l['detail']}") body = "\n".join(lines) ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", body, request_id=rid) db.log(g.db, rid, "log sent" if ok else "mail error", f"Full log emailed to {to} by {session['username']} — {detail}") g.db.commit() flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", "ok" if ok else "error") return redirect(url_for("admin_request", rid=rid)) # ---------------------------------------------------------------- admin: workflows WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", "approved_body", "denied_subject", "denied_body") @app.route("/admin/workflows/new", methods=["GET", "POST"]) @app.route("/admin/workflows/", methods=["GET", "POST"]) @admin_required def workflow_edit(wid=None): wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None if wid and wf is None: abort(404) if request.method == "POST": f = request.form name = f.get("name", "").strip() approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] if not name or not approvers: flash("A name and at least one approver are required.", "error") else: templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} try: if wf is None: g.db.execute( "INSERT INTO workflows (name, slug, description, approvers, active," " approval_subject, approval_body, approved_subject, approved_body," " denied_subject, denied_body, created_at)" " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), 1 if f.get("active") else 0, *[templates[k] for k in WF_FIELDS], db.now())) flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") else: g.db.execute( "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," " active=?, approval_subject=?, approval_body=?," " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), 1 if f.get("active") else 0, *[templates[k] for k in WF_FIELDS], wid)) flash(f"Workflow “{name}” saved.", "ok") g.db.commit() return redirect(url_for("admin")) except Exception as e: flash(f"Could not save: {e}", "error") return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) # ---------------------------------------------------------------- admin: mail status @app.route("/admin/mail") @admin_required def mail_status(): rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() stats = {s["status"]: s["c"] for s in g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} copy_fails = g.db.execute( "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] configured = bool(os.environ.get("MAIL_USER")) return render_template("mail_status.html", rows=rows, stats=stats, copy_fails=copy_fails, configured=configured) # ---------------------------------------------------------------- admin: users & access @app.route("/admin/users", methods=["GET", "POST"]) @admin_required def users(): if request.method == "POST": f = request.form act = f.get("action") try: if act == "create": if not f.get("username") or not f.get("password"): flash("Username and password are required.", "error") else: g.db.execute( "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", (f["username"].strip(), generate_password_hash(f["password"]), f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) flash(f"User “{f['username']}” created.", "ok") elif act == "delete": uid = f.get("user_id", type=int) if uid == session["user_id"]: flash("You can't delete your own account.", "error") else: g.db.execute("DELETE FROM users WHERE id=?", (uid,)) flash("User deleted.", "ok") elif act == "password": g.db.execute("UPDATE users SET password_hash=? WHERE id=?", (generate_password_hash(f["password"]), f.get("user_id", type=int))) flash("Password updated.", "ok") elif act == "access": uid = f.get("user_id", type=int) g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) for wid in request.form.getlist("workflow_ids", type=int): g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", (uid, wid)) flash("Access updated.", "ok") g.db.commit() except Exception as e: flash(f"Error: {e}", "error") return redirect(url_for("users")) all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() access = {} for row in g.db.execute("SELECT * FROM user_workflows"): access.setdefault(row["user_id"], set()).add(row["workflow_id"]) return render_template("users.html", users=all_users, workflows=workflows, access=access) # ---------------------------------------------------------------- boot db.init_db() if __name__ == "__main__": app.run(host=os.environ.get("HOST", "0.0.0.0"), port=int(os.environ.get("PORT", "8000")), debug=os.environ.get("FLASK_DEBUG") == "1")