glo/app.py
2026-10-30 01:02:31 +00:00

2168 lines
69 KiB
Python

# app.py
import base64
import hashlib
import html
import hmac
import json
import mimetypes
import os
import re
import secrets
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
from flask import Flask, g, jsonify, render_template, request, session
from werkzeug.exceptions import BadRequest, RequestEntityTooLarge
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash, generate_password_hash
from werkzeug.utils import secure_filename
from features import maybe_cleanup_orphaned_attachments
from routes import register_routes
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_REQUEST_BYTES = 6 * 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_BYTES = 100 * 1024 * 1024
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",
}
USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$")
ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$")
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")
app = Flask(__name__)
app.secret_key = SECRET_KEY
trusted_proxy_hops = int(os.environ.get("TRUSTED_PROXY_HOPS", "0"))
if trusted_proxy_hops > 0:
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=trusted_proxy_hops,
x_proto=trusted_proxy_hops,
x_host=trusted_proxy_hops,
)
app.config.update(
MAX_CONTENT_LENGTH=MAX_REQUEST_BYTES,
SESSION_COOKIE_NAME="chat_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
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 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 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_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 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),
}
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:
return """
SELECT
p.id,
p.team_id,
p.channel_id,
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 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 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) -> str:
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
payload_bytes = payload_json.encode("utf-8")
signature = hmac.new(
CALL_HELPER_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 = time.time()
cutoff = now - 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 - oldest)) + 1)
db.execute(
"""
INSERT INTO rate_limit_events (
scope,
subject_hash,
created_at
) VALUES (?, ?, ?)
""",
(scope, subject_hash, now),
)
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 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 user_payload(row: sqlite3.Row) -> 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: sqlite3.Row | dict) -> 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 get_asset_template_values():
return {
"asset_version": get_asset_version(),
"csp_nonce": getattr(g, "csp_nonce", ""),
}
@app.before_request
def setup_request_security():
g.csp_nonce = base64.b64encode(secrets.token_bytes(18)).decode("ascii")
if request_blacklisted():
if request.path.startswith("/api/"):
return jsonify({"error": "Access to this service has been blocked.", "code": "blacklisted"}), 403
return render_template("blocked.html", asset_version=get_asset_version()), 403
@app.context_processor
def inject_template_values():
return get_asset_template_values()
@app.after_request
def add_security_headers(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "same-origin"
response.headers["Permissions-Policy"] = "camera=(self), microphone=(self), geolocation=()"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'; "
"img-src 'self' data: blob:; "
"media-src 'self' blob:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"connect-src 'self';"
)
if request.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else:
response.headers["Cache-Control"] = "no-store"
return response
@app.errorhandler(RequestEntityTooLarge)
def request_too_large(_error):
return jsonify({"error": "Request is too large."}), 413
@app.errorhandler(sqlite3.OperationalError)
def sqlite_error(error):
app.logger.exception("Database error: %s", error)
return jsonify({"error": "A database error occurred."}), 500
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 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,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
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',
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 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 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_settings", "name_color"):
db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT")
if not column_exists(db, "presence_events", "direct_conversation_id"):
db.execute("ALTER TABLE presence_events 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")
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,
updated_at
)
SELECT
id,
username,
'',
'',
'txt',
'',
'',
?
FROM users
""",
(now_iso(),),
)
db.execute(
"""
INSERT OR IGNORE INTO user_settings (
user_id,
accent_color,
name_color,
custom_css,
theme_mode,
updated_at
)
SELECT
id,
'#f7dce8',
NULL,
'',
'dark',
?
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 = normalize_username(os.environ.get("ADMIN_USERNAME", ""))
password = os.environ.get("ADMIN_PASSWORD", "")
if not username or not password:
return
if validate_username(username) or validate_password(password, username):
print("WARNING: Admin bootstrap credentials failed validation.")
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,
updated_at
) VALUES (?, ?, '', '', 'txt', '', '', ?)
""",
(user_id, username, now_iso()),
)
db.execute(
"""
INSERT OR IGNORE INTO user_settings (
user_id,
accent_color,
name_color,
custom_css,
theme_mode,
updated_at
) VALUES (?, '#f7dce8', NULL, '', 'dark', ?)
""",
(user_id, now_iso()),
)
db.commit()
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/csrf")
def api_csrf():
return jsonify({"csrf_token": csrf_token()})
@app.route("/api/me")
def api_me():
user = current_user()
return jsonify(
{
"authenticated": bool(user),
"user": user,
"csrf_token": csrf_token(),
}
)
@app.route("/api/register", methods=["POST"])
def register():
limited = rate_limited("register")
if limited:
return limited
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
access_key = data.get("access_key", "")
if not PRIVATE_ACCESS_KEY or not isinstance(access_key, str) or access_key.strip() != PRIVATE_ACCESS_KEY:
return 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 jsonify({"error": username_error}), 400
if password_error:
return 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 sqlite3.IntegrityError:
return jsonify({"error": "Unable to create account with those details."}), 409
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,
updated_at
) VALUES (?, ?, '', '', 'txt', '', '', ?)
""",
(user["id"], username, now_iso()),
)
db_write(
"""
INSERT OR IGNORE INTO user_settings (
user_id,
accent_color,
name_color,
custom_css,
theme_mode,
updated_at
) VALUES (?, '#f7dce8', NULL, '', 'dark', ?)
""",
(user["id"], now_iso()),
)
authenticated = login_user(user)
audit("account_registered", user["id"], "user", str(user["id"]))
return jsonify(
{
"status": "registered",
"user": authenticated,
"csrf_token": csrf_token(),
}
), 201
@app.route("/api/login", methods=["POST"])
def login():
limited = rate_limited("login")
if limited:
return limited
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
access_key = data.get("access_key", "")
if not PRIVATE_ACCESS_KEY or not isinstance(access_key, str) or access_key.strip() != PRIVATE_ACCESS_KEY:
return 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 jsonify({"error": "Invalid username or password."}), 401
if row["status"] in {"disabled", "blacklisted"}:
return 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 jsonify(
{
"status": "logged_in",
"user": authenticated,
"csrf_token": csrf_token(),
}
)
@app.route("/api/logout", methods=["POST"])
def logout():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
session.clear()
return jsonify({"status": "logged_out", "csrf_token": csrf_token()})
def _maybe_cleanup_orphaned_attachments():
maybe_cleanup_orphaned_attachments(
_runtime_state,
ATTACHMENT_CLEANUP_INTERVAL_SECONDS,
lambda: __import__("features").cleanup_orphaned_attachments(
get_db,
UPLOAD_DIR,
utc_now,
ATTACHMENT_ORPHAN_TTL_HOURS,
app.logger,
),
app.logger,
)
register_routes(
app,
{
"get_db": get_db,
"db_write": db_write,
"safe_json": safe_json,
"csrf_valid": csrf_valid,
"rate_limited": rate_limited,
"current_user": current_user,
"require_user": require_user,
"require_writable_user": require_writable_user,
"require_moderator": require_moderator,
"require_admin": require_admin,
"now_iso": now_iso,
"utc_now": utc_now,
"parse_iso": parse_iso,
"normalize_username": normalize_username,
"audit": audit,
"hash_identifier": hash_identifier,
"sign_call_helper_token": sign_call_helper_token,
"CALLS_BASE": CALLS_BASE,
"CALL_TOKEN_TTL_SECONDS": CALL_TOKEN_TTL_SECONDS,
"CALL_HELPER_SHARED_SECRET": CALL_HELPER_SHARED_SECRET,
"PRIVATE_ACCESS_KEY": PRIVATE_ACCESS_KEY,
"room_by_id": room_by_id,
"get_room_by_slug": get_room_by_slug,
"can_access_room": can_access_room,
"can_write_room": can_write_room,
"can_manage_room": can_manage_room,
"room_payload": room_payload,
"team_slug_from_name": team_slug_from_name,
"page_slug_from_title": page_slug_from_title,
"get_team_by_slug": get_team_by_slug,
"get_channel_by_id": get_channel_by_id,
"get_channel_by_slug": get_channel_by_slug,
"get_channel_by_legacy_room_id": get_channel_by_legacy_room_id,
"team_member_role": team_member_role,
"is_team_member": is_team_member,
"is_team_admin": is_team_admin,
"can_edit_team": can_edit_team,
"can_access_team": can_access_team,
"can_access_channel": can_access_channel,
"can_write_channel": can_write_channel,
"team_payload": team_payload,
"channel_payload": channel_payload,
"user_payload": user_payload,
"get_dm_other_user": lambda dm_id, user_id: __import__("features").get_dm_other_user(get_db, dm_id, user_id),
"MESSAGE_SELECT": MESSAGE_SELECT,
"team_page_select": team_page_select,
"UPLOAD_DIR": UPLOAD_DIR,
"secure_filename": secure_filename,
"mimetypes": mimetypes,
"secrets": secrets,
"os": os,
"timedelta": timedelta,
"html": html,
"attachment_list_for_message": lambda message_id: __import__("features").attachment_list_for_message(get_db, message_id),
"maybe_cleanup_orphaned_attachments": _maybe_cleanup_orphaned_attachments,
"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,
},
},
)
init_db()
bootstrap_admin()
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=int(os.environ.get("PORT", 5000)),
debug=os.environ.get("FLASK_DEBUG", "0") == "1",
)