Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Thread(target=cleaner, daemon=True).start()
2 changes: 1 addition & 1 deletion api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 6 additions & 6 deletions api/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 9 additions & 8 deletions api/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ",
"NULL AS user, ",
"NULL AS signature, ",
"NULL AS signed_timestamp, ",
Expand All @@ -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, ",
"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, ",
Expand All @@ -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:
Expand Down Expand Up @@ -272,6 +272,7 @@ 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 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}

Expand All @@ -284,8 +285,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,
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=?
Expand Down
12 changes: 6 additions & 6 deletions api/pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, ",
"NULL AS user, ",
"(SELECT json_group_array(json_object(",
" 'id', am.file_id, ",
Expand All @@ -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, ",
"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, ",
Expand Down Expand Up @@ -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})
return jsonify({"success": True})
167 changes: 167 additions & 0 deletions api/webhooks.py
Original file line number Diff line number Diff line change
@@ -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|x-icon);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/<string:channel_id>/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/<string:channel_id>/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/<string:channel_id>/webhooks/<string:webhook_id>", methods=["POST", "DELETE"])
@webhooks_bp.route("/channel/<string:channel_id>/webhooks/<string:webhook_id>/<string:service>", 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"] 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}
message_sent(channel_id, message_data, "0", db)
return jsonify({"message_id": message_id, "success": True}), 201
finally:
db.close()
4 changes: 3 additions & 1 deletion default_config.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down Expand Up @@ -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
Loading
Loading