From 115f3a27bb670f69d991458fc66ab05b3aff4f17 Mon Sep 17 00:00:00 2001 From: inventionpro <109528211+inventionpro@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:34:18 +0200 Subject: [PATCH 1/3] Webhooks --- api/__init__.py | 4 +- api/auth.py | 2 +- api/channels.py | 12 ++-- api/messages.py | 36 +++++++--- api/pins.py | 12 ++-- api/webhooks.py | 167 ++++++++++++++++++++++++++++++++++++++++++++ default_config.toml | 4 +- main.py | 6 +- migrations/8.sql | 18 +++++ version.toml | 6 +- 10 files changed, 237 insertions(+), 30 deletions(-) create mode 100644 api/webhooks.py create mode 100644 migrations/8.sql diff --git a/api/__init__.py b/api/__init__.py index 5e4b337..33bd802 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -9,6 +9,7 @@ from .pins import pins_bp from .stream import stream_bp from .calls import calls_bp +from .webhooks import webhooks_bp from .utils import process_cors_headers, cleaner from threading import Thread @@ -29,5 +30,6 @@ def add_cors_headers(resp): api_bp.register_blueprint(pins_bp) api_bp.register_blueprint(stream_bp) api_bp.register_blueprint(calls_bp) +api_bp.register_blueprint(webhooks_bp) -Thread(target=cleaner, daemon=True).start() \ No newline at end of file +Thread(target=cleaner, daemon=True).start() diff --git a/api/auth.py b/api/auth.py index 08c9bc6..0bff801 100644 --- a/api/auth.py +++ b/api/auth.py @@ -21,7 +21,7 @@ def index(): return {"running": "Parley", "version": version, "disable_channel_creation": config["instance"]["disable_channel_creation"], "disable_channel_deletion": config["instance"]["disable_channel_deletion"], "max_channels": config["max_members"]["max_channels"], "password_protected": bool(config["instance"]["password"]), - "calls": config["calls"], + "calls": config["calls"], "webhooks": config["webhooks"], **({"dev": True} if dev_mode else {})}, 200 def join_invite(db, id, invite_code): diff --git a/api/channels.py b/api/channels.py index 31722d6..7e76577 100644 --- a/api/channels.py +++ b/api/channels.py @@ -48,12 +48,12 @@ def channels(db:SQLite, id): 'signature', last_msg.signature, 'signed_timestamp', last_msg.signed_timestamp, 'nonce', last_msg.nonce, - 'user', - json_object( - 'username', last_msg_user.username, - 'display', last_msg_user.display_name, - 'pfp', last_msg_user.pfp - ), + 'user', + json_object( + 'username', CASE WHEN last_msg.user_id='0' THEN NULL ELSE last_msg_user.username END, + 'display', CASE WHEN last_msg.user_id='0' THEN last_msg.webhook_name ELSE last_msg_user.display_name END, + 'pfp', CASE WHEN last_msg.user_id='0' THEN last_msg.webhook_pfp ELSE last_msg_user.pfp END + ), 'attachments', ( SELECT json_group_array(json_object( 'id', am.file_id, diff --git a/api/messages.py b/api/messages.py index 34c568d..f9c7113 100644 --- a/api/messages.py +++ b/api/messages.py @@ -3,7 +3,7 @@ from .utils import ( make_json_error, logged_in, sliding_window_rate_limiter, timestamp, perm, has_permission, validate_request_data, - get_file_size_chunked + get_file_size_chunked, public_key_open, rsa_verify_signature ) from utils import generate from .stream import message_sent, message_edited, message_deleted, dm_unhide @@ -49,7 +49,7 @@ def channel_messages(db:SQLite, id, channel_id): if before_messages>100: before_messages=100 if hide_author: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", "NULL AS user, ", "NULL AS signature, ", "NULL AS signed_timestamp, ", @@ -68,11 +68,11 @@ def channel_messages(db:SQLite, id, channel_id): ] else: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", "json_object(", - " 'username', u.username, ", - " 'display', u.display_name, ", - " 'pfp', u.pfp", + " 'username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, ", + " 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, ", + " 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END", ") AS user, ", "m.signature, ", "m.signed_timestamp, ", @@ -92,7 +92,7 @@ def channel_messages(db:SQLite, id, channel_id): ] params=[channel_id, member_message_seq] if "user_id" in request.args: - if len(request.args["user_id"])!=20: return make_json_error(400, "Invalid user_id parameter, error: length") + if request.args["user_id"]!="0" and len(request.args["user_id"])!=20: return make_json_error(400, "Invalid user_id parameter, error: length") sql_parts.append("AND m.user_id=?") params.append(request.args["user_id"]) if "before" in request.args and "after" in request.args: @@ -128,7 +128,7 @@ def channel_messages(db:SQLite, id, channel_id): @validate_request_data({"content": {}, "timestamp": {}, "signature": {}}) def sending_messages(db:SQLite, id, channel_id): files=request.files.getlist("files") - msg=request.form["content"].replace("\r\n", "\n").replace("\r", "\n").strip() + msg=request.form["content"].strip() has_files=any(file.filename for file in files) if (not has_files and not msg): return make_json_error(400, "content or files required") replied_to=request.form.get("replied_to") @@ -153,6 +153,13 @@ def sending_messages(db:SQLite, id, channel_id): channel_permissions=data["channel_permissions"] if not has_permission(member_permissions, perm.send_messages, channel_permissions): return make_json_error(403, "No permission to send messages") if len(msg)>(config["messages"]["max_message_length"] if data["type"]==3 else max_encrypted_msg_len): return make_json_error(400, "Message too long") + if data["type"]==3: + user_public_key_data=db.execute_raw_sql("SELECT public_key FROM users WHERE id=?", (id,)) + if not user_public_key_data: return make_json_error(500, "User public key not found") + public_key, error_resp=public_key_open(user_public_key_data[0]["public_key"]) + if error_resp: return error_resp + signed_data=f"{msg}:{channel_id}:{signed_timestamp}" + if not rsa_verify_signature(public_key, signature, signed_data): return make_json_error(400, "Invalid signature") key=None iv=None if data["type"]!=3: @@ -262,7 +269,6 @@ def message_management(db:SQLite, id, channel_id, message_id): if request.method=="PATCH": content=request.form.get("content") if content is None: return make_json_error(400, "content is required") - content=content.replace("\r\n", "\n").replace("\r", "\n") if request.form.get("timestamp") is None: return make_json_error(400, "timestamp is required") if request.form.get("signature") is None: return make_json_error(400, "signature is required") if len(content)>(config["messages"]["max_message_length"] if data["type"]==3 else max_encrypted_msg_len): return make_json_error(400, "Message too long") @@ -272,6 +278,14 @@ def message_management(db:SQLite, id, channel_id, message_id): signature=request.form["signature"] current_time=timestamp() if abs(current_time-signed_timestamp)>config["messages"]["signature_timestamp_window"]: return make_json_error(400, "Timestamp is invalid") + if data["type"]==3: + user_public_key_data=db.execute_raw_sql("SELECT public_key FROM users WHERE id=?", (id,)) + if not user_public_key_data: return make_json_error(500, "User public key not found") + public_key, error_resp=public_key_open(user_public_key_data[0]["public_key"]) + if error_resp: return error_resp + signed_data=f"{content}:{channel_id}:{signed_timestamp}" + if not rsa_verify_signature(public_key, signature, signed_data): return make_json_error(400, "Invalid signature") + if content==data["content"] and (data["type"]==3 or request.form.get("iv")==data["iv"]): return jsonify({"success": True}) update_fields={"content": content, "edited_at": timestamp(True), "signature": signature, "signed_timestamp": signed_timestamp} @@ -284,8 +298,8 @@ def message_management(db:SQLite, id, channel_id, message_id): # Get updated message data for emit updated_message=db.execute_raw_sql(""" - SELECT m.id, m.content, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, - json_object('username', u.username, 'display', u.display_name, 'pfp', u.pfp) as user, + SELECT m.id, m.content, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, + json_object('username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END) as user, (SELECT json_group_array(json_object('id', am.file_id, 'filename', f.filename, 'size', f.size, 'mimetype', f.mimetype, 'encrypted', am.encrypted, 'iv', am.iv)) FROM attachment_message am JOIN files f ON am.file_id = f.id WHERE am.message_id = m.id) as attachments FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id=? diff --git a/api/pins.py b/api/pins.py index c0b6965..30d7b32 100644 --- a/api/pins.py +++ b/api/pins.py @@ -33,7 +33,7 @@ def get_pinned_messages(db:SQLite, id, channel_id): page_size, offset = pagination["page_size"], pagination["offset"] if hide_author: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", "NULL AS user, ", "(SELECT json_group_array(json_object(", " 'id', am.file_id, ", @@ -51,11 +51,11 @@ def get_pinned_messages(db:SQLite, id, channel_id): ] else: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", "json_object(", - " 'username', u.username, ", - " 'display', u.display_name, ", - " 'pfp', u.pfp", + " 'username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, ", + " 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, ", + " 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END", ") AS user, ", "(SELECT json_group_array(json_object(", " 'id', am.file_id, ", @@ -113,4 +113,4 @@ def unpin_message(db:SQLite, id, channel_id, message_id): if channel_data["type"]!=1 and not has_permission(user_permissions, perm.manage_messages, channel_data["permissions"]): return make_json_error(403, "You don't have manage messages permission") if not db.exists("messages", {"id": message_id, "channel_id": channel_id}): return make_json_error(404, "Message not found") if db.delete_data("message_pins", {"id": message_id})==0: return make_json_error(409, "Message is not pinned") - return jsonify({"success": True}) \ No newline at end of file + return jsonify({"success": True}) diff --git a/api/webhooks.py b/api/webhooks.py new file mode 100644 index 0000000..3b6d66c --- /dev/null +++ b/api/webhooks.py @@ -0,0 +1,167 @@ +from flask import Blueprint, request, jsonify +import json +import re +from .utils import make_json_error, logged_in, sliding_window_rate_limiter, timestamp, perm, has_permission +from .stream import message_sent +from utils import generate, config +from db import SQLite + +webhooks_bp=Blueprint("webhooks", __name__) +data_uri_regex=re.compile(r"^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$") + +def _get_manageable_channel(db, id, channel_id): + member_channel_data=db.execute_raw_sql(""" + SELECT m.permissions, c.type, c.permissions as channel_permissions + FROM members m + JOIN channels c ON m.channel_id=c.id + WHERE m.user_id=? AND m.channel_id=? + """, (id, channel_id)) + if not member_channel_data: return None, make_json_error(404, "Channel not found") + data=member_channel_data[0] + if data["type"]!=3: return None, make_json_error(400, "Webhooks are only supported in broadcast channels") + if not has_permission(data["permissions"], perm.manage_channel, data["channel_permissions"]): return None, make_json_error(403, "Channel management privileges required") + return data, None + +def _build_webhook_path(channel_id, webhook_id, service=None, token=None): + base=f"{request.host_url.rstrip('/')}{request.script_root}" + path=f"/{config['uri_prefix']}/api/v1/channel/{channel_id}/webhooks/{webhook_id}" if config["uri_prefix"] else f"/api/v1/channel/{channel_id}/webhooks/{webhook_id}" + if service: path+=f"/{service}" + if token: path+=f"?token={token}" + return f"{base}{path}" + +def _get_webhook_data(db, channel_id, webhook_id, token): + webhook_data=db.execute_raw_sql(""" + SELECT w.id, w.channel_id, w.name, w.pfp, w.token, c.type + FROM webhooks w + JOIN channels c ON c.id=w.channel_id + WHERE w.id=? AND w.channel_id=? + """, (webhook_id, channel_id)) + if not webhook_data: return None, make_json_error(404, "Webhook not found") + if webhook_data[0]["token"]!=token: return None, make_json_error(401, "Invalid webhook token") + if webhook_data[0]["type"]!=3: return None, make_json_error(400, "Webhooks are only supported in broadcast channels") + return webhook_data[0], None + +def _truncate_webhook_content(content): + return content[:config["messages"]["max_message_length"]] + +def _validate_webhook_pfp(pfp): + if pfp is None or pfp=="": return None, None + pfp=pfp.strip() + if len(pfp)>131072: return None, make_json_error(400, "Webhook pfp is too large") + if not data_uri_regex.fullmatch(pfp): return None, make_json_error(400, "Webhook pfp must be an image data URI") + return pfp, None + +def _github_message(payload): + event=request.headers.get("X-GitHub-Event", "github") + repository=(payload.get("repository") or {}).get("full_name") if isinstance(payload, dict) else None + if event=="ping": return f"GitHub webhook ping{f' from {repository}' if repository else ''}" + if event=="push": + ref=(payload.get("ref") or "").split("/")[-1] + pusher=(payload.get("pusher") or {}).get("name") or (payload.get("sender") or {}).get("login") or "unknown" + commits=len(payload.get("commits") or []) + return _truncate_webhook_content(f"[{repository or 'GitHub'}] {pusher} pushed {commits} commit{'s' if commits!=1 else ''} to {ref or 'unknown'}") + if event=="pull_request": + action=payload.get("action") or "updated" + pr=payload.get("pull_request") or {} + title=pr.get("title") or "pull request" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] pull request {action}: {title}") + if event=="issues": + action=payload.get("action") or "updated" + issue=payload.get("issue") or {} + title=issue.get("title") or "issue" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] issue {action}: {title}") + if event=="issue_comment": + action=payload.get("action") or "created" + issue=payload.get("issue") or {} + title=issue.get("title") or "issue" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] comment {action} on: {title}") + if event=="release": + action=payload.get("action") or "published" + release=payload.get("release") or {} + name=release.get("name") or release.get("tag_name") or "release" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] release {action}: {name}") + return _truncate_webhook_content(f"GitHub event received: {event}{f' in {repository}' if repository else ''}") + +def _get_webhook_content(service): + payload=request.get_json(force=True, silent=True) + if service is not None: service=service.lower() + if service not in [None, "discord", "github"]: return None, make_json_error(400, "Unsupported webhook compatibility mode") + if not isinstance(payload, dict): return None, make_json_error(400, "Webhook payload must be JSON") + if service=="github": + return {"content": _github_message(payload), "name": None, "pfp": None}, None + content=(payload.get("content") or "").strip() + if not content and service=="discord" and payload.get("embeds"): content=json.dumps(payload["embeds"], ensure_ascii=True) + if not content and payload: content=json.dumps(payload, ensure_ascii=True) + if not content: return None, make_json_error(400, "content is required") + webhook_name=(payload.get("username") or payload.get("name") or "").strip() + webhook_pfp=payload.get("avatar_url") if service=="discord" else payload.get("pfp") + webhook_pfp, error_resp=_validate_webhook_pfp(webhook_pfp) + if error_resp: return None, error_resp + if webhook_name and len(webhook_name)>50: return None, make_json_error(400, "Invalid webhook username parameter, error: length") + return {"content": _truncate_webhook_content(content), "name": webhook_name or None, "pfp": webhook_pfp}, None + +@webhooks_bp.route("/channel//webhooks", methods=["GET"]) +@logged_in() +@sliding_window_rate_limiter(limit=100, window=60, user_limit=50) +def list_webhooks(db:SQLite, id, channel_id): + if not config["webhooks"]["enabled"]: return make_json_error(403, "Webhooks are disabled") + _, error_resp=_get_manageable_channel(db, id, channel_id) + if error_resp: return error_resp + webhooks=db.execute_raw_sql(""" + SELECT w.id, w.name, w.pfp, w.token, w.created_at, w.last_used_at, u.username as created_by_username, u.display_name as created_by_display + FROM webhooks w + LEFT JOIN users u ON u.id=w.created_by + WHERE w.channel_id=? + ORDER BY w.created_at DESC + """, (channel_id,)) + for webhook in webhooks: + webhook["url"]=_build_webhook_path(channel_id, webhook["id"]) + return jsonify(webhooks) + +@webhooks_bp.route("/channel//webhooks", methods=["POST"]) +@logged_in() +@sliding_window_rate_limiter(limit=20, window=300, user_limit=10) +def create_webhook(db:SQLite, id, channel_id): + if not config["webhooks"]["enabled"]: return make_json_error(403, "Webhooks are disabled") + _, error_resp=_get_manageable_channel(db, id, channel_id) + if error_resp: return error_resp + data=request.get_json(force=True, silent=True) + if not isinstance(data, dict): return make_json_error(400, "Webhook payload must be JSON") + name=(data.get("name") or "").strip() + if len(name)<1 or len(name)>50: return make_json_error(400, "Invalid name parameter, error: length") + pfp, error_resp=_validate_webhook_pfp(data.get("pfp")) + if error_resp: return error_resp + webhook_id=generate() + webhook_token=generate(32) + now=timestamp(True) + db.insert_data("webhooks", {"id": webhook_id, "channel_id": channel_id, "name": name, "pfp": pfp, "token": webhook_token, "created_by": id, "created_at": now, "last_used_at": None}) + webhook={"id": webhook_id, "name": name, "pfp": pfp, "created_at": now, "last_used_at": None, "url": _build_webhook_path(channel_id, webhook_id)} + return jsonify({"webhook": webhook, "token": webhook_token, "send_url": _build_webhook_path(channel_id, webhook_id, token=webhook_token), "success": True}), 201 + +@webhooks_bp.route("/channel//webhooks/", methods=["POST", "DELETE"]) +@webhooks_bp.route("/channel//webhooks//", methods=["POST"]) +@sliding_window_rate_limiter(limit=100, window=60) +def webhook_action(channel_id, webhook_id, service=None): + if not config["webhooks"]["enabled"]: return make_json_error(403, "Webhooks are disabled") + db=SQLite() + try: + token=request.args.get("token") + if not token: return make_json_error(401, "Webhook token is required") + webhook_data, error_resp=_get_webhook_data(db, channel_id, webhook_id, token) + if error_resp: return error_resp + if request.method=="DELETE": + db.delete_data("webhooks", {"id": webhook_id, "channel_id": channel_id}) + return jsonify({"success": True}) + payload, error_resp=_get_webhook_content(service) + if error_resp: return error_resp + sent_at=timestamp(True) + message_id=generate() + webhook_name=payload["name"] or webhook_data["name"] + webhook_pfp=payload["pfp"] + db.insert_data("messages", {"id": message_id, "channel_id": channel_id, "user_id": "0", "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "replied_to": None, "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id, "webhook_name": webhook_name, "webhook_pfp": webhook_pfp}) + db.update_data("webhooks", {"last_used_at": sent_at}, {"id": webhook_id}) + message_data={"id": message_id, "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "edited_at": None, "replied_to": None, "user": {"username": None, "display": webhook_name, "pfp": webhook_pfp}, "attachments": [], "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id, "webhook_name": webhook_name, "webhook_pfp": webhook_pfp} + message_sent(channel_id, message_data, "0", db) + return jsonify({"message_id": message_id, "success": True}), 201 + finally: + db.close() diff --git a/default_config.toml b/default_config.toml index 5ec1ead..ed309b9 100644 --- a/default_config.toml +++ b/default_config.toml @@ -1,6 +1,6 @@ # DO NOT EDIT THIS FILE, RUN THE PROGRAM AND EDIT config.toml INSTEAD -version=6 # DO NOT TOUCH IF YOU DON'T KNOW WHAT YOU'RE DOING +version=7 # DO NOT TOUCH IF YOU DON'T KNOW WHAT YOU'RE DOING uri_prefix="$URI_PREFIX" # URI Prefix, you must include it when connecting to the server (https://example.com/uri_prefix/) if present [server] @@ -39,3 +39,5 @@ uri_prefix="$URI_PREFIX" # URI Prefix, you must include it when connecting to th turn_servers=["turn:openrelay.metered.ca:80", "turn:openrelay.metered.ca:443"] # Optional TURN servers for relaying (format: ["turns:turn.example.com:5349"]) turn_username="openrelayproject" # TURN server username turn_password="openrelayproject" # TURN server password +[webhooks] + enabled=true # Enable or disable webhooks feature diff --git a/main.py b/main.py index a3a1e2e..dc0b1d9 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ db.create_table("session", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "user": "TEXT NOT NULL", "token_hash": "TEXT UNIQUE NOT NULL", "id": "TEXT UNIQUE NOT NULL", "device": "TEXT", "browser": "TEXT", "logged_in_at": "INTEGER NOT NULL", "next_challenge": "INTEGER", "FOREIGN KEY (user)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("channels", {"id": "TEXT PRIMARY KEY", "name": "TEXT", "pfp": "TEXT", "type": "INTEGER NOT NULL CHECK (type IN (1, 2, 3))", "permissions": "INTEGER NOT NULL DEFAULT 0", "dm": "TEXT", "invite_code": "TEXT UNIQUE", "created_at": "INTEGER NOT NULL", "FOREIGN KEY (pfp)": "REFERENCES files (id) ON DELETE SET NULL"}) db.create_table("members", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "user_id": "TEXT", "channel_id": "TEXT", "joined_at": "INTEGER NOT NULL", "permissions": "INTEGER", "message_seq": "INTEGER DEFAULT 0", "hidden": "INTEGER CHECK (hidden IS NULL OR hidden = 1)", "UNIQUE": "(user_id, channel_id)", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE"}) - db.create_table("messages", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "id": "TEXT UNIQUE NOT NULL", "channel_id": "TEXT NOT NULL", "user_id": "TEXT NOT NULL", "content": "TEXT NOT NULL", "key": "TEXT", "iv": "TEXT", "timestamp": "INTEGER NOT NULL", "edited_at": "INTEGER", "replied_to": "TEXT", "signature": "TEXT", "signed_timestamp": "INTEGER", "nonce": "TEXT", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE"}) + db.create_table("messages", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "id": "TEXT UNIQUE NOT NULL", "channel_id": "TEXT NOT NULL", "user_id": "TEXT NOT NULL", "content": "TEXT NOT NULL", "key": "TEXT", "iv": "TEXT", "timestamp": "INTEGER NOT NULL", "edited_at": "INTEGER", "replied_to": "TEXT", "signature": "TEXT", "signed_timestamp": "INTEGER", "nonce": "TEXT", "webhook_id": "TEXT", "webhook_name": "TEXT", "webhook_pfp": "TEXT", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("message_pins", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "id": "TEXT UNIQUE NOT NULL", "FOREIGN KEY (id)": "REFERENCES messages (id) ON DELETE CASCADE"}) db.create_table("files", {"id": "TEXT PRIMARY KEY", "filename": "TEXT", "hash": "TEXT NOT NULL", "size": "INTEGER NOT NULL", "mimetype": "TEXT", "file_type": "TEXT NOT NULL CHECK (file_type IN ('attachment', 'pfp'))", "UNIQUE": "(hash, file_type)"}) db.create_table("attachment_message", {"file_id": "TEXT NOT NULL", "message_id": "TEXT NOT NULL", "encrypted": "INTEGER NOT NULL DEFAULT 0", "iv": "TEXT", "PRIMARY KEY": "(file_id, message_id)", "FOREIGN KEY (file_id)": "REFERENCES files (id) ON DELETE CASCADE", "FOREIGN KEY (message_id)": "REFERENCES messages (id) ON DELETE CASCADE"}) @@ -31,6 +31,7 @@ db.create_table("blocks", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "blocker_id": "TEXT NOT NULL", "blocked_id": "TEXT NOT NULL", "blocked_at": "INTEGER NOT NULL", "UNIQUE": "(blocker_id, blocked_id)", "FOREIGN KEY (blocker_id)": "REFERENCES users (id) ON DELETE CASCADE", "FOREIGN KEY (blocked_id)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("calls", {"channel_id": "TEXT PRIMARY KEY", "started_by": "TEXT NOT NULL", "started_at": "INTEGER NOT NULL", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (started_by)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("call_participants", {"channel_id": "TEXT NOT NULL", "user_id": "TEXT NOT NULL", "joined_at": "INTEGER NOT NULL", "left_at": "INTEGER", "PRIMARY KEY": "(channel_id, user_id)", "FOREIGN KEY (channel_id)": "REFERENCES calls (channel_id) ON DELETE CASCADE", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE"}) + db.create_table("webhooks", {"id": "TEXT PRIMARY KEY", "channel_id": "TEXT NOT NULL", "name": "TEXT NOT NULL", "pfp": "TEXT", "token": "TEXT NOT NULL", "created_by": "TEXT", "created_at": "INTEGER NOT NULL", "last_used_at": "INTEGER", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (created_by)": "REFERENCES users (id) ON DELETE SET NULL"}) db.create_index("session", "user") db.create_index("members", "channel_id") db.create_index("members", "message_seq") @@ -47,6 +48,9 @@ db.create_index("message_reads", "channel_id") db.create_index("call_participants", "channel_id") db.create_index("call_participants", "user_id") + db.create_index("webhooks", "channel_id") + db.create_index("webhooks", "token", unique=True) + if not db.exists("users", {"id": "0"}): db.insert_data("users", {"id": "0", "username": "__parley_webhooks_system_account_do_not_use__", "display_name": "System", "pfp": None, "passkey": "system", "public_key": "system", "created_at": 0}) if db.execute_raw_sql("PRAGMA user_version;")[0]["user_version"]!=db_version: db.execute_raw_sql(f"PRAGMA user_version={db_version};") uri_prefix="/"+config["uri_prefix"] if config["uri_prefix"] else "" diff --git a/migrations/8.sql b/migrations/8.sql new file mode 100644 index 0000000..83510e7 --- /dev/null +++ b/migrations/8.sql @@ -0,0 +1,18 @@ +CREATE TABLE webhooks ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + name TEXT NOT NULL, + pfp TEXT, + token TEXT NOT NULL, + created_by TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL +); +ALTER TABLE messages ADD COLUMN webhook_id TEXT; +ALTER TABLE messages ADD COLUMN webhook_name TEXT; +ALTER TABLE messages ADD COLUMN webhook_pfp TEXT; +INSERT OR IGNORE INTO users (id, username, display_name, pfp, passkey, public_key, created_at) VALUES ('0', '__parley_webhooks_system_account_do_not_use__', 'System', NULL, 'system', 'system', 0); +CREATE INDEX idx_webhooks_channel_id ON webhooks (channel_id); +CREATE UNIQUE INDEX idx_webhooks_token ON webhooks (token); diff --git a/version.toml b/version.toml index 1319e2c..de90ee7 100644 --- a/version.toml +++ b/version.toml @@ -1,4 +1,4 @@ # DO NOT TOUCH THESE IF YOU DON'T KNOW WHAT YOU'RE DOING -version="0.6.0" # app version -db=7 # database schema version -config=5 # config file version \ No newline at end of file +version="0.7.0" # app version +db=8 # database schema version +config=7 # config file version From 2f44c3486547376c55ebfc50337a14f8d41ac502 Mon Sep 17 00:00:00 2001 From: inventionpro <109528211+inventionpro@users.noreply.github.com> Date: Thu, 30 Apr 2026 22:48:47 +0200 Subject: [PATCH 2/3] Undo deletions --- api/messages.py | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/api/messages.py b/api/messages.py index f9c7113..eded3a4 100644 --- a/api/messages.py +++ b/api/messages.py @@ -3,7 +3,7 @@ from .utils import ( make_json_error, logged_in, sliding_window_rate_limiter, timestamp, perm, has_permission, validate_request_data, - get_file_size_chunked, public_key_open, rsa_verify_signature + get_file_size_chunked ) from utils import generate from .stream import message_sent, message_edited, message_deleted, dm_unhide @@ -128,7 +128,7 @@ def channel_messages(db:SQLite, id, channel_id): @validate_request_data({"content": {}, "timestamp": {}, "signature": {}}) def sending_messages(db:SQLite, id, channel_id): files=request.files.getlist("files") - msg=request.form["content"].strip() + msg=request.form["content"].replace("\r\n", "\n").replace("\r", "\n").strip() has_files=any(file.filename for file in files) if (not has_files and not msg): return make_json_error(400, "content or files required") replied_to=request.form.get("replied_to") @@ -153,13 +153,6 @@ def sending_messages(db:SQLite, id, channel_id): channel_permissions=data["channel_permissions"] if not has_permission(member_permissions, perm.send_messages, channel_permissions): return make_json_error(403, "No permission to send messages") if len(msg)>(config["messages"]["max_message_length"] if data["type"]==3 else max_encrypted_msg_len): return make_json_error(400, "Message too long") - if data["type"]==3: - user_public_key_data=db.execute_raw_sql("SELECT public_key FROM users WHERE id=?", (id,)) - if not user_public_key_data: return make_json_error(500, "User public key not found") - public_key, error_resp=public_key_open(user_public_key_data[0]["public_key"]) - if error_resp: return error_resp - signed_data=f"{msg}:{channel_id}:{signed_timestamp}" - if not rsa_verify_signature(public_key, signature, signed_data): return make_json_error(400, "Invalid signature") key=None iv=None if data["type"]!=3: @@ -269,6 +262,7 @@ def message_management(db:SQLite, id, channel_id, message_id): if request.method=="PATCH": content=request.form.get("content") if content is None: return make_json_error(400, "content is required") + content=content.replace("\r\n", "\n").replace("\r", "\n") if request.form.get("timestamp") is None: return make_json_error(400, "timestamp is required") if request.form.get("signature") is None: return make_json_error(400, "signature is required") if len(content)>(config["messages"]["max_message_length"] if data["type"]==3 else max_encrypted_msg_len): return make_json_error(400, "Message too long") @@ -278,13 +272,6 @@ def message_management(db:SQLite, id, channel_id, message_id): signature=request.form["signature"] current_time=timestamp() if abs(current_time-signed_timestamp)>config["messages"]["signature_timestamp_window"]: return make_json_error(400, "Timestamp is invalid") - if data["type"]==3: - user_public_key_data=db.execute_raw_sql("SELECT public_key FROM users WHERE id=?", (id,)) - if not user_public_key_data: return make_json_error(500, "User public key not found") - public_key, error_resp=public_key_open(user_public_key_data[0]["public_key"]) - if error_resp: return error_resp - signed_data=f"{content}:{channel_id}:{signed_timestamp}" - if not rsa_verify_signature(public_key, signature, signed_data): return make_json_error(400, "Invalid signature") if content==data["content"] and (data["type"]==3 or request.form.get("iv")==data["iv"]): return jsonify({"success": True}) update_fields={"content": content, "edited_at": timestamp(True), "signature": signature, "signed_timestamp": signed_timestamp} From 0d39ef5c2e3b71913aa4b9adce40c712e3bd8d85 Mon Sep 17 00:00:00 2001 From: FrenchToblerone54 Date: Sat, 2 May 2026 07:35:15 +0000 Subject: [PATCH 3/3] Fix webhook profile payloads --- api/messages.py | 6 +++--- api/pins.py | 4 ++-- api/webhooks.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/messages.py b/api/messages.py index eded3a4..dc79a16 100644 --- a/api/messages.py +++ b/api/messages.py @@ -49,7 +49,7 @@ def channel_messages(db:SQLite, id, channel_id): if before_messages>100: before_messages=100 if hide_author: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, ", "NULL AS user, ", "NULL AS signature, ", "NULL AS signed_timestamp, ", @@ -68,7 +68,7 @@ def channel_messages(db:SQLite, id, channel_id): ] else: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, ", "json_object(", " 'username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, ", " 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, ", @@ -285,7 +285,7 @@ def message_management(db:SQLite, id, channel_id, message_id): # Get updated message data for emit updated_message=db.execute_raw_sql(""" - SELECT m.id, m.content, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, + SELECT m.id, m.content, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, json_object('username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END) as user, (SELECT json_group_array(json_object('id', am.file_id, 'filename', f.filename, 'size', f.size, 'mimetype', f.mimetype, 'encrypted', am.encrypted, 'iv', am.iv)) FROM attachment_message am JOIN files f ON am.file_id = f.id WHERE am.message_id = m.id) as attachments diff --git a/api/pins.py b/api/pins.py index 30d7b32..33c45e0 100644 --- a/api/pins.py +++ b/api/pins.py @@ -33,7 +33,7 @@ def get_pinned_messages(db:SQLite, id, channel_id): page_size, offset = pagination["page_size"], pagination["offset"] if hide_author: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, ", "NULL AS user, ", "(SELECT json_group_array(json_object(", " 'id', am.file_id, ", @@ -51,7 +51,7 @@ def get_pinned_messages(db:SQLite, id, channel_id): ] else: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, m.webhook_name, m.webhook_pfp, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, ", "json_object(", " 'username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, ", " 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, ", diff --git a/api/webhooks.py b/api/webhooks.py index 3b6d66c..c8ca17a 100644 --- a/api/webhooks.py +++ b/api/webhooks.py @@ -7,7 +7,7 @@ from db import SQLite webhooks_bp=Blueprint("webhooks", __name__) -data_uri_regex=re.compile(r"^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$") +data_uri_regex=re.compile(r"^data:image\/(png|jpeg|jpg|webp|gif|x-icon);base64,[A-Za-z0-9+/=]+$") def _get_manageable_channel(db, id, channel_id): member_channel_data=db.execute_raw_sql(""" @@ -157,10 +157,10 @@ def webhook_action(channel_id, webhook_id, service=None): sent_at=timestamp(True) message_id=generate() webhook_name=payload["name"] or webhook_data["name"] - webhook_pfp=payload["pfp"] + webhook_pfp=payload["pfp"] or webhook_data["pfp"] db.insert_data("messages", {"id": message_id, "channel_id": channel_id, "user_id": "0", "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "replied_to": None, "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id, "webhook_name": webhook_name, "webhook_pfp": webhook_pfp}) db.update_data("webhooks", {"last_used_at": sent_at}, {"id": webhook_id}) - message_data={"id": message_id, "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "edited_at": None, "replied_to": None, "user": {"username": None, "display": webhook_name, "pfp": webhook_pfp}, "attachments": [], "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id, "webhook_name": webhook_name, "webhook_pfp": webhook_pfp} + message_data={"id": message_id, "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "edited_at": None, "replied_to": None, "user": {"username": None, "display": webhook_name, "pfp": webhook_pfp}, "attachments": [], "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id} message_sent(channel_id, message_data, "0", db) return jsonify({"message_id": message_id, "success": True}), 201 finally: