1180 lines
39 KiB
Python
1180 lines
39 KiB
Python
from .common import (
|
|
MAX_HISTORY_MESSAGES,
|
|
MAX_INCREMENTAL_MESSAGES,
|
|
MAX_MESSAGE_EDIT_WINDOW_SECONDS,
|
|
MAX_MESSAGE_LENGTH,
|
|
MAX_SEARCH_QUERY_LENGTH,
|
|
MAX_SEARCH_RESULTS,
|
|
MAX_THREAD_REPLIES,
|
|
READ_RECEIPT_BATCH_LIMIT,
|
|
json,
|
|
now_iso,
|
|
parse_iso,
|
|
utc_now,
|
|
require_csrf,
|
|
rate_limited,
|
|
current_user,
|
|
require_user,
|
|
require_writable_user,
|
|
require_moderator,
|
|
audit,
|
|
parse_json_request,
|
|
jsonify,
|
|
request,
|
|
get_db,
|
|
db_write,
|
|
build_message_payload,
|
|
build_feed_payload,
|
|
build_local_stub_share_url,
|
|
room_by_id,
|
|
get_channel_by_id,
|
|
get_channel_by_legacy_room_id,
|
|
channel_payload,
|
|
require_room_access,
|
|
require_room_write,
|
|
require_channel_access,
|
|
require_channel_write,
|
|
require_dm_access,
|
|
validate_dm_access,
|
|
dm_payload,
|
|
resolve_name_color,
|
|
MESSAGE_SELECT,
|
|
attachment_list_for_message,
|
|
reaction_summary_for_message,
|
|
resolve_message_attachment_inputs,
|
|
user_can_access_message_scope,
|
|
)
|
|
|
|
|
|
def register_message_routes(app):
|
|
@app.route("/api/rooms/<string:slug>/messages")
|
|
def get_room_messages(slug: str):
|
|
user = current_user()
|
|
room, error = require_room_access(
|
|
slug,
|
|
user,
|
|
login_message="Login required for this private room.",
|
|
forbidden_message="You do not have access to this room.",
|
|
)
|
|
if error:
|
|
return error
|
|
|
|
after_raw = request.args.get("after_id", "").strip()
|
|
after_id = 0
|
|
if after_raw:
|
|
try:
|
|
after_id = max(0, int(after_raw))
|
|
except ValueError:
|
|
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
|
|
|
with get_db() as db:
|
|
if after_raw:
|
|
rows = db.execute(
|
|
f"""
|
|
{MESSAGE_SELECT}
|
|
WHERE m.room_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.direct_conversation_id IS NULL
|
|
AND m.id > ?
|
|
ORDER BY m.id ASC
|
|
LIMIT ?
|
|
""",
|
|
(room["id"], after_id, MAX_INCREMENTAL_MESSAGES),
|
|
).fetchall()
|
|
else:
|
|
rows = db.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM (
|
|
{MESSAGE_SELECT}
|
|
WHERE m.room_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.direct_conversation_id IS NULL
|
|
ORDER BY m.id DESC
|
|
LIMIT ?
|
|
)
|
|
ORDER BY id ASC
|
|
""",
|
|
(room["id"], MAX_HISTORY_MESSAGES),
|
|
).fetchall()
|
|
|
|
messages = [build_message_payload(row, user) for row in rows]
|
|
latest_id = messages[-1]["id"] if messages else after_id
|
|
return jsonify(
|
|
{
|
|
"messages": messages,
|
|
"latest_id": latest_id,
|
|
"has_more": len(messages) == MAX_INCREMENTAL_MESSAGES,
|
|
}
|
|
)
|
|
|
|
@app.route("/api/rooms/<string:slug>/messages", methods=["POST"])
|
|
def send_room_message(slug: str):
|
|
limited = rate_limited("send")
|
|
if limited:
|
|
return limited
|
|
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
user, error = require_writable_user()
|
|
if error:
|
|
return error
|
|
|
|
room, room_error = require_room_write(slug, user)
|
|
if room_error:
|
|
return room_error
|
|
|
|
data, json_error = parse_json_request()
|
|
if json_error:
|
|
return json_error
|
|
|
|
text = data.get("text", "")
|
|
if not isinstance(text, str):
|
|
return jsonify({"error": "Message must be text."}), 400
|
|
|
|
text = text.strip()
|
|
if len(text) > MAX_MESSAGE_LENGTH:
|
|
return jsonify({"error": "Message is too long."}), 400
|
|
|
|
attachment_ids, attachment_id, attachment_error = resolve_message_attachment_inputs(
|
|
data,
|
|
user["id"],
|
|
)
|
|
if attachment_error:
|
|
return attachment_error
|
|
|
|
if not text and not attachment_id:
|
|
return jsonify({"error": "Message cannot be empty."}), 400
|
|
|
|
channel = get_channel_by_legacy_room_id(room["id"])
|
|
channel_id = channel["id"] if channel else None
|
|
|
|
with get_db() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO messages (
|
|
room_id,
|
|
channel_id,
|
|
direct_conversation_id,
|
|
user_id,
|
|
username,
|
|
text,
|
|
attachment_id,
|
|
parent_message_id,
|
|
created_at
|
|
) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?)
|
|
""",
|
|
(
|
|
room["id"],
|
|
channel_id,
|
|
user["id"],
|
|
user["username"],
|
|
text,
|
|
attachment_id,
|
|
now_iso(),
|
|
),
|
|
)
|
|
message_id = cursor.lastrowid
|
|
|
|
for position, extra_attachment_id in enumerate(attachment_ids):
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO message_attachments (
|
|
message_id,
|
|
attachment_id,
|
|
position
|
|
) VALUES (?, ?, ?)
|
|
""",
|
|
(message_id, extra_attachment_id, position),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
with get_db() as db:
|
|
row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
|
|
|
|
audit("room_message_sent", user["id"], "room", str(room["id"]), str(message_id))
|
|
return jsonify({"message": build_message_payload(row, user)}), 201
|
|
|
|
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/messages")
|
|
def api_get_channel_messages(team_slug: str, channel_slug: str):
|
|
user = current_user()
|
|
channel, error = require_channel_access(team_slug, channel_slug, user)
|
|
if error:
|
|
return error
|
|
|
|
after_raw = request.args.get("after_id", "").strip()
|
|
after_id = 0
|
|
if after_raw:
|
|
try:
|
|
after_id = max(0, int(after_raw))
|
|
except ValueError:
|
|
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
|
|
|
with get_db() as db:
|
|
if after_raw:
|
|
rows = db.execute(
|
|
f"""
|
|
{MESSAGE_SELECT}
|
|
WHERE m.channel_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.direct_conversation_id IS NULL
|
|
AND m.id > ?
|
|
ORDER BY m.id ASC
|
|
LIMIT ?
|
|
""",
|
|
(channel["id"], after_id, MAX_INCREMENTAL_MESSAGES),
|
|
).fetchall()
|
|
else:
|
|
rows = db.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM (
|
|
{MESSAGE_SELECT}
|
|
WHERE m.channel_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.direct_conversation_id IS NULL
|
|
ORDER BY m.id DESC
|
|
LIMIT ?
|
|
)
|
|
ORDER BY id ASC
|
|
""",
|
|
(channel["id"], MAX_HISTORY_MESSAGES),
|
|
).fetchall()
|
|
|
|
messages = [build_message_payload(row, user) for row in rows]
|
|
latest_id = messages[-1]["id"] if messages else after_id
|
|
|
|
return jsonify(
|
|
{
|
|
"team": {"slug": channel["team_slug"], "name": channel["team_name"]},
|
|
"channel": channel_payload(channel, user),
|
|
"messages": messages,
|
|
"latest_id": latest_id,
|
|
"has_more": len(messages) == MAX_INCREMENTAL_MESSAGES,
|
|
}
|
|
)
|
|
|
|
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/messages", methods=["POST"])
|
|
def api_send_channel_message(team_slug: str, channel_slug: str):
|
|
limited = rate_limited("send")
|
|
if limited:
|
|
return limited
|
|
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
user, error = require_writable_user()
|
|
if error:
|
|
return error
|
|
|
|
channel, channel_error = require_channel_write(team_slug, channel_slug, user)
|
|
if channel_error:
|
|
return channel_error
|
|
|
|
data, json_error = parse_json_request()
|
|
if json_error:
|
|
return json_error
|
|
|
|
text = data.get("text", "")
|
|
if not isinstance(text, str):
|
|
return jsonify({"error": "Message must be text."}), 400
|
|
|
|
text = text.strip()
|
|
if len(text) > MAX_MESSAGE_LENGTH:
|
|
return jsonify({"error": "Message is too long."}), 400
|
|
|
|
attachment_ids, attachment_id, attachment_error = resolve_message_attachment_inputs(
|
|
data,
|
|
user["id"],
|
|
)
|
|
if attachment_error:
|
|
return attachment_error
|
|
|
|
if not text and not attachment_id:
|
|
return jsonify({"error": "Message cannot be empty."}), 400
|
|
|
|
with get_db() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO messages (
|
|
room_id,
|
|
channel_id,
|
|
direct_conversation_id,
|
|
user_id,
|
|
username,
|
|
text,
|
|
attachment_id,
|
|
parent_message_id,
|
|
created_at
|
|
) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?)
|
|
""",
|
|
(
|
|
channel["legacy_room_id"],
|
|
channel["id"],
|
|
user["id"],
|
|
user["username"],
|
|
text,
|
|
attachment_id,
|
|
now_iso(),
|
|
),
|
|
)
|
|
message_id = cursor.lastrowid
|
|
|
|
for position, extra_attachment_id in enumerate(attachment_ids):
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO message_attachments (
|
|
message_id,
|
|
attachment_id,
|
|
position
|
|
) VALUES (?, ?, ?)
|
|
""",
|
|
(message_id, extra_attachment_id, position),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
with get_db() as db:
|
|
row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
|
|
|
|
audit("channel_message_sent", user["id"], "channel", str(channel["id"]), str(message_id))
|
|
return jsonify({"message": build_message_payload(row, user)}), 201
|
|
|
|
@app.route("/api/messages/<int:message_id>", methods=["PATCH"])
|
|
def edit_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
limited = rate_limited("send")
|
|
if limited:
|
|
return limited
|
|
|
|
user, error = require_writable_user()
|
|
if error:
|
|
return error
|
|
|
|
data, json_error = parse_json_request()
|
|
if json_error:
|
|
return json_error
|
|
|
|
new_text = data.get("text", "")
|
|
if not isinstance(new_text, str):
|
|
return jsonify({"error": "Message must be text."}), 400
|
|
|
|
new_text = new_text.strip()
|
|
|
|
attachment_ids, _, attachment_error = resolve_message_attachment_inputs(
|
|
data,
|
|
user["id"],
|
|
)
|
|
if attachment_error:
|
|
return attachment_error
|
|
|
|
if len(new_text) > MAX_MESSAGE_LENGTH:
|
|
return jsonify({"error": "Message is too long."}), 400
|
|
|
|
if not new_text and not attachment_ids:
|
|
return jsonify({"error": "Message cannot be empty."}), 400
|
|
|
|
with get_db() as db:
|
|
message = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
|
|
|
|
if not message:
|
|
return jsonify({"error": "Message not found."}), 404
|
|
|
|
is_owner = message["user_id"] == user["id"]
|
|
is_moderator = user["role"] in {"moderator", "admin"}
|
|
if not is_owner and not is_moderator:
|
|
return jsonify({"error": "You cannot edit this message.", "code": "forbidden"}), 403
|
|
|
|
created_at = parse_iso(message["created_at"])
|
|
if is_owner and created_at:
|
|
age = (utc_now() - created_at).total_seconds()
|
|
if age > MAX_MESSAGE_EDIT_WINDOW_SECONDS:
|
|
return jsonify({"error": "Edit window has expired."}), 403
|
|
|
|
old_attachments = attachment_list_for_message(message_id)
|
|
|
|
with get_db() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
db.execute(
|
|
"UPDATE messages SET text = ?, attachment_id = ? WHERE id = ?",
|
|
(new_text, attachment_ids[0] if attachment_ids else None, message_id),
|
|
)
|
|
db.execute("DELETE FROM message_attachments WHERE message_id = ?", (message_id,))
|
|
|
|
for position, attachment_id in enumerate(attachment_ids):
|
|
db.execute(
|
|
"""
|
|
INSERT INTO message_attachments (
|
|
message_id,
|
|
attachment_id,
|
|
position
|
|
) VALUES (?, ?, ?)
|
|
""",
|
|
(message_id, attachment_id, position),
|
|
)
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT INTO message_edits (
|
|
message_id,
|
|
edited_by_user_id,
|
|
previous_text,
|
|
new_text,
|
|
previous_attachments_json,
|
|
new_attachments_json,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
message_id,
|
|
user["id"],
|
|
message["text"],
|
|
new_text,
|
|
json.dumps(old_attachments),
|
|
json.dumps(attachment_ids),
|
|
now_iso(),
|
|
),
|
|
)
|
|
db.commit()
|
|
|
|
with get_db() as db:
|
|
updated = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
|
|
|
|
audit("message_edited", user["id"], "message", str(message_id))
|
|
return jsonify({"message": build_message_payload(updated, user)})
|
|
|
|
@app.route("/api/messages/<int:message_id>", methods=["DELETE"])
|
|
def delete_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
moderator, error = require_moderator()
|
|
if error:
|
|
return error
|
|
|
|
limited = rate_limited("admin")
|
|
if limited:
|
|
return limited
|
|
|
|
cursor = db_write("DELETE FROM messages WHERE id = ?", (message_id,))
|
|
if cursor.rowcount == 0:
|
|
return jsonify({"error": "Message not found."}), 404
|
|
|
|
audit("message_deleted", moderator["id"], "message", str(message_id))
|
|
return jsonify({"status": "deleted"})
|
|
|
|
@app.route("/api/messages/history")
|
|
def api_message_history():
|
|
user = current_user()
|
|
kind = str(request.args.get("kind", "")).strip()
|
|
slug = str(request.args.get("slug", "")).strip()
|
|
team_slug = str(request.args.get("team_slug", "")).strip()
|
|
before_id_raw = str(request.args.get("before_id", "")).strip()
|
|
|
|
try:
|
|
before_id = int(before_id_raw)
|
|
except ValueError:
|
|
return jsonify({"error": "before_id is required."}), 400
|
|
|
|
if kind == "room":
|
|
room, error = require_room_access(slug, user)
|
|
if error:
|
|
return error
|
|
|
|
with get_db() as db:
|
|
rows = db.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM (
|
|
{MESSAGE_SELECT}
|
|
WHERE m.room_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.direct_conversation_id IS NULL
|
|
AND m.id < ?
|
|
ORDER BY m.id DESC
|
|
LIMIT ?
|
|
)
|
|
ORDER BY id ASC
|
|
""",
|
|
(room["id"], before_id, MAX_HISTORY_MESSAGES),
|
|
).fetchall()
|
|
|
|
elif kind == "channel":
|
|
channel, error = require_channel_access(team_slug, slug, user)
|
|
if error:
|
|
return error
|
|
|
|
with get_db() as db:
|
|
rows = db.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM (
|
|
{MESSAGE_SELECT}
|
|
WHERE m.channel_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.direct_conversation_id IS NULL
|
|
AND m.id < ?
|
|
ORDER BY m.id DESC
|
|
LIMIT ?
|
|
)
|
|
ORDER BY id ASC
|
|
""",
|
|
(channel["id"], before_id, MAX_HISTORY_MESSAGES),
|
|
).fetchall()
|
|
|
|
elif kind == "dm":
|
|
dm_id_raw = str(request.args.get("dm_id", "")).strip()
|
|
try:
|
|
dm_id = int(dm_id_raw)
|
|
except ValueError:
|
|
return jsonify({"error": "dm_id is required for DM history."}), 400
|
|
|
|
_, error = require_dm_access(dm_id, user)
|
|
if error:
|
|
return error
|
|
|
|
with get_db() as db:
|
|
rows = db.execute(
|
|
f"""
|
|
SELECT *
|
|
FROM (
|
|
{MESSAGE_SELECT}
|
|
WHERE m.direct_conversation_id = ?
|
|
AND m.parent_message_id IS NULL
|
|
AND m.id < ?
|
|
ORDER BY m.id DESC
|
|
LIMIT ?
|
|
)
|
|
ORDER BY id ASC
|
|
""",
|
|
(dm_id, before_id, MAX_HISTORY_MESSAGES),
|
|
).fetchall()
|
|
else:
|
|
return jsonify({"error": "Invalid history kind."}), 400
|
|
|
|
messages = [build_message_payload(row, user) for row in rows]
|
|
return jsonify({"messages": messages, "has_more": len(messages) == MAX_HISTORY_MESSAGES})
|
|
|
|
@app.route("/api/messages/<int:message_id>/thread")
|
|
def api_get_message_thread(message_id: int):
|
|
user = current_user()
|
|
|
|
with get_db() as db:
|
|
root = db.execute(
|
|
f"""
|
|
{MESSAGE_SELECT}
|
|
WHERE m.id = ?
|
|
AND m.parent_message_id IS NULL
|
|
""",
|
|
(message_id,),
|
|
).fetchone()
|
|
|
|
if not root:
|
|
return jsonify({"error": "Thread root message not found."}), 404
|
|
|
|
if root["direct_conversation_id"]:
|
|
if not validate_dm_access(root["direct_conversation_id"], user):
|
|
if not user:
|
|
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
|
return jsonify({"error": "You do not have access to this thread.", "code": "forbidden"}), 403
|
|
else:
|
|
channel = None
|
|
if root["channel_id"]:
|
|
channel = get_channel_by_id(root["channel_id"])
|
|
if not channel and root["room_id"]:
|
|
channel = get_channel_by_legacy_room_id(root["room_id"])
|
|
|
|
if not channel or not require_channel_access(channel["team_slug"], channel["slug"], user)[0]:
|
|
if not user:
|
|
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
|
return jsonify({"error": "You do not have access to this thread.", "code": "forbidden"}), 403
|
|
|
|
with get_db() as db:
|
|
replies = db.execute(
|
|
f"""
|
|
{MESSAGE_SELECT}
|
|
WHERE m.parent_message_id = ?
|
|
ORDER BY m.id ASC
|
|
LIMIT ?
|
|
""",
|
|
(message_id, MAX_THREAD_REPLIES),
|
|
).fetchall()
|
|
|
|
payload = {
|
|
"root": build_message_payload(root, user),
|
|
"replies": [build_message_payload(reply, user) for reply in replies],
|
|
}
|
|
|
|
if root["direct_conversation_id"]:
|
|
payload["dm"] = dm_payload(root["direct_conversation_id"], user["id"], resolve_name_color)
|
|
else:
|
|
channel = (
|
|
get_channel_by_id(root["channel_id"])
|
|
if root["channel_id"]
|
|
else get_channel_by_legacy_room_id(root["room_id"])
|
|
)
|
|
payload["team"] = {"slug": channel["team_slug"], "name": channel["team_name"]}
|
|
payload["channel"] = channel_payload(channel, user)
|
|
|
|
return jsonify(payload)
|
|
|
|
@app.route("/api/messages/<int:message_id>/thread", methods=["POST"])
|
|
def api_reply_to_thread(message_id: int):
|
|
limited = rate_limited("send")
|
|
if limited:
|
|
return limited
|
|
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
user, error = require_writable_user()
|
|
if error:
|
|
return error
|
|
|
|
with get_db() as db:
|
|
root = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
room_id,
|
|
channel_id,
|
|
direct_conversation_id,
|
|
parent_message_id
|
|
FROM messages
|
|
WHERE id = ?
|
|
""",
|
|
(message_id,),
|
|
).fetchone()
|
|
|
|
if not root or root["parent_message_id"] is not None:
|
|
return jsonify({"error": "Thread root message not found."}), 404
|
|
|
|
if root["direct_conversation_id"]:
|
|
if not validate_dm_access(root["direct_conversation_id"], user):
|
|
return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403
|
|
else:
|
|
channel = None
|
|
if root["channel_id"]:
|
|
channel = get_channel_by_id(root["channel_id"])
|
|
if not channel and root["room_id"]:
|
|
channel = get_channel_by_legacy_room_id(root["room_id"])
|
|
if not channel or not user_can_access_message_scope(root, user) or not channel:
|
|
return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403
|
|
if not channel or channel["id"] != root["channel_id"] and root["channel_id"] is not None:
|
|
return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403
|
|
|
|
data, json_error = parse_json_request()
|
|
if json_error:
|
|
return json_error
|
|
|
|
text = data.get("text", "")
|
|
if not isinstance(text, str):
|
|
return jsonify({"error": "Reply must be text."}), 400
|
|
|
|
text = text.strip()
|
|
if len(text) > MAX_MESSAGE_LENGTH:
|
|
return jsonify({"error": "Reply is too long."}), 400
|
|
|
|
attachment_ids, attachment_id, attachment_error = resolve_message_attachment_inputs(
|
|
data,
|
|
user["id"],
|
|
)
|
|
if attachment_error:
|
|
return attachment_error
|
|
|
|
if not text and not attachment_id:
|
|
return jsonify({"error": "Reply cannot be empty."}), 400
|
|
|
|
with get_db() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
cursor = db.execute(
|
|
"""
|
|
INSERT INTO messages (
|
|
room_id,
|
|
channel_id,
|
|
direct_conversation_id,
|
|
user_id,
|
|
username,
|
|
text,
|
|
attachment_id,
|
|
parent_message_id,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
root["room_id"],
|
|
root["channel_id"],
|
|
root["direct_conversation_id"],
|
|
user["id"],
|
|
user["username"],
|
|
text,
|
|
attachment_id,
|
|
root["id"],
|
|
now_iso(),
|
|
),
|
|
)
|
|
reply_id = cursor.lastrowid
|
|
|
|
for position, extra_attachment_id in enumerate(attachment_ids):
|
|
db.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO message_attachments (
|
|
message_id,
|
|
attachment_id,
|
|
position
|
|
) VALUES (?, ?, ?)
|
|
""",
|
|
(reply_id, extra_attachment_id, position),
|
|
)
|
|
|
|
db.commit()
|
|
|
|
with get_db() as db:
|
|
reply = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (reply_id,)).fetchone()
|
|
|
|
audit("thread_reply_sent", user["id"], "message", str(message_id), str(reply_id))
|
|
return jsonify({"reply": build_message_payload(reply, user)}), 201
|
|
|
|
@app.route("/api/search")
|
|
def api_search():
|
|
user = current_user()
|
|
query = str(request.args.get("q", "")).strip()
|
|
|
|
if not query:
|
|
return jsonify({"results": [], "query": ""})
|
|
|
|
if len(query) > MAX_SEARCH_QUERY_LENGTH:
|
|
return jsonify({"error": "Search query is too long."}), 400
|
|
|
|
pattern = f"%{query.lower()}%"
|
|
|
|
with get_db() as db:
|
|
rows = db.execute(
|
|
"""
|
|
SELECT
|
|
m.id,
|
|
m.room_id,
|
|
m.channel_id,
|
|
m.direct_conversation_id,
|
|
m.user_id,
|
|
m.username,
|
|
m.text,
|
|
m.created_at,
|
|
m.attachment_id,
|
|
m.parent_message_id,
|
|
(
|
|
SELECT COUNT(*)
|
|
FROM messages AS thread_reply
|
|
WHERE thread_reply.parent_message_id = m.id
|
|
) AS thread_reply_count,
|
|
r.slug AS room_slug,
|
|
r.name AS room_name,
|
|
r.visibility AS room_visibility,
|
|
c.slug AS channel_slug,
|
|
c.name AS channel_name,
|
|
t.slug AS team_slug,
|
|
t.name AS team_name,
|
|
a.original_name AS attachment_original_name,
|
|
a.mime_type AS attachment_mime_type,
|
|
a.size_bytes AS attachment_size_bytes,
|
|
u.role AS user_role,
|
|
us.name_color AS user_name_color
|
|
FROM messages AS m
|
|
JOIN users AS u
|
|
ON u.id = m.user_id
|
|
LEFT JOIN user_settings AS us
|
|
ON us.user_id = u.id
|
|
LEFT JOIN rooms AS r
|
|
ON r.id = m.room_id
|
|
LEFT JOIN channels AS c
|
|
ON c.id = m.channel_id
|
|
LEFT JOIN teams AS t
|
|
ON t.id = c.team_id
|
|
LEFT JOIN attachments AS a
|
|
ON a.id = m.attachment_id
|
|
WHERE LOWER(m.text) LIKE ?
|
|
ORDER BY m.id DESC
|
|
LIMIT ?
|
|
""",
|
|
(pattern, MAX_SEARCH_RESULTS),
|
|
).fetchall()
|
|
|
|
visible = [build_feed_payload(row, user) for row in rows if user_can_access_message_scope(row, user)]
|
|
return jsonify({"query": query, "results": visible})
|
|
|
|
@app.route("/api/read-receipts", methods=["POST"])
|
|
def api_mark_read_receipts():
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
|
|
data, json_error = parse_json_request()
|
|
if json_error:
|
|
return json_error
|
|
|
|
ids = data.get("message_ids", [])
|
|
if not isinstance(ids, list):
|
|
return jsonify({"error": "message_ids must be an array."}), 400
|
|
|
|
parsed_ids = []
|
|
for item in ids[:READ_RECEIPT_BATCH_LIMIT]:
|
|
try:
|
|
parsed_ids.append(int(item))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
|
|
if not parsed_ids:
|
|
return jsonify({"status": "ok", "updated": 0})
|
|
|
|
updated = 0
|
|
with get_db() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
|
|
for message_id in parsed_ids:
|
|
row = db.execute(
|
|
"""
|
|
SELECT id, room_id, channel_id, direct_conversation_id
|
|
FROM messages
|
|
WHERE id = ?
|
|
""",
|
|
(message_id,),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
continue
|
|
|
|
if not user_can_access_message_scope(row, user):
|
|
continue
|
|
|
|
db.execute(
|
|
"""
|
|
INSERT INTO message_receipts (
|
|
message_id,
|
|
user_id,
|
|
seen_at
|
|
) VALUES (?, ?, ?)
|
|
ON CONFLICT(message_id, user_id)
|
|
DO UPDATE SET seen_at = excluded.seen_at
|
|
""",
|
|
(message_id, user["id"], now_iso()),
|
|
)
|
|
updated += 1
|
|
|
|
db.commit()
|
|
|
|
return jsonify({"status": "ok", "updated": updated})
|
|
|
|
@app.route("/api/messages/<int:message_id>/receipts")
|
|
def api_get_message_receipts(message_id: int):
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
|
|
with get_db() as db:
|
|
message = db.execute(
|
|
"""
|
|
SELECT id, room_id, channel_id, direct_conversation_id, user_id
|
|
FROM messages
|
|
WHERE id = ?
|
|
""",
|
|
(message_id,),
|
|
).fetchone()
|
|
|
|
if not message:
|
|
return jsonify({"error": "Message not found."}), 404
|
|
|
|
if not user_can_access_message_scope(message, user):
|
|
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
|
|
with get_db() as db:
|
|
rows = db.execute(
|
|
"""
|
|
SELECT
|
|
u.id,
|
|
u.username,
|
|
mr.seen_at
|
|
FROM message_receipts AS mr
|
|
JOIN users AS u
|
|
ON u.id = mr.user_id
|
|
WHERE mr.message_id = ?
|
|
ORDER BY mr.seen_at DESC, u.username COLLATE NOCASE
|
|
""",
|
|
(message_id,),
|
|
).fetchall()
|
|
|
|
return jsonify(
|
|
{
|
|
"message_id": message_id,
|
|
"receipts": [
|
|
{
|
|
"user_id": row["id"],
|
|
"username": row["username"],
|
|
"seen_at": row["seen_at"],
|
|
}
|
|
for row in rows
|
|
],
|
|
}
|
|
)
|
|
|
|
@app.route("/api/messages/<int:message_id>/reactions")
|
|
def api_get_message_reactions(message_id: int):
|
|
user = current_user()
|
|
|
|
with get_db() as db:
|
|
row = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
room_id,
|
|
channel_id,
|
|
direct_conversation_id
|
|
FROM messages
|
|
WHERE id = ?
|
|
""",
|
|
(message_id,),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return jsonify({"error": "Message not found."}), 404
|
|
|
|
if not user_can_access_message_scope(row, user):
|
|
if not user:
|
|
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
|
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
|
|
with get_db() as db:
|
|
detail_rows = db.execute(
|
|
"""
|
|
SELECT
|
|
mr.emoji,
|
|
u.id AS user_id,
|
|
u.username,
|
|
mr.created_at
|
|
FROM message_reactions AS mr
|
|
JOIN users AS u
|
|
ON u.id = mr.user_id
|
|
WHERE mr.message_id = ?
|
|
ORDER BY mr.emoji ASC, u.username COLLATE NOCASE
|
|
""",
|
|
(message_id,),
|
|
).fetchall()
|
|
|
|
grouped = {}
|
|
for item in detail_rows:
|
|
grouped.setdefault(item["emoji"], []).append(
|
|
{
|
|
"user_id": item["user_id"],
|
|
"username": item["username"],
|
|
"created_at": item["created_at"],
|
|
}
|
|
)
|
|
|
|
summary = []
|
|
for emoji, users in grouped.items():
|
|
summary.append(
|
|
{
|
|
"emoji": emoji,
|
|
"count": len(users),
|
|
"reacted": bool(user and any(x["user_id"] == user["id"] for x in users)),
|
|
"users": users,
|
|
}
|
|
)
|
|
|
|
summary.sort(key=lambda x: (-x["count"], x["emoji"]))
|
|
return jsonify({"message_id": message_id, "reactions": summary})
|
|
|
|
@app.route("/api/messages/<int:message_id>/reactions", methods=["POST"])
|
|
def api_add_reaction(message_id: int):
|
|
limited = rate_limited("reaction")
|
|
if limited:
|
|
return limited
|
|
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
user, error = require_writable_user()
|
|
if error:
|
|
return error
|
|
|
|
data, json_error = parse_json_request()
|
|
if json_error:
|
|
return json_error
|
|
|
|
emoji = str(data.get("emoji", "")).strip()
|
|
if not emoji or len(emoji) > 16:
|
|
return jsonify({"error": "Invalid reaction."}), 400
|
|
|
|
with get_db() as db:
|
|
row = db.execute(
|
|
"""
|
|
SELECT
|
|
id,
|
|
room_id,
|
|
channel_id,
|
|
direct_conversation_id
|
|
FROM messages
|
|
WHERE id = ?
|
|
""",
|
|
(message_id,),
|
|
).fetchone()
|
|
|
|
if not row:
|
|
return jsonify({"error": "Message not found."}), 404
|
|
|
|
if not user_can_access_message_scope(row, user):
|
|
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
|
|
db_write(
|
|
"""
|
|
INSERT OR IGNORE INTO message_reactions (
|
|
message_id,
|
|
user_id,
|
|
emoji,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?)
|
|
""",
|
|
(message_id, user["id"], emoji, now_iso()),
|
|
)
|
|
|
|
audit("message_reaction_added", user["id"], "message", str(message_id), emoji)
|
|
return jsonify(
|
|
{
|
|
"message_id": message_id,
|
|
"reactions": reaction_summary_for_message(message_id, user),
|
|
}
|
|
), 201
|
|
|
|
@app.route("/api/messages/<int:message_id>/reactions/<path:emoji>", methods=["DELETE"])
|
|
def api_remove_reaction(message_id: int, emoji: str):
|
|
limited = rate_limited("reaction")
|
|
if limited:
|
|
return limited
|
|
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
|
|
user, error = require_writable_user()
|
|
if error:
|
|
return error
|
|
|
|
db_write(
|
|
"""
|
|
DELETE FROM message_reactions
|
|
WHERE message_id = ?
|
|
AND user_id = ?
|
|
AND emoji = ?
|
|
""",
|
|
(message_id, user["id"], emoji),
|
|
)
|
|
|
|
audit("message_reaction_removed", user["id"], "message", str(message_id), emoji)
|
|
return jsonify(
|
|
{
|
|
"message_id": message_id,
|
|
"reactions": reaction_summary_for_message(message_id, user),
|
|
}
|
|
)
|
|
|
|
@app.route("/api/messages/<int:message_id>/bookmark", methods=["POST"])
|
|
def api_bookmark_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify({"status": "stubbed", "message_id": message_id})
|
|
|
|
@app.route("/api/messages/<int:message_id>/bookmark", methods=["DELETE"])
|
|
def api_unbookmark_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify({"status": "stubbed", "message_id": message_id})
|
|
|
|
@app.route("/api/bookmarks")
|
|
def api_list_bookmarks():
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify({"messages": []})
|
|
|
|
@app.route("/api/messages/<int:message_id>/pin", methods=["POST"])
|
|
def api_pin_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify({"status": "stubbed", "message_id": message_id})
|
|
|
|
@app.route("/api/messages/<int:message_id>/pin", methods=["DELETE"])
|
|
def api_unpin_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify({"status": "stubbed", "message_id": message_id})
|
|
|
|
@app.route("/api/pins")
|
|
def api_list_pins():
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify({"messages": []})
|
|
|
|
@app.route("/api/messages/<int:message_id>/share", methods=["POST"])
|
|
def api_share_message(message_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify(
|
|
{
|
|
"share": {
|
|
"url": build_local_stub_share_url("message", message_id)
|
|
}
|
|
}
|
|
)
|
|
|
|
@app.route("/api/attachments/<int:attachment_id>/share", methods=["POST"])
|
|
def api_share_attachment(attachment_id: int):
|
|
csrf_error = require_csrf()
|
|
if csrf_error:
|
|
return csrf_error
|
|
user, error = require_user()
|
|
if error:
|
|
return error
|
|
return jsonify(
|
|
{
|
|
"share": {
|
|
"url": build_local_stub_share_url("attachment", attachment_id)
|
|
}
|
|
}
|
|
)
|