diff --git a/app.py b/app.py index fb80139..a424d5e 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,6 @@ # app.py import base64 +import hashlib import html import hmac import json @@ -22,13 +23,19 @@ 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")) + +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 @@ -36,34 +43,33 @@ 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", @@ -189,7 +195,12 @@ 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.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, @@ -204,7 +215,8 @@ app.config.update( def get_asset_version() -> str: try: js_dir = APP_ROOT / "static" / "js" - values = [int((APP_ROOT / "static" / "style.css").stat().st_mtime), *[int(path.stat().st_mtime) for path in js_dir.glob("*.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" @@ -217,7 +229,6 @@ def get_db() -> sqlite3.Connection: 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") @@ -240,7 +251,7 @@ def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor: raise if attempt == 4: raise - time.sleep(0.05 * (2**attempt)) + time.sleep(0.05 * (2 ** attempt)) raise last_error @@ -657,20 +668,44 @@ def client_ip() -> str: return (request.remote_addr or "unknown")[:128] -def hash_identifier(value: str) -> str: - return hmac.new(CLIENT_HASH_KEY.encode("utf-8"), value.encode("utf-8"), ctx_hash()).hexdigest() - - def ctx_hash(): - import hashlib 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: +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 ( @@ -683,7 +718,15 @@ def audit(action: str, actor_user_id: Optional[int] = None, target_type: Optiona created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) """, - (actor_user_id, action, target_type, target_id, details, current_client_hash(), now_iso()), + ( + actor_user_id, + action, + target_type, + target_id, + details, + current_client_hash(), + now_iso(), + ), ) @@ -759,7 +802,16 @@ 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 + return ( + jsonify( + { + "error": "Too many requests. Please wait and try again.", + "code": "rate_limited", + "retry_after": retry_after, + } + ), + 429, + ) def safe_json() -> dict: @@ -858,7 +910,6 @@ def login_user(row: sqlite3.Row | dict) -> dict: session.permanent = True session["user_id"] = row["id"] session["csrf_token"] = secrets.token_urlsafe(32) - return { "id": row["id"], "username": row["username"], @@ -879,10 +930,17 @@ 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 None, ( + jsonify( + { + "error": "Your account is temporarily restricted.", + "code": "restricted", + "restricted_until": user["restricted_until"], + } + ), + 403, + ) return user, None @@ -905,7 +963,10 @@ def require_admin(): def get_asset_template_values(): - return {"asset_version": get_asset_version(), "csp_nonce": getattr(g, "csp_nonce", "")} + return { + "asset_version": get_asset_version(), + "csp_nonce": getattr(g, "csp_nonce", ""), + } @app.before_request @@ -942,7 +1003,6 @@ def add_security_headers(response): "script-src 'self'; " "connect-src 'self';" ) - if request.path.startswith("/static/"): response.headers["Cache-Control"] = "public, max-age=31536000, immutable" else: @@ -1397,6 +1457,42 @@ def init_db() -> None: """ ) + 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"): @@ -1437,6 +1533,8 @@ def init_db() -> None: 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: @@ -1457,7 +1555,10 @@ def init_db() -> None: 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 messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL", + (general_id,), + ) db.execute( """ @@ -1517,10 +1618,12 @@ def init_db() -> None: ).fetchall() for room in legacy_rooms: - existing_channel = db.execute("SELECT id FROM channels WHERE legacy_room_id = ?", (room["id"],)).fetchone() + existing_channel = db.execute( + "SELECT id FROM channels WHERE legacy_room_id = ?", + (room["id"],), + ).fetchone() if existing_channel: continue - db.execute( """ INSERT INTO channels ( @@ -1534,7 +1637,15 @@ def init_db() -> None: is_active ) VALUES (?, ?, ?, ?, ?, ?, ?, 1) """, - (general_team_id, room["id"], room["slug"], room["name"], room["description"], room["created_by_user_id"], room["created_at"]), + ( + general_team_id, + room["id"], + room["slug"], + room["name"], + room["description"], + room["created_by_user_id"], + room["created_at"], + ), ) db.execute( @@ -1620,7 +1731,10 @@ def init_db() -> None: (now_iso(),), ) - existing_message_attachment_counts = db.execute("SELECT COUNT(*) AS count FROM message_attachments").fetchone()["count"] + existing_message_attachment_counts = db.execute( + "SELECT COUNT(*) AS count FROM message_attachments" + ).fetchone()["count"] + if existing_message_attachment_counts == 0: rows = db.execute( """ @@ -1630,7 +1744,6 @@ def init_db() -> None: ORDER BY id ASC """ ).fetchall() - for row in rows: db.execute( """ @@ -1658,7 +1771,10 @@ def bootstrap_admin() -> None: return with get_db() as db: - existing = db.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() + existing = db.execute( + "SELECT id FROM users WHERE username = ?", + (username,), + ).fetchone() if existing: db.execute( @@ -1736,7 +1852,13 @@ def api_csrf(): @app.route("/api/me") def api_me(): user = current_user() - return jsonify({"authenticated": bool(user), "user": user, "csrf_token": csrf_token()}) + return jsonify( + { + "authenticated": bool(user), + "user": user, + "csrf_token": csrf_token(), + } + ) @app.route("/api/register", methods=["POST"]) @@ -1822,7 +1944,13 @@ def register(): authenticated = login_user(user) audit("account_registered", user["id"], "user", str(user["id"])) - return jsonify({"status": "registered", "user": authenticated, "csrf_token": csrf_token()}), 201 + return jsonify( + { + "status": "registered", + "user": authenticated, + "csrf_token": csrf_token(), + } + ), 201 @app.route("/api/login", methods=["POST"]) @@ -1868,14 +1996,19 @@ def login(): 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()}) + 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()}) @@ -1914,6 +2047,10 @@ register_routes( "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, @@ -1936,6 +2073,7 @@ register_routes( "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, @@ -1978,7 +2116,6 @@ register_routes( }, ) - init_db() bootstrap_admin() @@ -1987,4 +2124,4 @@ if __name__ == "__main__": host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=os.environ.get("FLASK_DEBUG", "0") == "1", - ) + ) \ No newline at end of file