2127 lines
67 KiB
Python
2127 lines
67 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_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
|
|
|
|
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,
|
|
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
|
|
)
|
|
"""
|
|
)
|
|
|
|
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")
|
|
|
|
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)")
|
|
|
|
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,
|
|
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,
|
|
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
|
|
|
|
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,
|
|
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
|
|
|
|
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,
|
|
"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_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER,
|
|
"ROOM_VISIBILITIES": ROOM_VISIBILITIES,
|
|
"ROLES": ROLES,
|
|
"STATUSES": STATUSES,
|
|
},
|
|
},
|
|
)
|
|
|
|
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",
|
|
) |