diff --git a/web/app.py b/web/app.py index 92d9412..e6d0af8 100644 --- a/web/app.py +++ b/web/app.py @@ -148,14 +148,19 @@ def create_app() -> Flask: policy_version=Config.PRIVACY_POLICY_VERSION, ) - @app.route("/policy/") - def public_group_policy(group_id): - """Public, unauthenticated URL for a group's server privacy policy.""" + @app.route("/policy/") + def public_group_policy(token): + """Public, unauthenticated URL for a group's server privacy policy. + + The token is an opaque UUID so the group ID is never exposed. + """ import panel_db as db - policy = db.get_group_policy(group_id) - group = db.get_group_by_id(group_id) - if not group: - return "Group not found", 404 + row = db.get_group_policy_by_token(token) + if not row: + return render_template("404.html"), 404 + # Build lightweight dicts that the template expects + policy = row + group = {"name": row["group_name"]} return render_template("group_policy.html", policy=policy, group=group) @app.errorhandler(400) diff --git a/web/blueprints/group_admin.py b/web/blueprints/group_admin.py index 0e5120f..8ac31bd 100644 --- a/web/blueprints/group_admin.py +++ b/web/blueprints/group_admin.py @@ -572,7 +572,8 @@ def privacy_policy(): return redirect(url_for("group_admin.privacy_policy")) group = db.get_group_by_id(group_id) - public_url = url_for("public_group_policy", group_id=group_id, _external=True) + token = policy["public_token"] if policy else None + public_url = url_for("public_group_policy", token=token, _external=True) if token else None return render_template("group_admin/privacy_policy.html", policy=policy, group=group, public_url=public_url) diff --git a/web/panel_db.py b/web/panel_db.py index b42c6e6..0fb2c56 100644 --- a/web/panel_db.py +++ b/web/panel_db.py @@ -177,13 +177,22 @@ PANEL_MIGRATIONS = [ "Add users.consented_at for GDPR consent timestamp"), (9, """CREATE TABLE IF NOT EXISTS group_privacy_policy ( - group_id INT PRIMARY KEY, - policy_text LONGTEXT, - policy_url VARCHAR(500), - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + group_id INT PRIMARY KEY, + policy_text LONGTEXT, + policy_url VARCHAR(500), + public_token CHAR(36) NULL UNIQUE, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (group_id) REFERENCES user_groups(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", "Add group_privacy_policy table for group-hosted privacy policies"), + (10, + "ALTER TABLE group_privacy_policy ADD COLUMN IF NOT EXISTS " + "public_token CHAR(36) NULL", + "Add group_privacy_policy.public_token for opaque public URL"), + (11, + "ALTER TABLE group_privacy_policy ADD CONSTRAINT IF NOT EXISTS " + "uq_gpp_public_token UNIQUE (public_token)", + "Unique index on group_privacy_policy.public_token"), ] CREDS_SCHEMA = [ @@ -769,20 +778,39 @@ def set_user_consent(user_id: int, policy_version: str) -> None: def get_group_policy(group_id: int): """Returns the group_privacy_policy row for *group_id*, or None if not set.""" rows = _panel_query( - "SELECT group_id, policy_text, policy_url, updated_at " + "SELECT group_id, policy_text, policy_url, public_token, updated_at " "FROM group_privacy_policy WHERE group_id = %s", (group_id,), ) return rows[0] if rows else None +def get_group_policy_by_token(token: str): + """Returns the policy row and group for the given opaque public_token, or None.""" + rows = _panel_query( + "SELECT p.group_id, p.policy_text, p.policy_url, p.public_token, p.updated_at, " + "g.name AS group_name " + "FROM group_privacy_policy p " + "JOIN user_groups g ON g.id = p.group_id " + "WHERE p.public_token = %s", + (token,), + ) + return rows[0] if rows else None + + def set_group_policy(group_id: int, policy_text: str | None, policy_url: str | None) -> None: - """Upserts the privacy policy for a group.""" + """Upserts the privacy policy for a group. + + The *public_token* (opaque UUID) is generated once on first INSERT and + never changed on subsequent updates, so existing URLs stay valid. + """ _panel_query( - "INSERT INTO group_privacy_policy (group_id, policy_text, policy_url) " - "VALUES (%s, %s, %s) " - "ON DUPLICATE KEY UPDATE policy_text = VALUES(policy_text), " - "policy_url = VALUES(policy_url), updated_at = UTC_TIMESTAMP()", + "INSERT INTO group_privacy_policy (group_id, policy_text, policy_url, public_token) " + "VALUES (%s, %s, %s, UUID()) " + "ON DUPLICATE KEY UPDATE " + "policy_text = VALUES(policy_text), " + "policy_url = VALUES(policy_url), " + "updated_at = UTC_TIMESTAMP()", (group_id, policy_text, policy_url), write=True, ) diff --git a/web/templates/group_admin/privacy_policy.html b/web/templates/group_admin/privacy_policy.html index 6dc09d1..500188e 100644 --- a/web/templates/group_admin/privacy_policy.html +++ b/web/templates/group_admin/privacy_policy.html @@ -12,6 +12,7 @@

{# ── Public URL banner ────────────────────────────────────── #} + {% if public_url %}
@@ -23,6 +24,12 @@
+ {% else %} +
+ + Save the policy once to generate a secret public URL. +
+ {% endif %} {# ── Last updated ─────────────────────────────────────────── #} {% if policy and policy.updated_at %}