v1.6 backend refactor and api compatibility

This commit is contained in:
admin 2026-11-02 22:40:59 +00:00
parent 2ef3309fb8
commit 216ca382b3
10 changed files with 4122 additions and 4196 deletions

2155
app.py

File diff suppressed because it is too large Load Diff

335
attachments.py Normal file
View 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
View 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
View 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
View 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
View 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()

File diff suppressed because it is too large Load Diff

642
models.py Normal file
View 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
View 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

850
routes.py

File diff suppressed because it is too large Load Diff