v1.6 backend refactor and api compatibility
This commit is contained in:
parent
2ef3309fb8
commit
216ca382b3
335
attachments.py
Normal file
335
attachments.py
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import abort, jsonify, send_from_directory
|
||||||
|
|
||||||
|
from db import get_db
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_MIME_PREFIX = "image/"
|
||||||
|
AUDIO_MIME_PREFIX = "audio/"
|
||||||
|
VIDEO_MIME_PREFIX = "video/"
|
||||||
|
|
||||||
|
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||||
|
AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"}
|
||||||
|
VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"}
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_kind_from_name_and_mime(
|
||||||
|
original_name: Optional[str],
|
||||||
|
mime_type: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
mime_type = str(mime_type or "").lower()
|
||||||
|
name = str(original_name or "")
|
||||||
|
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||||
|
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
|
||||||
|
return "image"
|
||||||
|
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
|
||||||
|
return "audio"
|
||||||
|
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
|
||||||
|
return "video"
|
||||||
|
return "file"
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_payload_from_row(row) -> Optional[dict]:
|
||||||
|
if not row or not row["id"]:
|
||||||
|
return None
|
||||||
|
kind = attachment_kind_from_name_and_mime(
|
||||||
|
row["original_name"],
|
||||||
|
row["mime_type"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"original_name": row["original_name"],
|
||||||
|
"mime_type": row["mime_type"],
|
||||||
|
"size_bytes": row["size_bytes"],
|
||||||
|
"kind": kind,
|
||||||
|
"url": f"/api/attachments/{row['id']}/download",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
|
||||||
|
if not row["attachment_id"]:
|
||||||
|
return None
|
||||||
|
kind = attachment_kind_from_name_and_mime(
|
||||||
|
row["attachment_original_name"],
|
||||||
|
row["attachment_mime_type"],
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"id": row["attachment_id"],
|
||||||
|
"original_name": row["attachment_original_name"],
|
||||||
|
"mime_type": row["attachment_mime_type"],
|
||||||
|
"size_bytes": row["attachment_size_bytes"],
|
||||||
|
"kind": kind,
|
||||||
|
"url": f"/api/attachments/{row['attachment_id']}/download",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_list_for_message(message_id: int) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.original_name,
|
||||||
|
a.mime_type,
|
||||||
|
a.size_bytes
|
||||||
|
FROM message_attachments AS ma
|
||||||
|
JOIN attachments AS a
|
||||||
|
ON a.id = ma.attachment_id
|
||||||
|
WHERE ma.message_id = ?
|
||||||
|
ORDER BY ma.position ASC, a.id ASC
|
||||||
|
""",
|
||||||
|
(message_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [attachment_payload_from_row(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_orphaned_attachments(
|
||||||
|
upload_dir: Path,
|
||||||
|
utc_now_func,
|
||||||
|
orphan_ttl_hours: int,
|
||||||
|
app_logger,
|
||||||
|
) -> None:
|
||||||
|
cutoff = (utc_now_func() - timedelta(hours=orphan_ttl_hours)).isoformat()
|
||||||
|
with get_db() as db:
|
||||||
|
orphaned = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.stored_name
|
||||||
|
FROM attachments AS a
|
||||||
|
WHERE a.created_at < ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM messages AS m
|
||||||
|
WHERE m.attachment_id = a.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM message_attachments AS ma
|
||||||
|
WHERE ma.attachment_id = a.id
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(cutoff,),
|
||||||
|
).fetchall()
|
||||||
|
for attachment in orphaned:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM attachments
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(attachment["id"],),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
for attachment in orphaned:
|
||||||
|
try:
|
||||||
|
(upload_dir / attachment["stored_name"]).unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
app_logger.warning(
|
||||||
|
"Could not remove orphaned attachment file: %s",
|
||||||
|
attachment["stored_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_cleanup_orphaned_attachments(
|
||||||
|
state: dict,
|
||||||
|
cleanup_interval_seconds: int,
|
||||||
|
cleanup_func,
|
||||||
|
app_logger,
|
||||||
|
) -> None:
|
||||||
|
import time
|
||||||
|
|
||||||
|
now_value = time.time()
|
||||||
|
last = state.get("last_attachment_cleanup", 0.0)
|
||||||
|
if now_value - last < cleanup_interval_seconds:
|
||||||
|
return
|
||||||
|
state["last_attachment_cleanup"] = now_value
|
||||||
|
try:
|
||||||
|
cleanup_func()
|
||||||
|
except Exception:
|
||||||
|
app_logger.exception("Could not clean orphaned attachments.")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_owned_attachment_ids(
|
||||||
|
attachment_ids: object,
|
||||||
|
user_id: int,
|
||||||
|
) -> tuple[Optional[list[int]], Optional[tuple]]:
|
||||||
|
if attachment_ids is None:
|
||||||
|
return [], None
|
||||||
|
if not isinstance(attachment_ids, list):
|
||||||
|
return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
|
||||||
|
|
||||||
|
parsed = []
|
||||||
|
seen = set()
|
||||||
|
for item in attachment_ids[:10]:
|
||||||
|
try:
|
||||||
|
attachment_id = int(item)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
||||||
|
if attachment_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(attachment_id)
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, uploader_user_id
|
||||||
|
FROM attachments
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(attachment_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None, (jsonify({"error": "Attachment not found."}), 404)
|
||||||
|
if row["uploader_user_id"] != user_id:
|
||||||
|
return None, (
|
||||||
|
jsonify({"error": "You can only attach files you uploaded."}),
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
parsed.append(attachment_id)
|
||||||
|
return parsed, None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_owned_attachment(
|
||||||
|
attachment_id: object,
|
||||||
|
user_id: int,
|
||||||
|
) -> tuple[Optional[int], Optional[tuple]]:
|
||||||
|
if attachment_id is None:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
parsed_id = int(attachment_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
attachment = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
uploader_user_id
|
||||||
|
FROM attachments
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(parsed_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not attachment:
|
||||||
|
return None, (jsonify({"error": "Attachment not found."}), 404)
|
||||||
|
if attachment["uploader_user_id"] != user_id:
|
||||||
|
return None, (
|
||||||
|
jsonify({"error": "You can only attach files you uploaded."}),
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
return parsed_id, None
|
||||||
|
|
||||||
|
|
||||||
|
def can_download_attachment(
|
||||||
|
attachment_id: int,
|
||||||
|
user: Optional[dict],
|
||||||
|
can_access_room,
|
||||||
|
can_access_channel,
|
||||||
|
get_channel_by_id,
|
||||||
|
) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
with get_db() as db:
|
||||||
|
room_rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
r.id,
|
||||||
|
r.slug,
|
||||||
|
r.name,
|
||||||
|
r.description,
|
||||||
|
r.visibility,
|
||||||
|
r.created_by_user_id,
|
||||||
|
r.is_active
|
||||||
|
FROM message_attachments AS ma
|
||||||
|
JOIN messages AS m
|
||||||
|
ON m.id = ma.message_id
|
||||||
|
JOIN rooms AS r
|
||||||
|
ON r.id = m.room_id
|
||||||
|
WHERE ma.attachment_id = ?
|
||||||
|
AND r.is_active = 1
|
||||||
|
""",
|
||||||
|
(attachment_id,),
|
||||||
|
).fetchall()
|
||||||
|
channel_rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
m.channel_id
|
||||||
|
FROM message_attachments AS ma
|
||||||
|
JOIN messages AS m
|
||||||
|
ON m.id = ma.message_id
|
||||||
|
WHERE ma.attachment_id = ?
|
||||||
|
AND m.channel_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
(attachment_id,),
|
||||||
|
).fetchall()
|
||||||
|
dm_rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT dc.id
|
||||||
|
FROM message_attachments AS ma
|
||||||
|
JOIN messages AS m
|
||||||
|
ON m.id = ma.message_id
|
||||||
|
JOIN direct_conversations AS dc
|
||||||
|
ON dc.id = m.direct_conversation_id
|
||||||
|
JOIN direct_conversation_members AS dcm
|
||||||
|
ON dcm.direct_conversation_id = dc.id
|
||||||
|
WHERE ma.attachment_id = ?
|
||||||
|
AND dcm.user_id = ?
|
||||||
|
""",
|
||||||
|
(attachment_id, user["id"]),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if dm_rows:
|
||||||
|
return True
|
||||||
|
if any(can_access_room(room, user) for room in room_rows):
|
||||||
|
return True
|
||||||
|
for item in channel_rows:
|
||||||
|
channel = get_channel_by_id(item["channel_id"])
|
||||||
|
if channel and can_access_channel(channel, user):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_attachment_file(
|
||||||
|
upload_dir: Path,
|
||||||
|
attachment_id: int,
|
||||||
|
user: Optional[dict],
|
||||||
|
can_access_room,
|
||||||
|
can_access_channel,
|
||||||
|
get_channel_by_id,
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
attachment = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
stored_name,
|
||||||
|
original_name,
|
||||||
|
mime_type
|
||||||
|
FROM attachments
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(attachment_id,),
|
||||||
|
).fetchone()
|
||||||
|
if not attachment:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if not can_download_attachment(
|
||||||
|
attachment_id,
|
||||||
|
user,
|
||||||
|
can_access_room,
|
||||||
|
can_access_channel,
|
||||||
|
get_channel_by_id,
|
||||||
|
):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
return send_from_directory(
|
||||||
|
upload_dir,
|
||||||
|
attachment["stored_name"],
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=attachment["original_name"],
|
||||||
|
mimetype=attachment["mime_type"],
|
||||||
|
)
|
||||||
498
auth.py
Normal file
498
auth.py
Normal file
@ -0,0 +1,498 @@
|
|||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from flask import jsonify, request, session
|
||||||
|
from werkzeug.exceptions import BadRequest
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
CLIENT_HASH_KEY,
|
||||||
|
PLACEHOLDER_SECRETS,
|
||||||
|
PRIVATE_ACCESS_KEY,
|
||||||
|
RATE_LIMITS,
|
||||||
|
ROLES,
|
||||||
|
STATUSES,
|
||||||
|
MAX_PASSWORD_LENGTH,
|
||||||
|
MIN_PASSWORD_LENGTH,
|
||||||
|
MAX_USERNAME_LENGTH,
|
||||||
|
MIN_USERNAME_LENGTH,
|
||||||
|
now_iso,
|
||||||
|
parse_iso,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
from db import db_write, get_db
|
||||||
|
|
||||||
|
USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_username(value: object) -> str:
|
||||||
|
return value.strip().lower() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_username(username: str) -> Optional[str]:
|
||||||
|
if not username:
|
||||||
|
return "Username is required."
|
||||||
|
if len(username) < MIN_USERNAME_LENGTH:
|
||||||
|
return f"Username must be at least {MIN_USERNAME_LENGTH} characters."
|
||||||
|
if len(username) > MAX_USERNAME_LENGTH:
|
||||||
|
return f"Username must be {MAX_USERNAME_LENGTH} characters or less."
|
||||||
|
if not USERNAME_RE.fullmatch(username):
|
||||||
|
return "Username contains invalid characters."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(password: object, username: str = "") -> Optional[str]:
|
||||||
|
if not isinstance(password, str) or not password:
|
||||||
|
return "Password is required."
|
||||||
|
if len(password) < MIN_PASSWORD_LENGTH:
|
||||||
|
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||||
|
if len(password) > MAX_PASSWORD_LENGTH:
|
||||||
|
return f"Password must be {MAX_PASSWORD_LENGTH} characters or less."
|
||||||
|
if not re.search(r"[a-z]", password):
|
||||||
|
return "Password must include a lowercase letter."
|
||||||
|
if not re.search(r"[A-Z]", password):
|
||||||
|
return "Password must include an uppercase letter."
|
||||||
|
if not re.search(r"\d", password):
|
||||||
|
return "Password must include a number."
|
||||||
|
if not re.search(r"[^A-Za-z0-9]", password):
|
||||||
|
return "Password must include a symbol."
|
||||||
|
if username and username in password.lower():
|
||||||
|
return "Password must not contain your username."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def safe_json() -> dict:
|
||||||
|
if not request.is_json:
|
||||||
|
raise ValueError("Request body must be JSON.")
|
||||||
|
try:
|
||||||
|
data = request.get_json(silent=False)
|
||||||
|
except BadRequest as error:
|
||||||
|
raise ValueError("Invalid JSON request body.") from error
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("JSON body must be an object.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_token() -> str:
|
||||||
|
token = session.get("csrf_token")
|
||||||
|
if not token:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
session["csrf_token"] = token
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def csrf_valid() -> bool:
|
||||||
|
supplied = request.headers.get("X-CSRF-Token", "")
|
||||||
|
expected = session.get("csrf_token", "")
|
||||||
|
return bool(supplied and expected and hmac.compare_digest(supplied, expected))
|
||||||
|
|
||||||
|
|
||||||
|
def client_ip() -> str:
|
||||||
|
return (request.remote_addr or "unknown")[:128]
|
||||||
|
|
||||||
|
|
||||||
|
def ctx_hash():
|
||||||
|
return hashlib.sha256
|
||||||
|
|
||||||
|
|
||||||
|
def hash_identifier(value: str) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
CLIENT_HASH_KEY.encode("utf-8"),
|
||||||
|
value.encode("utf-8"),
|
||||||
|
ctx_hash(),
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def b64url_encode_bytes(data: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_call_helper_token(payload: dict, shared_secret: str) -> str:
|
||||||
|
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
payload_bytes = payload_json.encode("utf-8")
|
||||||
|
signature = hmac.new(
|
||||||
|
shared_secret.encode("utf-8"),
|
||||||
|
payload_bytes,
|
||||||
|
hashlib.sha256,
|
||||||
|
).digest()
|
||||||
|
return f"{b64url_encode_bytes(payload_bytes)}.{b64url_encode_bytes(signature)}"
|
||||||
|
|
||||||
|
|
||||||
|
def current_client_hash() -> str:
|
||||||
|
return hash_identifier(client_ip())
|
||||||
|
|
||||||
|
|
||||||
|
def audit(
|
||||||
|
action: str,
|
||||||
|
actor_user_id: Optional[int] = None,
|
||||||
|
target_type: Optional[str] = None,
|
||||||
|
target_id: Optional[str] = None,
|
||||||
|
details: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_log (
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
details,
|
||||||
|
client_hash,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
actor_user_id,
|
||||||
|
action,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
details,
|
||||||
|
current_client_hash(),
|
||||||
|
now_iso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def active_blacklist_match(kind: str, value_hash: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM blacklist_entries
|
||||||
|
WHERE kind = ?
|
||||||
|
AND value_hash = ?
|
||||||
|
AND (
|
||||||
|
expires_at IS NULL
|
||||||
|
OR expires_at > ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(kind, value_hash, now_iso()),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def request_blacklisted() -> bool:
|
||||||
|
if request.endpoint in {"static"}:
|
||||||
|
return False
|
||||||
|
if active_blacklist_match("ip", current_client_hash()):
|
||||||
|
return True
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if user_id and active_blacklist_match("user", hash_identifier(str(user_id))):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate_limit(scope: str) -> Optional[int]:
|
||||||
|
limit, window_seconds = RATE_LIMITS[scope]
|
||||||
|
now_value = time.time()
|
||||||
|
cutoff = now_value - window_seconds
|
||||||
|
subject_hash = current_client_hash()
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute("DELETE FROM rate_limit_events WHERE created_at < ?", (cutoff - 60,))
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT created_at
|
||||||
|
FROM rate_limit_events
|
||||||
|
WHERE scope = ?
|
||||||
|
AND subject_hash = ?
|
||||||
|
AND created_at >= ?
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
""",
|
||||||
|
(scope, subject_hash, cutoff),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
if len(rows) >= limit:
|
||||||
|
oldest = rows[0]["created_at"]
|
||||||
|
return max(1, int(window_seconds - (now_value - oldest)) + 1)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rate_limit_events (
|
||||||
|
scope,
|
||||||
|
subject_hash,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(scope, subject_hash, now_value),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rate_limited(scope: str):
|
||||||
|
retry_after = check_rate_limit(scope)
|
||||||
|
if retry_after is None:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": "Too many requests. Please wait and try again.",
|
||||||
|
"code": "rate_limited",
|
||||||
|
"retry_after": retry_after,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
429,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def user_payload(row, settings_row=None) -> dict:
|
||||||
|
status = row["status"]
|
||||||
|
restricted_until = parse_iso(row["restricted_until"])
|
||||||
|
if status == "restricted" and restricted_until and restricted_until <= utc_now():
|
||||||
|
status = "active"
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"role": row["role"],
|
||||||
|
"status": status,
|
||||||
|
"restricted_until": row["restricted_until"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def current_user() -> Optional[dict]:
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
restricted_until
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
session.clear()
|
||||||
|
return None
|
||||||
|
|
||||||
|
user = user_payload(row)
|
||||||
|
|
||||||
|
if user["status"] == "restricted":
|
||||||
|
expires = parse_iso(user["restricted_until"])
|
||||||
|
if expires and expires <= utc_now():
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
status = 'active',
|
||||||
|
restricted_until = NULL,
|
||||||
|
restriction_reason = NULL,
|
||||||
|
is_active = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user["id"],),
|
||||||
|
)
|
||||||
|
user["status"] = "active"
|
||||||
|
user["restricted_until"] = None
|
||||||
|
|
||||||
|
if user["status"] in {"disabled", "blacklisted"}:
|
||||||
|
session.clear()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def login_user(row) -> dict:
|
||||||
|
session.clear()
|
||||||
|
session.permanent = True
|
||||||
|
session["user_id"] = row["id"]
|
||||||
|
session["csrf_token"] = secrets.token_urlsafe(32)
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"role": row["role"],
|
||||||
|
"status": row["status"],
|
||||||
|
"restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def require_user():
|
||||||
|
user = current_user()
|
||||||
|
if not user:
|
||||||
|
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
def require_writable_user():
|
||||||
|
user, error = require_user()
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
if user["status"] == "restricted":
|
||||||
|
return None, (
|
||||||
|
jsonify(
|
||||||
|
{
|
||||||
|
"error": "Your account is temporarily restricted.",
|
||||||
|
"code": "restricted",
|
||||||
|
"restricted_until": user["restricted_until"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
403,
|
||||||
|
)
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
def require_moderator():
|
||||||
|
user, error = require_writable_user()
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
if user["role"] not in {"moderator", "admin"}:
|
||||||
|
return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403)
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin():
|
||||||
|
user, error = require_writable_user()
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
if user["role"] != "admin":
|
||||||
|
return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403)
|
||||||
|
return user, None
|
||||||
|
|
||||||
|
|
||||||
|
def private_access_key_valid(candidate: object) -> bool:
|
||||||
|
configured = (PRIVATE_ACCESS_KEY or "").strip()
|
||||||
|
if not configured:
|
||||||
|
return True
|
||||||
|
if not isinstance(candidate, str):
|
||||||
|
return False
|
||||||
|
return candidate.strip() == configured
|
||||||
|
|
||||||
|
|
||||||
|
def register_account(data: dict):
|
||||||
|
access_key = data.get("access_key", "")
|
||||||
|
if not private_access_key_valid(access_key):
|
||||||
|
return None, (jsonify({"error": "Invalid private access key."}), 403)
|
||||||
|
|
||||||
|
username = normalize_username(data.get("username"))
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
username_error = validate_username(username)
|
||||||
|
password_error = validate_password(password, username)
|
||||||
|
|
||||||
|
if username_error:
|
||||||
|
return None, (jsonify({"error": username_error}), 400)
|
||||||
|
if password_error:
|
||||||
|
return None, (jsonify({"error": password_error}), 400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor = db_write(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
is_admin,
|
||||||
|
is_active,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, 'user', 'active', 0, 1, ?)
|
||||||
|
""",
|
||||||
|
(username, generate_password_hash(password), now_iso()),
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
if "UNIQUE" in str(error).upper():
|
||||||
|
return None, (jsonify({"error": "Unable to create account with those details."}), 409)
|
||||||
|
raise
|
||||||
|
|
||||||
|
user = {
|
||||||
|
"id": cursor.lastrowid,
|
||||||
|
"username": username,
|
||||||
|
"role": "user",
|
||||||
|
"status": "active",
|
||||||
|
"restricted_until": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO user_profiles (
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
badge_text,
|
||||||
|
bio,
|
||||||
|
bio_mode,
|
||||||
|
website,
|
||||||
|
location,
|
||||||
|
status_message,
|
||||||
|
banner_attachment_id,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
||||||
|
""",
|
||||||
|
(user["id"], username, now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO user_settings (
|
||||||
|
user_id,
|
||||||
|
accent_color,
|
||||||
|
name_color,
|
||||||
|
custom_css,
|
||||||
|
theme_mode,
|
||||||
|
profile_background_color,
|
||||||
|
profile_banner_color,
|
||||||
|
profile_visibility,
|
||||||
|
last_active_visibility,
|
||||||
|
presence_mode,
|
||||||
|
dm_layout_mode,
|
||||||
|
show_presence_badge,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
||||||
|
""",
|
||||||
|
(user["id"], now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
authenticated = login_user(user)
|
||||||
|
audit("account_registered", user["id"], "user", str(user["id"]))
|
||||||
|
return authenticated, None
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_account(data: dict):
|
||||||
|
access_key = data.get("access_key", "")
|
||||||
|
if not private_access_key_valid(access_key):
|
||||||
|
return None, (jsonify({"error": "Invalid private access key."}), 403)
|
||||||
|
|
||||||
|
username = normalize_username(data.get("username"))
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
restricted_until
|
||||||
|
FROM users
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password):
|
||||||
|
return None, (jsonify({"error": "Invalid username or password."}), 401)
|
||||||
|
|
||||||
|
if row["status"] in {"disabled", "blacklisted"}:
|
||||||
|
return None, (jsonify({"error": "Invalid username or password."}), 401)
|
||||||
|
|
||||||
|
db_write("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"]))
|
||||||
|
authenticated = login_user(row)
|
||||||
|
audit("account_logged_in", row["id"], "user", str(row["id"]))
|
||||||
|
return authenticated, None
|
||||||
464
calls.py
Normal file
464
calls.py
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from db import db_write, get_db
|
||||||
|
from config import utc_now, now_iso, GROUP_CALL_PARTICIPANT_STALE_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def create_call_session(dm_id: int, started_by_user_id: int) -> int:
|
||||||
|
cursor = db_write(
|
||||||
|
"""
|
||||||
|
INSERT INTO call_sessions (
|
||||||
|
direct_conversation_id,
|
||||||
|
started_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
) VALUES (?, ?, 'active', ?, NULL)
|
||||||
|
""",
|
||||||
|
(dm_id, started_by_user_id, now_iso()),
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def append_call_signal(call_id: int, from_user_id: int, signal_type: str, payload: dict) -> int:
|
||||||
|
cursor = db_write(
|
||||||
|
"""
|
||||||
|
INSERT INTO call_signals (
|
||||||
|
call_session_id,
|
||||||
|
from_user_id,
|
||||||
|
signal_type,
|
||||||
|
payload_json,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
call_id,
|
||||||
|
from_user_id,
|
||||||
|
signal_type,
|
||||||
|
json.dumps(payload or {}),
|
||||||
|
now_iso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def list_call_signals(call_id: int, after_id: int = 0) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
cs.id,
|
||||||
|
cs.call_session_id,
|
||||||
|
cs.from_user_id,
|
||||||
|
u.username,
|
||||||
|
cs.signal_type,
|
||||||
|
cs.payload_json,
|
||||||
|
cs.created_at
|
||||||
|
FROM call_signals AS cs
|
||||||
|
JOIN users AS u
|
||||||
|
ON u.id = cs.from_user_id
|
||||||
|
WHERE cs.call_session_id = ?
|
||||||
|
AND cs.id > ?
|
||||||
|
ORDER BY cs.id ASC
|
||||||
|
LIMIT 200
|
||||||
|
""",
|
||||||
|
(call_id, after_id),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
payload = json.loads(row["payload_json"] or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
payload = {}
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"call_session_id": row["call_session_id"],
|
||||||
|
"from_user_id": row["from_user_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"signal_type": row["signal_type"],
|
||||||
|
"payload": payload,
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def end_call(call_id: int) -> None:
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE call_sessions
|
||||||
|
SET
|
||||||
|
status = 'ended',
|
||||||
|
ended_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now_iso(), call_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_call_with_dm(call_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
cs.id,
|
||||||
|
cs.direct_conversation_id,
|
||||||
|
cs.started_by_user_id,
|
||||||
|
cs.status,
|
||||||
|
cs.created_at,
|
||||||
|
cs.ended_at
|
||||||
|
FROM call_sessions AS cs
|
||||||
|
WHERE cs.id = ?
|
||||||
|
""",
|
||||||
|
(call_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def call_payload(call_id: int):
|
||||||
|
row = get_call_with_dm(call_id)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"direct_conversation_id": row["direct_conversation_id"],
|
||||||
|
"started_by_user_id": row["started_by_user_id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"ended_at": row["ended_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_room_helper_call_id(room) -> str:
|
||||||
|
return f"room-{room['slug']}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_channel_helper_call_id(channel) -> str:
|
||||||
|
return f"channel-{channel['team_slug']}--{channel['slug']}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_group_call_session(
|
||||||
|
scope_kind: str,
|
||||||
|
scope_id: int,
|
||||||
|
scope_slug: str,
|
||||||
|
helper_call_id: str,
|
||||||
|
created_by_user_id: int,
|
||||||
|
):
|
||||||
|
with get_db() as db:
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
FROM group_call_sessions
|
||||||
|
WHERE helper_call_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(helper_call_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing and existing["status"] == "active":
|
||||||
|
return existing
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
FROM group_call_sessions
|
||||||
|
WHERE helper_call_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(helper_call_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing and existing["status"] == "active":
|
||||||
|
db.commit()
|
||||||
|
return existing
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE group_call_sessions
|
||||||
|
SET
|
||||||
|
status = 'active',
|
||||||
|
ended_at = NULL,
|
||||||
|
created_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(now_iso(), existing["id"]),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
refreshed = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
FROM group_call_sessions
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(existing["id"],),
|
||||||
|
).fetchone()
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_call_sessions (
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
now_iso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
session_id = cursor.lastrowid
|
||||||
|
db.commit()
|
||||||
|
created = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
FROM group_call_sessions
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def group_call_session_payload(row):
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"scope_kind": row["scope_kind"],
|
||||||
|
"scope_id": row["scope_id"],
|
||||||
|
"scope_slug": row["scope_slug"],
|
||||||
|
"helper_call_id": row["helper_call_id"],
|
||||||
|
"created_by_user_id": row["created_by_user_id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"ended_at": row["ended_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_group_call_session_by_id(session_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
scope_kind,
|
||||||
|
scope_id,
|
||||||
|
scope_slug,
|
||||||
|
helper_call_id,
|
||||||
|
created_by_user_id,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
ended_at
|
||||||
|
FROM group_call_sessions
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_stale_group_call_participants(session_id: int):
|
||||||
|
cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE group_call_participants
|
||||||
|
SET
|
||||||
|
left_at = COALESCE(left_at, ?)
|
||||||
|
WHERE group_call_session_id = ?
|
||||||
|
AND left_at IS NULL
|
||||||
|
AND (
|
||||||
|
updated_at IS NULL
|
||||||
|
OR strftime('%s', updated_at) < ?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(now_iso(), session_id, int(cutoff)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_group_call_participant(session_id: int, user_id: int):
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
INSERT INTO group_call_participants (
|
||||||
|
group_call_session_id,
|
||||||
|
user_id,
|
||||||
|
joined_at,
|
||||||
|
updated_at,
|
||||||
|
left_at
|
||||||
|
) VALUES (?, ?, ?, ?, NULL)
|
||||||
|
ON CONFLICT(group_call_session_id, user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
left_at = NULL
|
||||||
|
""",
|
||||||
|
(session_id, user_id, now_iso(), now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def heartbeat_group_call_participant(session_id: int, user_id: int):
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE group_call_participants
|
||||||
|
SET
|
||||||
|
updated_at = ?,
|
||||||
|
left_at = NULL
|
||||||
|
WHERE group_call_session_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
""",
|
||||||
|
(now_iso(), session_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_group_call_participant_left(session_id: int, user_id: int):
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE group_call_participants
|
||||||
|
SET
|
||||||
|
updated_at = ?,
|
||||||
|
left_at = ?
|
||||||
|
WHERE group_call_session_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND left_at IS NULL
|
||||||
|
""",
|
||||||
|
(now_iso(), now_iso(), session_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_group_call_participants(session_id: int) -> list[dict]:
|
||||||
|
cleanup_stale_group_call_participants(session_id)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
gcp.group_call_session_id,
|
||||||
|
gcp.user_id,
|
||||||
|
gcp.joined_at,
|
||||||
|
gcp.updated_at,
|
||||||
|
gcp.left_at,
|
||||||
|
u.username,
|
||||||
|
u.role,
|
||||||
|
up.display_name,
|
||||||
|
us.name_color
|
||||||
|
FROM group_call_participants AS gcp
|
||||||
|
JOIN users AS u
|
||||||
|
ON u.id = gcp.user_id
|
||||||
|
LEFT JOIN user_profiles AS up
|
||||||
|
ON up.user_id = u.id
|
||||||
|
LEFT JOIN user_settings AS us
|
||||||
|
ON us.user_id = u.id
|
||||||
|
WHERE gcp.group_call_session_id = ?
|
||||||
|
AND gcp.left_at IS NULL
|
||||||
|
ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"display_name": row["display_name"] or row["username"],
|
||||||
|
"role": row["role"],
|
||||||
|
"name_color": row["name_color"],
|
||||||
|
"joined_at": row["joined_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def active_group_call_participant_count(session_id: int) -> int:
|
||||||
|
cleanup_stale_group_call_participants(session_id)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM group_call_participants
|
||||||
|
WHERE group_call_session_id = ?
|
||||||
|
AND left_at IS NULL
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["count"]) if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_end_group_call_if_empty(session_id: int):
|
||||||
|
if active_group_call_participant_count(session_id) > 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE group_call_sessions
|
||||||
|
SET
|
||||||
|
status = 'ended',
|
||||||
|
ended_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND status != 'ended'
|
||||||
|
""",
|
||||||
|
(now_iso(), session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
db_write(
|
||||||
|
"""
|
||||||
|
UPDATE group_call_invites
|
||||||
|
SET
|
||||||
|
status = 'dismissed',
|
||||||
|
responded_at = ?
|
||||||
|
WHERE group_call_session_id = ?
|
||||||
|
AND status = 'pending'
|
||||||
|
""",
|
||||||
|
(now_iso(), session_id),
|
||||||
|
)
|
||||||
231
config.py
Normal file
231
config.py
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta, timezone, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("CHAT_DB_PATH", str(APP_ROOT / "chat.db"))
|
||||||
|
UPLOAD_DIR = Path(os.environ.get("CHAT_UPLOAD_DIR", str(APP_ROOT / "uploads")))
|
||||||
|
|
||||||
|
SECRET_KEY_PATH = os.environ.get(
|
||||||
|
"CHAT_SECRET_KEY_PATH",
|
||||||
|
str(Path(DB_PATH).resolve().parent / "flask_secret.key"),
|
||||||
|
)
|
||||||
|
CLIENT_HASH_KEY_PATH = os.environ.get(
|
||||||
|
"CHAT_CLIENT_HASH_KEY_PATH",
|
||||||
|
str(Path(DB_PATH).resolve().parent / "client_hash.key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
|
||||||
|
MAX_REQUEST_BYTES = max(110 * 1024 * 1024, MAX_ATTACHMENT_BYTES + 10 * 1024 * 1024)
|
||||||
|
|
||||||
|
MAX_MESSAGE_LENGTH = 1000
|
||||||
|
MAX_USERNAME_LENGTH = 30
|
||||||
|
MIN_USERNAME_LENGTH = 3
|
||||||
|
MIN_PASSWORD_LENGTH = 7
|
||||||
|
MAX_PASSWORD_LENGTH = 256
|
||||||
|
MAX_HISTORY_MESSAGES = 100
|
||||||
|
MAX_INCREMENTAL_MESSAGES = 100
|
||||||
|
MAX_FEED_MESSAGES = 50
|
||||||
|
MAX_THREAD_REPLIES = 100
|
||||||
|
MAX_ATTACHMENT_NAME_LENGTH = 150
|
||||||
|
ATTACHMENT_ORPHAN_TTL_HOURS = 24
|
||||||
|
ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60
|
||||||
|
INVITE_TTL_HOURS = 72
|
||||||
|
TEAM_INVITE_TTL_HOURS = 72
|
||||||
|
TYPING_TTL_SECONDS = 7
|
||||||
|
PRESENCE_TTL_SECONDS = 40
|
||||||
|
MAX_PROFILE_BIO_LENGTH = 5000
|
||||||
|
MAX_DISPLAY_NAME_LENGTH = 80
|
||||||
|
MAX_LOCATION_LENGTH = 120
|
||||||
|
MAX_WEBSITE_LENGTH = 300
|
||||||
|
MAX_CUSTOM_CSS_LENGTH = 20000
|
||||||
|
MAX_PROFILE_BADGE_LENGTH = 48
|
||||||
|
MAX_OWNED_TEAMS_PER_USER = 3
|
||||||
|
MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60
|
||||||
|
MAX_SEARCH_QUERY_LENGTH = 120
|
||||||
|
MAX_SEARCH_RESULTS = 100
|
||||||
|
READ_RECEIPT_BATCH_LIMIT = 200
|
||||||
|
GROUP_CALL_PARTICIPANT_STALE_SECONDS = 45
|
||||||
|
|
||||||
|
PRIVATE_ACCESS_KEY = os.environ.get("PRIVATE_ACCESS_KEY", "").strip()
|
||||||
|
|
||||||
|
CALLS_BASE = os.environ.get("CALLS_BASE", "https://calls.example.com").rstrip("/")
|
||||||
|
CALL_HELPER_SHARED_SECRET = os.environ.get("CALL_HELPER_SHARED_SECRET", "").strip()
|
||||||
|
CALL_TOKEN_TTL_SECONDS = int(os.environ.get("CALL_TOKEN_TTL_SECONDS", "60"))
|
||||||
|
|
||||||
|
ALLOWED_ATTACHMENT_EXTENSIONS = {
|
||||||
|
"txt",
|
||||||
|
"pdf",
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"mp3",
|
||||||
|
"wav",
|
||||||
|
"ogg",
|
||||||
|
"m4a",
|
||||||
|
"aac",
|
||||||
|
"flac",
|
||||||
|
"mp4",
|
||||||
|
"webm",
|
||||||
|
"mov",
|
||||||
|
"m4v",
|
||||||
|
"ogv",
|
||||||
|
}
|
||||||
|
|
||||||
|
ALLOWED_ATTACHMENT_MIME_TYPES = {
|
||||||
|
"text/plain",
|
||||||
|
"application/pdf",
|
||||||
|
"image/png",
|
||||||
|
"image/jpeg",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/x-wav",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/flac",
|
||||||
|
"video/mp4",
|
||||||
|
"video/webm",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/ogg",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLES = {"user", "moderator", "admin"}
|
||||||
|
STATUSES = {"active", "disabled", "restricted", "blacklisted"}
|
||||||
|
ROOM_VISIBILITIES = {"public", "private"}
|
||||||
|
|
||||||
|
RATE_LIMITS = {
|
||||||
|
"login": (8, 10 * 60),
|
||||||
|
"register": (5, 60 * 60),
|
||||||
|
"send": (30, 60),
|
||||||
|
"admin": (60, 60),
|
||||||
|
"attachment": (20, 60),
|
||||||
|
"typing": (60, 60),
|
||||||
|
"reaction": (120, 60),
|
||||||
|
"profile": (30, 60),
|
||||||
|
"settings": (30, 60),
|
||||||
|
}
|
||||||
|
|
||||||
|
PLACEHOLDER_SECRETS = {
|
||||||
|
"",
|
||||||
|
"change-this-secret-key",
|
||||||
|
"replace-this-with-a-long-random-secret",
|
||||||
|
"replace-me",
|
||||||
|
"secret",
|
||||||
|
"changeme",
|
||||||
|
}
|
||||||
|
|
||||||
|
RUNTIME_STATE = {
|
||||||
|
"last_attachment_cleanup": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return utc_now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_iso(value: Optional[str]) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if not parsed.tzinfo:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> str:
|
||||||
|
configured = os.environ.get(env_value, "").strip()
|
||||||
|
if configured and configured.lower() not in PLACEHOLDER_SECRETS and len(configured) >= min_length:
|
||||||
|
return configured
|
||||||
|
|
||||||
|
path = Path(path_value)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if path.exists():
|
||||||
|
existing = path.read_text(encoding="utf-8").strip()
|
||||||
|
if len(existing) >= min_length:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
generated = secrets.token_urlsafe(64)
|
||||||
|
try:
|
||||||
|
path.write_text(generated, encoding="utf-8")
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return generated
|
||||||
|
|
||||||
|
|
||||||
|
SECRET_KEY = load_or_create_key(SECRET_KEY_PATH, "FLASK_SECRET_KEY")
|
||||||
|
CLIENT_HASH_KEY = load_or_create_key(CLIENT_HASH_KEY_PATH, "CHAT_CLIENT_HASH_KEY")
|
||||||
|
|
||||||
|
TRUSTED_PROXY_HOPS = int(os.environ.get("TRUSTED_PROXY_HOPS", "0"))
|
||||||
|
SESSION_COOKIE_SECURE = (os.environ.get("SESSION_COOKIE_SECURE", "1") == "1")
|
||||||
|
PERMANENT_SESSION_LIFETIME = timedelta(days=14)
|
||||||
|
|
||||||
|
|
||||||
|
def get_asset_version() -> str:
|
||||||
|
try:
|
||||||
|
js_dir = APP_ROOT / "static" / "js"
|
||||||
|
values = [int((APP_ROOT / "static" / "style.css").stat().st_mtime)]
|
||||||
|
values.extend(int(path.stat().st_mtime) for path in js_dir.glob("*.js"))
|
||||||
|
return str(max(values))
|
||||||
|
except OSError:
|
||||||
|
return "1"
|
||||||
|
|
||||||
|
|
||||||
|
def flask_config() -> dict:
|
||||||
|
return {
|
||||||
|
"MAX_CONTENT_LENGTH": MAX_REQUEST_BYTES,
|
||||||
|
"SESSION_COOKIE_NAME": "chat_session",
|
||||||
|
"SESSION_COOKIE_HTTPONLY": True,
|
||||||
|
"SESSION_COOKIE_SAMESITE": "Lax",
|
||||||
|
"SESSION_COOKIE_SECURE": SESSION_COOKIE_SECURE,
|
||||||
|
"PERMANENT_SESSION_LIFETIME": PERMANENT_SESSION_LIFETIME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CONSTANTS = {
|
||||||
|
"MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH,
|
||||||
|
"MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES,
|
||||||
|
"MAX_INCREMENTAL_MESSAGES": MAX_INCREMENTAL_MESSAGES,
|
||||||
|
"MAX_FEED_MESSAGES": MAX_FEED_MESSAGES,
|
||||||
|
"MAX_THREAD_REPLIES": MAX_THREAD_REPLIES,
|
||||||
|
"MAX_ATTACHMENT_BYTES": MAX_ATTACHMENT_BYTES,
|
||||||
|
"MAX_ATTACHMENT_NAME_LENGTH": MAX_ATTACHMENT_NAME_LENGTH,
|
||||||
|
"ALLOWED_ATTACHMENT_EXTENSIONS": ALLOWED_ATTACHMENT_EXTENSIONS,
|
||||||
|
"ALLOWED_ATTACHMENT_MIME_TYPES": ALLOWED_ATTACHMENT_MIME_TYPES,
|
||||||
|
"INVITE_TTL_HOURS": INVITE_TTL_HOURS,
|
||||||
|
"TEAM_INVITE_TTL_HOURS": TEAM_INVITE_TTL_HOURS,
|
||||||
|
"TYPING_TTL_SECONDS": TYPING_TTL_SECONDS,
|
||||||
|
"PRESENCE_TTL_SECONDS": PRESENCE_TTL_SECONDS,
|
||||||
|
"MAX_SEARCH_QUERY_LENGTH": MAX_SEARCH_QUERY_LENGTH,
|
||||||
|
"MAX_SEARCH_RESULTS": MAX_SEARCH_RESULTS,
|
||||||
|
"READ_RECEIPT_BATCH_LIMIT": READ_RECEIPT_BATCH_LIMIT,
|
||||||
|
"MAX_MESSAGE_EDIT_WINDOW_SECONDS": MAX_MESSAGE_EDIT_WINDOW_SECONDS,
|
||||||
|
"MAX_DISPLAY_NAME_LENGTH": MAX_DISPLAY_NAME_LENGTH,
|
||||||
|
"MAX_PROFILE_BIO_LENGTH": MAX_PROFILE_BIO_LENGTH,
|
||||||
|
"MAX_LOCATION_LENGTH": MAX_LOCATION_LENGTH,
|
||||||
|
"MAX_WEBSITE_LENGTH": MAX_WEBSITE_LENGTH,
|
||||||
|
"MAX_CUSTOM_CSS_LENGTH": MAX_CUSTOM_CSS_LENGTH,
|
||||||
|
"MAX_PROFILE_BADGE_LENGTH": MAX_PROFILE_BADGE_LENGTH,
|
||||||
|
"MAX_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER,
|
||||||
|
"ROOM_VISIBILITIES": ROOM_VISIBILITIES,
|
||||||
|
"ROLES": ROLES,
|
||||||
|
"STATUSES": STATUSES,
|
||||||
|
"GROUP_CALL_PARTICIPANT_STALE_SECONDS": GROUP_CALL_PARTICIPANT_STALE_SECONDS,
|
||||||
|
}
|
||||||
948
db.py
Normal file
948
db.py
Normal file
@ -0,0 +1,948 @@
|
|||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
DB_PATH,
|
||||||
|
UPLOAD_DIR,
|
||||||
|
ROLES,
|
||||||
|
STATUSES,
|
||||||
|
now_iso,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
db_dir = os.path.dirname(DB_PATH)
|
||||||
|
if db_dir:
|
||||||
|
os.makedirs(db_dir, exist_ok=True)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout = 8000")
|
||||||
|
conn.execute("PRAGMA synchronous = NORMAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
cursor = db.execute(query, params)
|
||||||
|
db.commit()
|
||||||
|
return cursor
|
||||||
|
except sqlite3.OperationalError as error:
|
||||||
|
last_error = error
|
||||||
|
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
|
||||||
|
raise
|
||||||
|
if attempt == 4:
|
||||||
|
raise
|
||||||
|
time.sleep(0.05 * (2 ** attempt))
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool:
|
||||||
|
rows = db.execute(f"PRAGMA table_info({table})").fetchall()
|
||||||
|
return any(row["name"] == column for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
restricted_until TEXT,
|
||||||
|
restriction_reason TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS rooms (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS room_members (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, user_id),
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
uploader_user_id INTEGER NOT NULL,
|
||||||
|
stored_name TEXT UNIQUE NOT NULL,
|
||||||
|
original_name TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS teams (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS team_members (
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (team_id, user_id),
|
||||||
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS team_invites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT UNIQUE NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
used_by_user_id INTEGER,
|
||||||
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
legacy_room_id INTEGER UNIQUE,
|
||||||
|
slug TEXT COLLATE NOCASE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(team_id, slug),
|
||||||
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_conversations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
member_pair_key TEXT UNIQUE NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_conversation_members (
|
||||||
|
direct_conversation_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
added_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (direct_conversation_id, user_id),
|
||||||
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id INTEGER,
|
||||||
|
channel_id INTEGER,
|
||||||
|
direct_conversation_id INTEGER,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
attachment_id INTEGER,
|
||||||
|
parent_message_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS room_invites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
token_hash TEXT UNIQUE NOT NULL,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
used_by_user_id INTEGER,
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS typing_events (
|
||||||
|
room_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, user_id),
|
||||||
|
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS channel_typing_events (
|
||||||
|
channel_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (channel_id, user_id),
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_typing_events (
|
||||||
|
direct_conversation_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
PRIMARY KEY (direct_conversation_id, user_id),
|
||||||
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS blacklist_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
value_hash TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT,
|
||||||
|
created_by_user_id INTEGER,
|
||||||
|
UNIQUE(kind, value_hash),
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
subject_hash TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
actor_user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
target_type TEXT,
|
||||||
|
target_id TEXT,
|
||||||
|
details TEXT,
|
||||||
|
client_hash TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS pages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
team_id INTEGER NOT NULL,
|
||||||
|
channel_id INTEGER,
|
||||||
|
slug TEXT COLLATE NOCASE NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL DEFAULT '',
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'team',
|
||||||
|
page_type TEXT NOT NULL DEFAULT 'document',
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
updated_by_user_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE(team_id, slug),
|
||||||
|
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE RESTRICT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
display_name TEXT,
|
||||||
|
badge_text TEXT,
|
||||||
|
bio TEXT NOT NULL DEFAULT '',
|
||||||
|
bio_mode TEXT NOT NULL DEFAULT 'txt',
|
||||||
|
website TEXT,
|
||||||
|
location TEXT,
|
||||||
|
status_message TEXT NOT NULL DEFAULT '',
|
||||||
|
banner_attachment_id INTEGER,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
accent_color TEXT,
|
||||||
|
name_color TEXT,
|
||||||
|
custom_css TEXT NOT NULL DEFAULT '',
|
||||||
|
theme_mode TEXT NOT NULL DEFAULT 'dark',
|
||||||
|
profile_background_color TEXT,
|
||||||
|
profile_banner_color TEXT,
|
||||||
|
profile_visibility TEXT NOT NULL DEFAULT 'public',
|
||||||
|
last_active_visibility TEXT NOT NULL DEFAULT 'everyone',
|
||||||
|
presence_mode TEXT NOT NULL DEFAULT 'online',
|
||||||
|
dm_layout_mode TEXT NOT NULL DEFAULT 'cards',
|
||||||
|
show_presence_badge INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (message_id, user_id, emoji),
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_edits (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
edited_by_user_id INTEGER NOT NULL,
|
||||||
|
previous_text TEXT NOT NULL,
|
||||||
|
new_text TEXT NOT NULL,
|
||||||
|
previous_attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
new_attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_receipts (
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
seen_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (message_id, user_id),
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_attachments (
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
attachment_id INTEGER NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (message_id, attachment_id),
|
||||||
|
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS presence_events (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
view_name TEXT,
|
||||||
|
room_id INTEGER,
|
||||||
|
channel_id INTEGER,
|
||||||
|
direct_conversation_id INTEGER,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS call_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
direct_conversation_id INTEGER NOT NULL,
|
||||||
|
started_by_user_id INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS call_signals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
call_session_id INTEGER NOT NULL,
|
||||||
|
from_user_id INTEGER NOT NULL,
|
||||||
|
signal_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS group_call_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scope_kind TEXT NOT NULL,
|
||||||
|
scope_id INTEGER NOT NULL,
|
||||||
|
scope_slug TEXT NOT NULL,
|
||||||
|
helper_call_id TEXT NOT NULL UNIQUE,
|
||||||
|
created_by_user_id INTEGER NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT,
|
||||||
|
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS group_call_invites (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
group_call_session_id INTEGER NOT NULL,
|
||||||
|
inviter_user_id INTEGER NOT NULL,
|
||||||
|
invitee_user_id INTEGER NOT NULL,
|
||||||
|
helper_token TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
responded_at TEXT,
|
||||||
|
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS group_call_participants (
|
||||||
|
group_call_session_id INTEGER NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
joined_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
left_at TEXT,
|
||||||
|
PRIMARY KEY (group_call_session_id, user_id),
|
||||||
|
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not column_exists(db, "messages", "room_id"):
|
||||||
|
db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER")
|
||||||
|
if not column_exists(db, "messages", "attachment_id"):
|
||||||
|
db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER")
|
||||||
|
if not column_exists(db, "messages", "parent_message_id"):
|
||||||
|
db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER")
|
||||||
|
if not column_exists(db, "messages", "channel_id"):
|
||||||
|
db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER")
|
||||||
|
if not column_exists(db, "messages", "direct_conversation_id"):
|
||||||
|
db.execute("ALTER TABLE messages ADD COLUMN direct_conversation_id INTEGER")
|
||||||
|
|
||||||
|
if not column_exists(db, "user_profiles", "badge_text"):
|
||||||
|
db.execute("ALTER TABLE user_profiles ADD COLUMN badge_text TEXT")
|
||||||
|
if not column_exists(db, "user_profiles", "status_message"):
|
||||||
|
db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''")
|
||||||
|
if not column_exists(db, "user_profiles", "banner_attachment_id"):
|
||||||
|
db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER")
|
||||||
|
|
||||||
|
if not column_exists(db, "user_settings", "name_color"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT")
|
||||||
|
if not column_exists(db, "user_settings", "profile_background_color"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT")
|
||||||
|
if not column_exists(db, "user_settings", "profile_banner_color"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT")
|
||||||
|
if not column_exists(db, "user_settings", "profile_visibility"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'")
|
||||||
|
if not column_exists(db, "user_settings", "last_active_visibility"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'")
|
||||||
|
if not column_exists(db, "user_settings", "presence_mode"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'")
|
||||||
|
if not column_exists(db, "user_settings", "dm_layout_mode"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'")
|
||||||
|
if not column_exists(db, "user_settings", "show_presence_badge"):
|
||||||
|
db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1")
|
||||||
|
|
||||||
|
if not column_exists(db, "presence_events", "direct_conversation_id"):
|
||||||
|
db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_id INTEGER")
|
||||||
|
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel_id_id ON messages(channel_id, id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_team_id_slug ON channels(team_id, slug)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_legacy_room_id ON channels(legacy_room_id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_pages_team_updated_at ON pages(team_id, updated_at DESC)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_channel_typing_events_updated_at ON channel_typing_events(channel_id, updated_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_dm_typing_events_updated_at ON dm_typing_events(direct_conversation_id, updated_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_invites_token_hash ON room_invites(token_hash)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_direct_conversation_members_user ON direct_conversation_members(user_id, direct_conversation_id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_call_signals_lookup ON call_signals(call_session_id, id)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_user_settings_name_color ON user_settings(user_id, name_color)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_sessions_scope ON group_call_sessions(scope_kind, scope_id, status)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_invites_invitee_status ON group_call_invites(invitee_user_id, status, expires_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)")
|
||||||
|
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)")
|
||||||
|
|
||||||
|
general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone()
|
||||||
|
if not general:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO rooms (
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
visibility,
|
||||||
|
created_at,
|
||||||
|
is_active
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 1)
|
||||||
|
""",
|
||||||
|
("general", "general", "Default public chat room", "public", now_iso()),
|
||||||
|
)
|
||||||
|
general_id = cursor.lastrowid
|
||||||
|
else:
|
||||||
|
general_id = general["id"]
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"UPDATE messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL",
|
||||||
|
(general_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET role = CASE
|
||||||
|
WHEN is_admin = 1 THEN 'admin'
|
||||||
|
ELSE 'user'
|
||||||
|
END
|
||||||
|
WHERE role IS NULL
|
||||||
|
OR role = ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET status = CASE
|
||||||
|
WHEN is_active = 1 THEN 'active'
|
||||||
|
ELSE 'disabled'
|
||||||
|
END
|
||||||
|
WHERE status IS NULL
|
||||||
|
OR status = ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone()
|
||||||
|
if general_team:
|
||||||
|
general_team_id = general_team["id"]
|
||||||
|
else:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO teams (
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
is_active
|
||||||
|
) VALUES (?, ?, ?, NULL, ?, 1)
|
||||||
|
""",
|
||||||
|
("general", "General", "Default team for existing chat rooms.", now_iso()),
|
||||||
|
)
|
||||||
|
general_team_id = cursor.lastrowid
|
||||||
|
|
||||||
|
legacy_rooms = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at
|
||||||
|
FROM rooms
|
||||||
|
WHERE is_active = 1
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for room in legacy_rooms:
|
||||||
|
existing_channel = db.execute(
|
||||||
|
"SELECT id FROM channels WHERE legacy_room_id = ?",
|
||||||
|
(room["id"],),
|
||||||
|
).fetchone()
|
||||||
|
if existing_channel:
|
||||||
|
continue
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO channels (
|
||||||
|
team_id,
|
||||||
|
legacy_room_id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
is_active
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
general_team_id,
|
||||||
|
room["id"],
|
||||||
|
room["slug"],
|
||||||
|
room["name"],
|
||||||
|
room["description"],
|
||||||
|
room["created_by_user_id"],
|
||||||
|
room["created_at"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE messages
|
||||||
|
SET channel_id = (
|
||||||
|
SELECT c.id
|
||||||
|
FROM channels AS c
|
||||||
|
WHERE c.legacy_room_id = messages.room_id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE channel_id IS NULL
|
||||||
|
AND room_id IS NOT NULL
|
||||||
|
AND direct_conversation_id IS NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_users = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE role = 'admin'
|
||||||
|
AND status = 'active'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for admin_user in admin_users:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO team_members (
|
||||||
|
team_id,
|
||||||
|
user_id,
|
||||||
|
membership_role,
|
||||||
|
added_at
|
||||||
|
) VALUES (?, ?, 'admin', ?)
|
||||||
|
""",
|
||||||
|
(general_team_id, admin_user["id"], now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO user_profiles (
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
badge_text,
|
||||||
|
bio,
|
||||||
|
bio_mode,
|
||||||
|
website,
|
||||||
|
location,
|
||||||
|
status_message,
|
||||||
|
banner_attachment_id,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'txt',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
NULL,
|
||||||
|
?
|
||||||
|
FROM users
|
||||||
|
""",
|
||||||
|
(now_iso(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO user_settings (
|
||||||
|
user_id,
|
||||||
|
accent_color,
|
||||||
|
name_color,
|
||||||
|
custom_css,
|
||||||
|
theme_mode,
|
||||||
|
profile_background_color,
|
||||||
|
profile_banner_color,
|
||||||
|
profile_visibility,
|
||||||
|
last_active_visibility,
|
||||||
|
presence_mode,
|
||||||
|
dm_layout_mode,
|
||||||
|
show_presence_badge,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
'#f7dce8',
|
||||||
|
NULL,
|
||||||
|
'',
|
||||||
|
'dark',
|
||||||
|
NULL,
|
||||||
|
NULL,
|
||||||
|
'public',
|
||||||
|
'everyone',
|
||||||
|
'online',
|
||||||
|
'cards',
|
||||||
|
1,
|
||||||
|
?
|
||||||
|
FROM users
|
||||||
|
""",
|
||||||
|
(now_iso(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_message_attachment_counts = db.execute(
|
||||||
|
"SELECT COUNT(*) AS count FROM message_attachments"
|
||||||
|
).fetchone()["count"]
|
||||||
|
|
||||||
|
if existing_message_attachment_counts == 0:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, attachment_id
|
||||||
|
FROM messages
|
||||||
|
WHERE attachment_id IS NOT NULL
|
||||||
|
ORDER BY id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO message_attachments (
|
||||||
|
message_id,
|
||||||
|
attachment_id,
|
||||||
|
position
|
||||||
|
) VALUES (?, ?, 0)
|
||||||
|
""",
|
||||||
|
(row["id"], row["attachment_id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_admin() -> None:
|
||||||
|
username = os.environ.get("ADMIN_USERNAME", "").strip().lower()
|
||||||
|
password = os.environ.get("ADMIN_PASSWORD", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT id FROM users WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
role = 'admin',
|
||||||
|
status = 'active',
|
||||||
|
is_admin = 1,
|
||||||
|
is_active = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(existing["id"],),
|
||||||
|
)
|
||||||
|
user_id = existing["id"]
|
||||||
|
else:
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
is_admin,
|
||||||
|
is_active,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, 'admin', 'active', 1, 1, ?)
|
||||||
|
""",
|
||||||
|
(username, generate_password_hash(password), now_iso()),
|
||||||
|
)
|
||||||
|
user_id = cursor.lastrowid
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO user_profiles (
|
||||||
|
user_id,
|
||||||
|
display_name,
|
||||||
|
badge_text,
|
||||||
|
bio,
|
||||||
|
bio_mode,
|
||||||
|
website,
|
||||||
|
location,
|
||||||
|
status_message,
|
||||||
|
banner_attachment_id,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
||||||
|
""",
|
||||||
|
(user_id, username, now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO user_settings (
|
||||||
|
user_id,
|
||||||
|
accent_color,
|
||||||
|
name_color,
|
||||||
|
custom_css,
|
||||||
|
theme_mode,
|
||||||
|
profile_background_color,
|
||||||
|
profile_banner_color,
|
||||||
|
profile_visibility,
|
||||||
|
last_active_visibility,
|
||||||
|
presence_mode,
|
||||||
|
dm_layout_mode,
|
||||||
|
show_presence_badge,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
||||||
|
""",
|
||||||
|
(user_id, now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
1537
features.py
1537
features.py
File diff suppressed because it is too large
Load Diff
642
models.py
Normal file
642
models.py
Normal file
@ -0,0 +1,642 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from config import utc_now
|
||||||
|
from db import get_db
|
||||||
|
from auth import user_payload
|
||||||
|
|
||||||
|
ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$")
|
||||||
|
|
||||||
|
|
||||||
|
MESSAGE_SELECT = """
|
||||||
|
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,
|
||||||
|
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 attachments AS a
|
||||||
|
ON a.id = m.attachment_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def team_page_select() -> str:
|
||||||
|
has_parent_page = False
|
||||||
|
try:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute("PRAGMA table_info(pages)").fetchall()
|
||||||
|
has_parent_page = any(row["name"] == "parent_page_id" for row in rows)
|
||||||
|
except Exception:
|
||||||
|
has_parent_page = False
|
||||||
|
|
||||||
|
if has_parent_page:
|
||||||
|
return """
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.team_id,
|
||||||
|
p.channel_id,
|
||||||
|
p.parent_page_id,
|
||||||
|
parent.slug AS parent_page_slug,
|
||||||
|
parent.title AS parent_page_title,
|
||||||
|
p.slug,
|
||||||
|
p.title,
|
||||||
|
p.body,
|
||||||
|
p.visibility,
|
||||||
|
p.page_type,
|
||||||
|
p.created_by_user_id,
|
||||||
|
p.updated_by_user_id,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
p.is_active,
|
||||||
|
t.slug AS team_slug,
|
||||||
|
t.name AS team_name,
|
||||||
|
c.slug AS channel_slug,
|
||||||
|
c.name AS channel_name
|
||||||
|
FROM pages AS p
|
||||||
|
JOIN teams AS t
|
||||||
|
ON t.id = p.team_id
|
||||||
|
LEFT JOIN channels AS c
|
||||||
|
ON c.id = p.channel_id
|
||||||
|
LEFT JOIN pages AS parent
|
||||||
|
ON parent.id = p.parent_page_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
return """
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
p.team_id,
|
||||||
|
p.channel_id,
|
||||||
|
NULL AS parent_page_id,
|
||||||
|
NULL AS parent_page_slug,
|
||||||
|
NULL AS parent_page_title,
|
||||||
|
p.slug,
|
||||||
|
p.title,
|
||||||
|
p.body,
|
||||||
|
p.visibility,
|
||||||
|
p.page_type,
|
||||||
|
p.created_by_user_id,
|
||||||
|
p.updated_by_user_id,
|
||||||
|
p.created_at,
|
||||||
|
p.updated_at,
|
||||||
|
p.is_active,
|
||||||
|
t.slug AS team_slug,
|
||||||
|
t.name AS team_name,
|
||||||
|
c.slug AS channel_slug,
|
||||||
|
c.name AS channel_name
|
||||||
|
FROM pages AS p
|
||||||
|
JOIN teams AS t
|
||||||
|
ON t.id = p.team_id
|
||||||
|
LEFT JOIN channels AS c
|
||||||
|
ON c.id = p.channel_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def team_slug_from_name(name: str) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")[:48].strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def page_slug_from_title(title: str) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")[:48].strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def room_by_id(room_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
visibility,
|
||||||
|
created_by_user_id,
|
||||||
|
is_active
|
||||||
|
FROM rooms
|
||||||
|
WHERE id = ?
|
||||||
|
AND is_active = 1
|
||||||
|
""",
|
||||||
|
(room_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_room_by_slug(slug: str):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
visibility,
|
||||||
|
created_by_user_id,
|
||||||
|
is_active
|
||||||
|
FROM rooms
|
||||||
|
WHERE slug = ?
|
||||||
|
AND is_active = 1
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def room_member(room_id: int, user_id: int) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM room_members
|
||||||
|
WHERE room_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
""",
|
||||||
|
(room_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def room_owner(room_id: int, user_id: int) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM room_members
|
||||||
|
WHERE room_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND membership_role = 'owner'
|
||||||
|
""",
|
||||||
|
(room_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_room(room, user: Optional[dict]) -> bool:
|
||||||
|
if room["visibility"] == "public":
|
||||||
|
return True
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if user["role"] in {"moderator", "admin"}:
|
||||||
|
return True
|
||||||
|
return room_member(room["id"], user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def can_write_room(room, user: Optional[dict]) -> bool:
|
||||||
|
if not user or user["status"] != "active":
|
||||||
|
return False
|
||||||
|
return can_access_room(room, user)
|
||||||
|
|
||||||
|
|
||||||
|
def can_manage_room(room, user: Optional[dict]) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if user["role"] == "admin":
|
||||||
|
return True
|
||||||
|
return room_owner(room["id"], user["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def room_payload(room, user: Optional[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"id": room["id"],
|
||||||
|
"slug": room["slug"],
|
||||||
|
"name": room["name"],
|
||||||
|
"description": room["description"],
|
||||||
|
"visibility": room["visibility"],
|
||||||
|
"can_read": can_access_room(room, user),
|
||||||
|
"can_write": can_write_room(room, user),
|
||||||
|
"can_manage": can_manage_room(room, user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_team_by_slug(slug: str):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by_user_id,
|
||||||
|
created_at,
|
||||||
|
is_active
|
||||||
|
FROM teams
|
||||||
|
WHERE slug = ?
|
||||||
|
AND is_active = 1
|
||||||
|
""",
|
||||||
|
(slug,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_by_id(channel_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.team_id,
|
||||||
|
c.legacy_room_id,
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.description,
|
||||||
|
c.created_by_user_id,
|
||||||
|
c.created_at,
|
||||||
|
c.is_active,
|
||||||
|
t.slug AS team_slug,
|
||||||
|
t.name AS team_name,
|
||||||
|
t.description AS team_description,
|
||||||
|
t.is_active AS team_is_active
|
||||||
|
FROM channels AS c
|
||||||
|
JOIN teams AS t
|
||||||
|
ON t.id = c.team_id
|
||||||
|
WHERE c.id = ?
|
||||||
|
AND c.is_active = 1
|
||||||
|
AND t.is_active = 1
|
||||||
|
""",
|
||||||
|
(channel_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_by_slug(team_slug: str, channel_slug: str):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.team_id,
|
||||||
|
c.legacy_room_id,
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.description,
|
||||||
|
c.created_by_user_id,
|
||||||
|
c.created_at,
|
||||||
|
c.is_active,
|
||||||
|
t.slug AS team_slug,
|
||||||
|
t.name AS team_name,
|
||||||
|
t.description AS team_description,
|
||||||
|
t.is_active AS team_is_active
|
||||||
|
FROM channels AS c
|
||||||
|
JOIN teams AS t
|
||||||
|
ON t.id = c.team_id
|
||||||
|
WHERE t.slug = ?
|
||||||
|
AND c.slug = ?
|
||||||
|
AND t.is_active = 1
|
||||||
|
AND c.is_active = 1
|
||||||
|
""",
|
||||||
|
(team_slug, channel_slug),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_channel_by_legacy_room_id(room_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.team_id,
|
||||||
|
c.legacy_room_id,
|
||||||
|
c.slug,
|
||||||
|
c.name,
|
||||||
|
c.description,
|
||||||
|
c.created_by_user_id,
|
||||||
|
c.created_at,
|
||||||
|
c.is_active,
|
||||||
|
t.slug AS team_slug,
|
||||||
|
t.name AS team_name,
|
||||||
|
t.description AS team_description,
|
||||||
|
t.is_active AS team_is_active
|
||||||
|
FROM channels AS c
|
||||||
|
JOIN teams AS t
|
||||||
|
ON t.id = c.team_id
|
||||||
|
WHERE c.legacy_room_id = ?
|
||||||
|
AND c.is_active = 1
|
||||||
|
AND t.is_active = 1
|
||||||
|
""",
|
||||||
|
(room_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def team_member_role(team_id: int, user_id: int) -> Optional[str]:
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT membership_role
|
||||||
|
FROM team_members
|
||||||
|
WHERE team_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
""",
|
||||||
|
(team_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return row["membership_role"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def is_team_member(team_id: int, user_id: int) -> bool:
|
||||||
|
return team_member_role(team_id, user_id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def is_team_admin(team_id: int, user: Optional[dict]) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
if user["role"] == "admin":
|
||||||
|
return True
|
||||||
|
return team_member_role(team_id, user["id"]) in {"owner", "admin"}
|
||||||
|
|
||||||
|
|
||||||
|
def can_edit_team(team_id: int, user: Optional[dict]) -> bool:
|
||||||
|
return is_team_admin(team_id, user)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_team(team, user: Optional[dict]) -> bool:
|
||||||
|
if not team["is_active"]:
|
||||||
|
return False
|
||||||
|
if user and user["role"] in {"moderator", "admin"}:
|
||||||
|
return True
|
||||||
|
return bool(user and is_team_member(team["id"], user["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_room_for_channel(channel):
|
||||||
|
if not channel["legacy_room_id"]:
|
||||||
|
return None
|
||||||
|
return room_by_id(channel["legacy_room_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_channel(channel, user: Optional[dict]) -> bool:
|
||||||
|
if user and user["role"] in {"moderator", "admin"}:
|
||||||
|
return True
|
||||||
|
legacy_room = legacy_room_for_channel(channel)
|
||||||
|
if legacy_room:
|
||||||
|
return can_access_room(legacy_room, user)
|
||||||
|
return bool(user and is_team_member(channel["team_id"], user["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def can_write_channel(channel, user: Optional[dict]) -> bool:
|
||||||
|
return bool(user and user["status"] == "active" and can_access_channel(channel, user))
|
||||||
|
|
||||||
|
|
||||||
|
def team_payload(team, user: Optional[dict]) -> dict:
|
||||||
|
membership_role = team_member_role(team["id"], user["id"]) if user else None
|
||||||
|
return {
|
||||||
|
"id": team["id"],
|
||||||
|
"slug": team["slug"],
|
||||||
|
"name": team["name"],
|
||||||
|
"description": team["description"],
|
||||||
|
"membership_role": membership_role,
|
||||||
|
"can_read": can_access_team(team, user),
|
||||||
|
"can_manage": is_team_admin(team["id"], user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def channel_payload(channel, user: Optional[dict]) -> dict:
|
||||||
|
return {
|
||||||
|
"id": channel["id"],
|
||||||
|
"team_id": channel["team_id"],
|
||||||
|
"team_slug": channel["team_slug"],
|
||||||
|
"team_name": channel["team_name"],
|
||||||
|
"legacy_room_id": channel["legacy_room_id"],
|
||||||
|
"slug": channel["slug"],
|
||||||
|
"name": channel["name"],
|
||||||
|
"description": channel["description"],
|
||||||
|
"can_read": can_access_channel(channel, user),
|
||||||
|
"can_write": can_write_channel(channel, user),
|
||||||
|
"can_manage": is_team_admin(channel["team_id"], user),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_dm_for_user(dm_id: int, user_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
dc.id,
|
||||||
|
dc.created_at
|
||||||
|
FROM direct_conversations AS dc
|
||||||
|
JOIN direct_conversation_members AS dcm
|
||||||
|
ON dcm.direct_conversation_id = dc.id
|
||||||
|
WHERE dc.id = ?
|
||||||
|
AND dcm.user_id = ?
|
||||||
|
""",
|
||||||
|
(dm_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def get_dm_other_user(dm_id: int, user_id: int):
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.username,
|
||||||
|
u.role,
|
||||||
|
up.display_name,
|
||||||
|
up.status_message,
|
||||||
|
us.name_color,
|
||||||
|
us.presence_mode
|
||||||
|
FROM direct_conversation_members AS dcm
|
||||||
|
JOIN users AS u
|
||||||
|
ON u.id = dcm.user_id
|
||||||
|
LEFT JOIN user_profiles AS up
|
||||||
|
ON up.user_id = u.id
|
||||||
|
LEFT JOIN user_settings AS us
|
||||||
|
ON us.user_id = u.id
|
||||||
|
WHERE dcm.direct_conversation_id = ?
|
||||||
|
AND dcm.user_id != ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(dm_id, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dm_access(dm_id: int, user: Optional[dict]) -> bool:
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
return bool(get_dm_for_user(dm_id, user["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def dm_payload(dm_id: int, user_id: int, resolve_name_color) -> dict:
|
||||||
|
dm = get_dm_for_user(dm_id, user_id)
|
||||||
|
other = get_dm_other_user(dm_id, user_id)
|
||||||
|
return {
|
||||||
|
"id": dm["id"],
|
||||||
|
"created_at": dm["created_at"],
|
||||||
|
"other_user": {
|
||||||
|
"id": other["id"],
|
||||||
|
"username": other["username"],
|
||||||
|
"display_name": other["display_name"] or other["username"],
|
||||||
|
"role": other["role"],
|
||||||
|
"name_color": other["name_color"],
|
||||||
|
"resolved_name_color": resolve_name_color(other["role"], other["name_color"]),
|
||||||
|
"status_message": other["status_message"] or "",
|
||||||
|
"presence_mode": other["presence_mode"] or "online",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_or_create_dm(user_a_id: int, user_b_id: int, now_iso_func) -> int:
|
||||||
|
low_id, high_id = sorted([int(user_a_id), int(user_b_id)])
|
||||||
|
with get_db() as db:
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT dc.id
|
||||||
|
FROM direct_conversations AS dc
|
||||||
|
JOIN direct_conversation_members AS m1
|
||||||
|
ON m1.direct_conversation_id = dc.id AND m1.user_id = ?
|
||||||
|
JOIN direct_conversation_members AS m2
|
||||||
|
ON m2.direct_conversation_id = dc.id AND m2.user_id = ?
|
||||||
|
WHERE dc.member_pair_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(low_id, high_id, f"{low_id}:{high_id}"),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return existing["id"]
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
db.execute("BEGIN IMMEDIATE")
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM direct_conversations
|
||||||
|
WHERE member_pair_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(f"{low_id}:{high_id}",),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
db.commit()
|
||||||
|
return existing["id"]
|
||||||
|
|
||||||
|
cursor = db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO direct_conversations (
|
||||||
|
member_pair_key,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(f"{low_id}:{high_id}", now_iso_func()),
|
||||||
|
)
|
||||||
|
dm_id = cursor.lastrowid
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO direct_conversation_members (
|
||||||
|
direct_conversation_id,
|
||||||
|
user_id,
|
||||||
|
added_at
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(dm_id, low_id, now_iso_func()),
|
||||||
|
)
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO direct_conversation_members (
|
||||||
|
direct_conversation_id,
|
||||||
|
user_id,
|
||||||
|
added_at
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(dm_id, high_id, now_iso_func()),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return dm_id
|
||||||
|
|
||||||
|
|
||||||
|
def update_presence(
|
||||||
|
db_write_func,
|
||||||
|
user_id: int,
|
||||||
|
view_name: Optional[str] = None,
|
||||||
|
room_id: Optional[int] = None,
|
||||||
|
channel_id: Optional[int] = None,
|
||||||
|
direct_conversation_id: Optional[int] = None,
|
||||||
|
) -> None:
|
||||||
|
db_write_func(
|
||||||
|
"""
|
||||||
|
INSERT INTO presence_events (
|
||||||
|
user_id,
|
||||||
|
updated_at,
|
||||||
|
view_name,
|
||||||
|
room_id,
|
||||||
|
channel_id,
|
||||||
|
direct_conversation_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id)
|
||||||
|
DO UPDATE SET
|
||||||
|
updated_at = excluded.updated_at,
|
||||||
|
view_name = excluded.view_name,
|
||||||
|
room_id = excluded.room_id,
|
||||||
|
channel_id = excluded.channel_id,
|
||||||
|
direct_conversation_id = excluded.direct_conversation_id
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
user_id,
|
||||||
|
time.time(),
|
||||||
|
view_name,
|
||||||
|
room_id,
|
||||||
|
channel_id,
|
||||||
|
direct_conversation_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def room_presence_count(ttl_seconds: int, room_id: int) -> int:
|
||||||
|
cutoff = time.time() - ttl_seconds
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM presence_events
|
||||||
|
WHERE room_id = ?
|
||||||
|
AND updated_at >= ?
|
||||||
|
""",
|
||||||
|
(room_id, cutoff),
|
||||||
|
).fetchone()
|
||||||
|
return row["count"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def channel_presence_count(ttl_seconds: int, channel_id: int) -> int:
|
||||||
|
cutoff = time.time() - ttl_seconds
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM presence_events
|
||||||
|
WHERE channel_id = ?
|
||||||
|
AND updated_at >= ?
|
||||||
|
""",
|
||||||
|
(channel_id, cutoff),
|
||||||
|
).fetchone()
|
||||||
|
return row["count"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def dm_presence_count(ttl_seconds: int, dm_id: int) -> int:
|
||||||
|
cutoff = time.time() - ttl_seconds
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM presence_events
|
||||||
|
WHERE direct_conversation_id = ?
|
||||||
|
AND updated_at >= ?
|
||||||
|
""",
|
||||||
|
(dm_id, cutoff),
|
||||||
|
).fetchone()
|
||||||
|
return row["count"] if row else 0
|
||||||
658
profiles.py
Normal file
658
profiles.py
Normal file
@ -0,0 +1,658 @@
|
|||||||
|
import html
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
from sanitizer import sanitize_html_fragment
|
||||||
|
from db import get_db
|
||||||
|
|
||||||
|
DEFAULT_ROLE_NAME_COLORS = {
|
||||||
|
"admin": "#ff7b7b",
|
||||||
|
"moderator": "#8ab4ff",
|
||||||
|
"user": "#f5f5f7",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROFILE_BIO_MODES = {"txt", "md", "html"}
|
||||||
|
THEME_MODES = {"dark", "light"}
|
||||||
|
PAGE_VISIBILITIES = {"team", "public"}
|
||||||
|
PAGE_TYPES = {"project", "document", "update", "resource"}
|
||||||
|
TEAM_MEMBER_ROLES = {"owner", "admin", "member"}
|
||||||
|
PROFILE_VISIBILITIES = {"public", "members", "private"}
|
||||||
|
LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"}
|
||||||
|
PRESENCE_MODES = {"online", "silent", "focus", "dnd"}
|
||||||
|
DM_LAYOUT_MODES = {"cards", "compact"}
|
||||||
|
REACTION_EMOJI_RE = re.compile(r"^.{1,16}$")
|
||||||
|
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||||
|
PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def valid_hex_color(value: object) -> bool:
|
||||||
|
return bool(isinstance(value, str) and HEX_COLOR_RE.fullmatch(value.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def normalized_hex_color(value: object) -> Optional[str]:
|
||||||
|
if not valid_hex_color(value):
|
||||||
|
return None
|
||||||
|
return str(value).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def role_default_name_color(role: Optional[str]) -> str:
|
||||||
|
return DEFAULT_ROLE_NAME_COLORS.get(str(role or "user"), DEFAULT_ROLE_NAME_COLORS["user"])
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_name_color(user_role: Optional[str], explicit_color: Optional[str]) -> str:
|
||||||
|
return normalized_hex_color(explicit_color) or role_default_name_color(user_role)
|
||||||
|
|
||||||
|
|
||||||
|
def render_markdown_to_safe_html(text: str) -> str:
|
||||||
|
rendered = markdown.markdown(
|
||||||
|
text or "",
|
||||||
|
extensions=[
|
||||||
|
"extra",
|
||||||
|
"sane_lists",
|
||||||
|
"nl2br",
|
||||||
|
"tables",
|
||||||
|
"fenced_code",
|
||||||
|
],
|
||||||
|
output_format="html5",
|
||||||
|
)
|
||||||
|
return sanitize_html_fragment(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def safe_profile_bio_html(bio: str, bio_mode: str) -> str:
|
||||||
|
mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt"
|
||||||
|
if mode == "html":
|
||||||
|
return sanitize_html_fragment(bio or "")
|
||||||
|
if mode == "md":
|
||||||
|
return render_markdown_to_safe_html(bio or "")
|
||||||
|
return "<p>" + html.escape(bio or "").replace("\n", "<br>") + "</p>"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_page_reference_slugs(markdown_text: str) -> list[str]:
|
||||||
|
text = str(markdown_text or "")
|
||||||
|
matches = PAGE_LINK_RE.findall(text)
|
||||||
|
seen = set()
|
||||||
|
slugs = []
|
||||||
|
for slug in matches:
|
||||||
|
normalized = slug.lower().strip()
|
||||||
|
if not normalized or normalized in seen:
|
||||||
|
continue
|
||||||
|
seen.add(normalized)
|
||||||
|
slugs.append(normalized)
|
||||||
|
return slugs
|
||||||
|
|
||||||
|
|
||||||
|
def detect_website_brand(website: str) -> dict:
|
||||||
|
value = str(website or "").strip().lower()
|
||||||
|
if not value:
|
||||||
|
return {"host": "", "brand": "web", "label": "website"}
|
||||||
|
|
||||||
|
host = value
|
||||||
|
host = host.replace("https://", "").replace("http://", "")
|
||||||
|
host = host.split("/", 1)[0]
|
||||||
|
host = host.removeprefix("www.")
|
||||||
|
|
||||||
|
mappings = [
|
||||||
|
("github.com", "github", "GitHub"),
|
||||||
|
("youtube.com", "youtube", "YouTube"),
|
||||||
|
("youtu.be", "youtube", "YouTube"),
|
||||||
|
("spotify.com", "spotify", "Spotify"),
|
||||||
|
("soundcloud.com", "soundcloud", "SoundCloud"),
|
||||||
|
("twitter.com", "x", "X"),
|
||||||
|
("x.com", "x", "X"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for suffix, brand, label in mappings:
|
||||||
|
if host.endswith(suffix):
|
||||||
|
return {"host": host, "brand": brand, "label": label}
|
||||||
|
|
||||||
|
return {"host": host, "brand": "web", "label": "website"}
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_table_exists(table_name: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name = ?
|
||||||
|
""",
|
||||||
|
(table_name,),
|
||||||
|
).fetchone()
|
||||||
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def sqlite_column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||||
|
return any(row["name"] == column_name for row in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def load_page_children(page_id: int) -> list[dict]:
|
||||||
|
if not sqlite_column_exists("pages", "parent_page_id"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
updated_at
|
||||||
|
FROM pages
|
||||||
|
WHERE parent_page_id = ?
|
||||||
|
AND is_active = 1
|
||||||
|
ORDER BY title COLLATE NOCASE, id ASC
|
||||||
|
""",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def load_page_backlinks(page_id: int, page_slug: str, team_id: int) -> list[dict]:
|
||||||
|
pattern_1 = f"%]({page_slug})%"
|
||||||
|
pattern_2 = f"%/pages/{page_slug})%"
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
updated_at
|
||||||
|
FROM pages
|
||||||
|
WHERE team_id = ?
|
||||||
|
AND is_active = 1
|
||||||
|
AND id != ?
|
||||||
|
AND (body LIKE ? OR body LIKE ?)
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
""",
|
||||||
|
(team_id, page_id, pattern_1, pattern_2),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def load_page_comments(page_id: int) -> list[dict]:
|
||||||
|
if not sqlite_table_exists("page_comments"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
pc.id,
|
||||||
|
pc.page_id,
|
||||||
|
pc.user_id,
|
||||||
|
pc.username,
|
||||||
|
pc.body,
|
||||||
|
pc.created_at,
|
||||||
|
pc.updated_at,
|
||||||
|
u.role AS user_role,
|
||||||
|
us.name_color AS user_name_color
|
||||||
|
FROM page_comments AS pc
|
||||||
|
JOIN users AS u
|
||||||
|
ON u.id = pc.user_id
|
||||||
|
LEFT JOIN user_settings AS us
|
||||||
|
ON us.user_id = u.id
|
||||||
|
WHERE pc.page_id = ?
|
||||||
|
AND pc.deleted_at IS NULL
|
||||||
|
ORDER BY pc.created_at ASC, pc.id ASC
|
||||||
|
""",
|
||||||
|
(page_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"page_id": row["page_id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"body": row["body"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"user_role": row["user_role"],
|
||||||
|
"user_name_color": resolve_name_color(row["user_role"], row["user_name_color"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def reaction_summary_for_message(message_id: int, user: Optional[dict]) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
emoji,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted
|
||||||
|
FROM message_reactions
|
||||||
|
WHERE message_id = ?
|
||||||
|
GROUP BY emoji
|
||||||
|
ORDER BY count DESC, emoji ASC
|
||||||
|
""",
|
||||||
|
(user["id"] if user else -1, message_id),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"emoji": row["emoji"],
|
||||||
|
"count": row["count"],
|
||||||
|
"reacted": bool(row["reacted"]),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_profile_privacy_context(viewer: Optional[dict], target_user_id: int) -> dict:
|
||||||
|
is_self = bool(viewer and viewer["id"] == target_user_id)
|
||||||
|
is_staff = bool(viewer and viewer["role"] in {"moderator", "admin"})
|
||||||
|
is_authenticated = bool(viewer)
|
||||||
|
return {
|
||||||
|
"is_self": is_self,
|
||||||
|
"is_staff": is_staff,
|
||||||
|
"is_authenticated": is_authenticated,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_profile_record(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
||||||
|
visibility = "public"
|
||||||
|
if settings_row and "profile_visibility" in settings_row.keys():
|
||||||
|
visibility = (settings_row["profile_visibility"] or "public").lower()
|
||||||
|
if visibility not in PROFILE_VISIBILITIES:
|
||||||
|
visibility = "public"
|
||||||
|
|
||||||
|
ctx = build_profile_privacy_context(viewer, target_user_id)
|
||||||
|
|
||||||
|
if visibility == "public":
|
||||||
|
return True
|
||||||
|
if visibility == "members":
|
||||||
|
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
|
||||||
|
if visibility == "private":
|
||||||
|
return ctx["is_self"] or ctx["is_staff"]
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def can_view_last_active(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
||||||
|
visibility = "everyone"
|
||||||
|
if settings_row and "last_active_visibility" in settings_row.keys():
|
||||||
|
visibility = (settings_row["last_active_visibility"] or "everyone").lower()
|
||||||
|
if visibility not in LAST_ACTIVE_VISIBILITIES:
|
||||||
|
visibility = "everyone"
|
||||||
|
|
||||||
|
ctx = build_profile_privacy_context(viewer, target_user_id)
|
||||||
|
|
||||||
|
if visibility == "everyone":
|
||||||
|
return True
|
||||||
|
if visibility == "members":
|
||||||
|
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
|
||||||
|
if visibility == "nobody":
|
||||||
|
return ctx["is_self"] or ctx["is_staff"]
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def profile_presence_badge_visible(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
||||||
|
ctx = build_profile_privacy_context(viewer, target_user_id)
|
||||||
|
if ctx["is_self"] or ctx["is_staff"]:
|
||||||
|
return True
|
||||||
|
if settings_row and "show_presence_badge" in settings_row.keys():
|
||||||
|
return bool(settings_row["show_presence_badge"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def profile_payload(user_payload_func, user_row, profile_row, settings_row, viewer: Optional[dict]) -> dict:
|
||||||
|
is_self = bool(viewer and viewer["id"] == user_row["id"])
|
||||||
|
status_payload = user_payload_func(user_row, settings_row)
|
||||||
|
bio = profile_row["bio"] or ""
|
||||||
|
bio_mode = profile_row["bio_mode"] or "txt"
|
||||||
|
explicit_name_color = settings_row["name_color"] if settings_row else None
|
||||||
|
resolved = resolve_name_color(user_row["role"], explicit_name_color)
|
||||||
|
|
||||||
|
profile_visibility = settings_row["profile_visibility"] if settings_row and "profile_visibility" in settings_row.keys() else "public"
|
||||||
|
if profile_visibility not in PROFILE_VISIBILITIES:
|
||||||
|
profile_visibility = "public"
|
||||||
|
|
||||||
|
last_active_visibility = settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone"
|
||||||
|
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
|
||||||
|
last_active_visibility = "everyone"
|
||||||
|
|
||||||
|
presence_mode = settings_row["presence_mode"] if settings_row and "presence_mode" in settings_row.keys() else "online"
|
||||||
|
if presence_mode not in PRESENCE_MODES:
|
||||||
|
presence_mode = "online"
|
||||||
|
|
||||||
|
dm_layout_mode = settings_row["dm_layout_mode"] if settings_row and "dm_layout_mode" in settings_row.keys() else "cards"
|
||||||
|
if dm_layout_mode not in DM_LAYOUT_MODES:
|
||||||
|
dm_layout_mode = "cards"
|
||||||
|
|
||||||
|
show_presence_badge = profile_presence_badge_visible(settings_row, viewer, user_row["id"])
|
||||||
|
|
||||||
|
last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None
|
||||||
|
if not can_view_last_active(settings_row, viewer, user_row["id"]):
|
||||||
|
last_active_at = None
|
||||||
|
|
||||||
|
website = profile_row["website"] or ""
|
||||||
|
website_meta = detect_website_brand(website) if website else {"host": "", "brand": "web", "label": "website"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user_row["id"],
|
||||||
|
"username": user_row["username"],
|
||||||
|
"role": user_row["role"],
|
||||||
|
"status": status_payload["status"],
|
||||||
|
"presence_mode": presence_mode,
|
||||||
|
"created_at": user_row["created_at"],
|
||||||
|
"last_active_at": last_active_at,
|
||||||
|
"display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"],
|
||||||
|
"badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "",
|
||||||
|
"bio": bio,
|
||||||
|
"bio_mode": bio_mode,
|
||||||
|
"bio_html": safe_profile_bio_html(bio, bio_mode),
|
||||||
|
"website": website,
|
||||||
|
"website_meta": website_meta,
|
||||||
|
"location": profile_row["location"] or "",
|
||||||
|
"status_message": profile_row["status_message"] if "status_message" in profile_row.keys() else "",
|
||||||
|
"banner_attachment_id": profile_row["banner_attachment_id"] if "banner_attachment_id" in profile_row.keys() else None,
|
||||||
|
"banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if ("banner_attachment_id" in profile_row.keys() and profile_row["banner_attachment_id"]) else "",
|
||||||
|
"updated_at": profile_row["updated_at"],
|
||||||
|
"is_self": is_self,
|
||||||
|
"name_color": explicit_name_color,
|
||||||
|
"resolved_name_color": resolved,
|
||||||
|
"profile_background_color": settings_row["profile_background_color"] if settings_row and "profile_background_color" in settings_row.keys() else None,
|
||||||
|
"profile_banner_color": settings_row["profile_banner_color"] if settings_row and "profile_banner_color" in settings_row.keys() else None,
|
||||||
|
"profile_visibility": profile_visibility,
|
||||||
|
"last_active_visibility": last_active_visibility,
|
||||||
|
"show_presence_badge": show_presence_badge,
|
||||||
|
"dm_layout_mode": dm_layout_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def page_payload(page, user: Optional[dict], is_team_member_func, is_team_admin_func) -> dict:
|
||||||
|
can_read = bool(
|
||||||
|
page["visibility"] == "public"
|
||||||
|
or (
|
||||||
|
user
|
||||||
|
and (
|
||||||
|
user["role"] in {"moderator", "admin"}
|
||||||
|
or is_team_member_func(page["team_id"], user["id"])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rendered_html = render_markdown_to_safe_html(page["body"] or "")
|
||||||
|
parent_page_id = page["parent_page_id"] if "parent_page_id" in page.keys() else None
|
||||||
|
parent_page_slug = page["parent_page_slug"] if "parent_page_slug" in page.keys() else None
|
||||||
|
parent_page_title = page["parent_page_title"] if "parent_page_title" in page.keys() else None
|
||||||
|
return {
|
||||||
|
"id": page["id"],
|
||||||
|
"team_id": page["team_id"],
|
||||||
|
"team_slug": page["team_slug"],
|
||||||
|
"team_name": page["team_name"],
|
||||||
|
"channel_id": page["channel_id"],
|
||||||
|
"channel_slug": page["channel_slug"],
|
||||||
|
"channel_name": page["channel_name"],
|
||||||
|
"parent_page_id": parent_page_id,
|
||||||
|
"parent_page_slug": parent_page_slug,
|
||||||
|
"parent_page_title": parent_page_title,
|
||||||
|
"slug": page["slug"],
|
||||||
|
"title": page["title"],
|
||||||
|
"body": page["body"],
|
||||||
|
"body_html": rendered_html,
|
||||||
|
"visibility": page["visibility"],
|
||||||
|
"page_type": page["page_type"],
|
||||||
|
"created_by_user_id": page["created_by_user_id"],
|
||||||
|
"updated_by_user_id": page["updated_by_user_id"],
|
||||||
|
"created_at": page["created_at"],
|
||||||
|
"updated_at": page["updated_at"],
|
||||||
|
"can_read": can_read,
|
||||||
|
"can_edit": bool(
|
||||||
|
user
|
||||||
|
and (
|
||||||
|
user["role"] in {"moderator", "admin"}
|
||||||
|
or is_team_admin_func(page["team_id"], user)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"children": load_page_children(page["id"]),
|
||||||
|
"backlinks": load_page_backlinks(
|
||||||
|
page["id"],
|
||||||
|
page["slug"],
|
||||||
|
page["team_id"],
|
||||||
|
),
|
||||||
|
"references": extract_page_reference_slugs(page["body"] or ""),
|
||||||
|
"comments": load_page_comments(page["id"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_dms_for_user(user_id: int) -> list[dict]:
|
||||||
|
with get_db() as db:
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
dc.id,
|
||||||
|
dc.created_at,
|
||||||
|
other.id AS other_user_id,
|
||||||
|
other.username AS other_username,
|
||||||
|
other.role AS other_role,
|
||||||
|
us.name_color AS other_name_color,
|
||||||
|
up.display_name AS other_display_name,
|
||||||
|
up.status_message AS other_status_message,
|
||||||
|
us.presence_mode AS other_presence_mode,
|
||||||
|
(
|
||||||
|
SELECT m.text
|
||||||
|
FROM messages AS m
|
||||||
|
WHERE m.direct_conversation_id = dc.id
|
||||||
|
ORDER BY m.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS last_message_text,
|
||||||
|
(
|
||||||
|
SELECT m.created_at
|
||||||
|
FROM messages AS m
|
||||||
|
WHERE m.direct_conversation_id = dc.id
|
||||||
|
ORDER BY m.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS last_message_created_at,
|
||||||
|
(
|
||||||
|
SELECT m.id
|
||||||
|
FROM messages AS m
|
||||||
|
WHERE m.direct_conversation_id = dc.id
|
||||||
|
ORDER BY m.id DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS last_message_id,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM messages AS m2
|
||||||
|
WHERE m2.direct_conversation_id = dc.id
|
||||||
|
AND m2.user_id != ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM message_receipts AS mr
|
||||||
|
WHERE mr.message_id = m2.id
|
||||||
|
AND mr.user_id = ?
|
||||||
|
)
|
||||||
|
) AS unread_count
|
||||||
|
FROM direct_conversations AS dc
|
||||||
|
JOIN direct_conversation_members AS self_member
|
||||||
|
ON self_member.direct_conversation_id = dc.id
|
||||||
|
JOIN direct_conversation_members AS other_member
|
||||||
|
ON other_member.direct_conversation_id = dc.id
|
||||||
|
AND other_member.user_id != self_member.user_id
|
||||||
|
JOIN users AS other
|
||||||
|
ON other.id = other_member.user_id
|
||||||
|
LEFT JOIN user_profiles AS up
|
||||||
|
ON up.user_id = other.id
|
||||||
|
LEFT JOIN user_settings AS us
|
||||||
|
ON us.user_id = other.id
|
||||||
|
WHERE self_member.user_id = ?
|
||||||
|
ORDER BY COALESCE(last_message_id, 0) DESC, dc.id DESC
|
||||||
|
""",
|
||||||
|
(user_id, user_id, user_id),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"other_user": {
|
||||||
|
"id": row["other_user_id"],
|
||||||
|
"username": row["other_username"],
|
||||||
|
"display_name": row["other_display_name"] or row["other_username"],
|
||||||
|
"role": row["other_role"],
|
||||||
|
"name_color": row["other_name_color"],
|
||||||
|
"resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]),
|
||||||
|
"status_message": row["other_status_message"] or "",
|
||||||
|
"presence_mode": row["other_presence_mode"] or "online",
|
||||||
|
},
|
||||||
|
"last_message_text": row["last_message_text"],
|
||||||
|
"last_message_created_at": row["last_message_created_at"],
|
||||||
|
"last_message_id": row["last_message_id"],
|
||||||
|
"unread_count": int(row["unread_count"] or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def richer_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
|
||||||
|
with get_db() as db:
|
||||||
|
edited = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT created_at
|
||||||
|
FROM message_edits
|
||||||
|
WHERE message_id = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
receipt_count = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM message_receipts
|
||||||
|
WHERE message_id = ?
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchone()["count"]
|
||||||
|
|
||||||
|
seen_by_user = False
|
||||||
|
is_bookmarked = False
|
||||||
|
is_pinned = False
|
||||||
|
pin_row = None
|
||||||
|
|
||||||
|
bookmark_table_exists = bool(
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name = 'message_bookmarks'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
)
|
||||||
|
pin_table_exists = bool(
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table'
|
||||||
|
AND name = 'message_pins'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
)
|
||||||
|
|
||||||
|
if user:
|
||||||
|
seen_by_user = bool(
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM message_receipts
|
||||||
|
WHERE message_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
""",
|
||||||
|
(row["id"], user["id"]),
|
||||||
|
).fetchone()
|
||||||
|
)
|
||||||
|
if bookmark_table_exists:
|
||||||
|
is_bookmarked = bool(
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1
|
||||||
|
FROM message_bookmarks
|
||||||
|
WHERE message_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
""",
|
||||||
|
(row["id"], user["id"]),
|
||||||
|
).fetchone()
|
||||||
|
)
|
||||||
|
|
||||||
|
if pin_table_exists:
|
||||||
|
pin_row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT pinned_at, pinned_by_user_id
|
||||||
|
FROM message_pins
|
||||||
|
WHERE message_id = ?
|
||||||
|
""",
|
||||||
|
(row["id"],),
|
||||||
|
).fetchone()
|
||||||
|
is_pinned = bool(pin_row)
|
||||||
|
|
||||||
|
primary_attachment = attachment_payload_legacy_from_message_select_row(row)
|
||||||
|
attachments = attachment_list_for_message(row["id"])
|
||||||
|
if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments):
|
||||||
|
attachments.insert(0, primary_attachment)
|
||||||
|
|
||||||
|
user_role = row["user_role"] if "user_role" in row.keys() else "user"
|
||||||
|
user_name_color = row["user_name_color"] if "user_name_color" in row.keys() else None
|
||||||
|
resolved_color = resolve_name_color(user_role, user_name_color)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"room_id": row["room_id"],
|
||||||
|
"channel_id": row["channel_id"],
|
||||||
|
"direct_conversation_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"user_role": user_role,
|
||||||
|
"user_name_color": resolved_color,
|
||||||
|
"text": row["text"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": edited["created_at"] if edited else None,
|
||||||
|
"is_edited": bool(edited),
|
||||||
|
"parent_message_id": row["parent_message_id"],
|
||||||
|
"thread_reply_count": row["thread_reply_count"],
|
||||||
|
"attachment": primary_attachment,
|
||||||
|
"attachments": attachments,
|
||||||
|
"read_receipt_count": receipt_count,
|
||||||
|
"seen_by_current_user": seen_by_user,
|
||||||
|
"reactions": reaction_summary_for_message(row["id"], user),
|
||||||
|
"is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]),
|
||||||
|
"is_pinned": is_pinned,
|
||||||
|
"pinned_at": pin_row["pinned_at"] if pin_row else None,
|
||||||
|
"pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None,
|
||||||
|
"is_bookmarked": is_bookmarked,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def feed_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
|
||||||
|
payload = richer_message_payload(
|
||||||
|
row,
|
||||||
|
user,
|
||||||
|
attachment_payload_legacy_from_message_select_row,
|
||||||
|
attachment_list_for_message,
|
||||||
|
)
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"room_slug": row["room_slug"] if "room_slug" in row.keys() else None,
|
||||||
|
"room_name": row["room_name"] if "room_name" in row.keys() else None,
|
||||||
|
"room_visibility": row["room_visibility"] if "room_visibility" in row.keys() else None,
|
||||||
|
"team_slug": row["team_slug"] if "team_slug" in row.keys() else None,
|
||||||
|
"team_name": row["team_name"] if "team_name" in row.keys() else None,
|
||||||
|
"channel_slug": row["channel_slug"] if "channel_slug" in row.keys() else None,
|
||||||
|
"channel_name": row["channel_name"] if "channel_name" in row.keys() else None,
|
||||||
|
"dm_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
|
||||||
|
"is_mention": bool(
|
||||||
|
user and f"@{user['username'].lower()}" in payload["text"].lower()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return payload
|
||||||
Loading…
x
Reference in New Issue
Block a user