From 78bc89113db386004bbee8464a20bbf17a92a5b9 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 8 Oct 2026 00:09:48 +0000 Subject: [PATCH] Update app.py --- app.py | 307 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 210 insertions(+), 97 deletions(-) diff --git a/app.py b/app.py index 2960f0e..9939824 100644 --- a/app.py +++ b/app.py @@ -24,10 +24,8 @@ 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"), @@ -66,12 +64,14 @@ MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60 MAX_SEARCH_QUERY_LENGTH = 120 MAX_SEARCH_RESULTS = 100 READ_RECEIPT_BATCH_LIMIT = 200 - +MAX_PAGE_COMMENT_LENGTH = 3000 +MAX_PAGE_SHARE_TTL_SECONDS = int(os.environ.get("MAX_PAGE_SHARE_TTL_SECONDS", "604800")) 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")) +SHARE_TOKEN_TTL_SECONDS = int(os.environ.get("SHARE_TOKEN_TTL_SECONDS", "86400")) +SCHEDULED_MESSAGE_POLL_SECONDS = int(os.environ.get("SCHEDULED_MESSAGE_POLL_SECONDS", "10")) ALLOWED_ATTACHMENT_EXTENSIONS = { "txt", @@ -93,7 +93,6 @@ ALLOWED_ATTACHMENT_EXTENSIONS = { "m4v", "ogv", } - ALLOWED_ATTACHMENT_MIME_TYPES = { "text/plain", "application/pdf", @@ -114,10 +113,8 @@ ALLOWED_ATTACHMENT_MIME_TYPES = { "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"} @@ -132,6 +129,13 @@ RATE_LIMITS = { "reaction": (120, 60), "profile": (30, 60), "settings": (30, 60), + "share": (60, 60), + "bookmark": (120, 60), + "pin": (120, 60), + "schedule": (60, 60), + "page_comment": (60, 60), + "page_share": (60, 60), + "page_restore": (30, 60), } PLACEHOLDER_SECRETS = { @@ -145,6 +149,7 @@ PLACEHOLDER_SECRETS = { _runtime_state = { "last_attachment_cleanup": 0.0, + "last_scheduled_dispatch": 0.0, } @@ -172,15 +177,12 @@ def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> 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") @@ -229,7 +231,6 @@ 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") @@ -584,7 +585,12 @@ MESSAGE_SELECT = """ 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 + us.name_color AS user_name_color, + EXISTS ( + SELECT 1 + FROM message_pins mp + WHERE mp.message_id = m.id + ) AS is_pinned FROM messages AS m JOIN users AS u ON u.id = m.user_id @@ -601,6 +607,7 @@ def team_page_select() -> str: p.id, p.team_id, p.channel_id, + p.parent_page_id, p.slug, p.title, p.body, @@ -614,12 +621,16 @@ def team_page_select() -> str: t.slug AS team_slug, t.name AS team_name, c.slug AS channel_slug, - c.name AS channel_name + c.name AS channel_name, + parent.slug AS parent_page_slug, + parent.title AS parent_page_title FROM pages AS p JOIN teams AS t ON t.id = p.team_id LEFT JOIN channels AS c ON c.id = p.channel_id + LEFT JOIN pages AS parent + ON parent.id = p.parent_page_id """ @@ -767,7 +778,6 @@ def check_rate_limit(scope: str) -> Optional[int]: 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( @@ -781,11 +791,9 @@ def check_rate_limit(scope: str) -> Optional[int]: """, (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 ( @@ -797,7 +805,6 @@ def check_rate_limit(scope: str) -> Optional[int]: (scope, subject_hash, now), ) db.commit() - return None @@ -861,7 +868,6 @@ 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( """ @@ -876,13 +882,11 @@ def current_user() -> Optional[dict]: """, (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(): @@ -904,7 +908,6 @@ def current_user() -> Optional[dict]: if user["status"] in {"disabled", "blacklisted"}: session.clear() return None - return user @@ -978,7 +981,7 @@ def setup_request_security(): 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 + return render_template("index.html", asset_version=get_asset_version()), 403 @app.context_processor @@ -1026,7 +1029,6 @@ def sqlite_error(error): def init_db() -> None: UPLOAD_DIR.mkdir(parents=True, exist_ok=True) - with get_db() as db: db.execute( """ @@ -1045,7 +1047,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS rooms ( @@ -1061,7 +1062,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS room_members ( @@ -1075,7 +1075,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS attachments ( @@ -1090,7 +1089,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS teams ( @@ -1105,7 +1103,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS team_members ( @@ -1119,7 +1116,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS team_invites ( @@ -1138,7 +1134,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS channels ( @@ -1158,7 +1153,27 @@ def init_db() -> None: ) """ ) - + db.execute( + """ + CREATE TABLE IF NOT EXISTS direct_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + member_pair_key TEXT UNIQUE NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS direct_conversation_members ( + direct_conversation_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (direct_conversation_id, user_id), + FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) db.execute( """ CREATE TABLE IF NOT EXISTS messages ( @@ -1181,7 +1196,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS room_invites ( @@ -1199,7 +1213,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS typing_events ( @@ -1212,7 +1225,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS channel_typing_events ( @@ -1225,7 +1237,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS dm_typing_events ( @@ -1238,7 +1249,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS blacklist_entries ( @@ -1254,7 +1264,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS rate_limit_events ( @@ -1265,7 +1274,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS audit_log ( @@ -1281,13 +1289,13 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS pages ( id INTEGER PRIMARY KEY AUTOINCREMENT, team_id INTEGER NOT NULL, channel_id INTEGER, + parent_page_id INTEGER, slug TEXT COLLATE NOCASE NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', @@ -1301,12 +1309,63 @@ def init_db() -> None: 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 (parent_page_id) REFERENCES pages(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 page_comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT, + deleted_at TEXT, + FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS page_versions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL, + version_number INTEGER NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + visibility TEXT NOT NULL, + page_type TEXT NOT NULL, + channel_id INTEGER, + parent_page_id INTEGER, + created_by_user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(page_id, version_number), + FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS page_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_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, + revoked_at TEXT, + FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) db.execute( """ CREATE TABLE IF NOT EXISTS user_profiles ( @@ -1322,7 +1381,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS user_settings ( @@ -1336,7 +1394,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS message_reactions ( @@ -1350,7 +1407,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS message_edits ( @@ -1367,7 +1423,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS message_receipts ( @@ -1380,7 +1435,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS message_attachments ( @@ -1393,7 +1447,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS presence_events ( @@ -1407,30 +1460,6 @@ def init_db() -> None: ) """ ) - - 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 ( @@ -1445,7 +1474,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS call_signals ( @@ -1460,7 +1488,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS group_call_sessions ( @@ -1477,7 +1504,6 @@ def init_db() -> None: ) """ ) - db.execute( """ CREATE TABLE IF NOT EXISTS group_call_invites ( @@ -1496,6 +1522,81 @@ def init_db() -> None: ) """ ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_pins ( + message_id INTEGER PRIMARY KEY, + pinned_by_user_id INTEGER NOT NULL, + pinned_at TEXT NOT NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (pinned_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS scheduled_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_ids_json TEXT NOT NULL DEFAULT '[]', + deliver_at TEXT NOT NULL, + created_at TEXT NOT NULL, + sent_at TEXT, + cancelled_at TEXT, + 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 + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_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, + revoked_at TEXT, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS attachment_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + attachment_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, + revoked_at TEXT, + FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE, + FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + db.execute( + """ + CREATE TABLE IF NOT EXISTS message_bookmarks ( + user_id INTEGER NOT NULL, + message_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, message_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE + ) + """ + ) if not column_exists(db, "messages", "room_id"): db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER") @@ -1513,6 +1614,8 @@ def init_db() -> None: 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") + if not column_exists(db, "pages", "parent_page_id"): + db.execute("ALTER TABLE pages ADD COLUMN parent_page_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)") @@ -1524,6 +1627,10 @@ def init_db() -> None: 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_pages_parent_page_id ON pages(parent_page_id)") + db.execute("CREATE INDEX IF NOT EXISTS idx_page_comments_page_created_at ON page_comments(page_id, created_at ASC)") + db.execute("CREATE INDEX IF NOT EXISTS idx_page_versions_page_version ON page_versions(page_id, version_number DESC)") + db.execute("CREATE INDEX IF NOT EXISTS idx_page_shares_token_hash ON page_shares(token_hash)") 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)") @@ -1541,6 +1648,11 @@ def init_db() -> None: 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_message_pins_pinned_at ON message_pins(pinned_at DESC)") + db.execute("CREATE INDEX IF NOT EXISTS idx_scheduled_messages_deliver_at ON scheduled_messages(deliver_at, sent_at, cancelled_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_message_shares_token_hash ON message_shares(token_hash)") + db.execute("CREATE INDEX IF NOT EXISTS idx_attachment_shares_token_hash ON attachment_shares(token_hash)") + db.execute("CREATE INDEX IF NOT EXISTS idx_message_bookmarks_user_created_at ON message_bookmarks(user_id, created_at DESC)") general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone() if not general: @@ -1577,7 +1689,6 @@ def init_db() -> None: OR role = '' """ ) - db.execute( """ UPDATE users @@ -1622,7 +1733,6 @@ def init_db() -> None: WHERE is_active = 1 """ ).fetchall() - for room in legacy_rooms: existing_channel = db.execute( "SELECT id FROM channels WHERE legacy_room_id = ?", @@ -1677,7 +1787,6 @@ def init_db() -> None: AND status = 'active' """ ).fetchall() - for admin_user in admin_users: db.execute( """ @@ -1716,7 +1825,6 @@ def init_db() -> None: """, (now_iso(),), ) - db.execute( """ INSERT OR IGNORE INTO user_settings ( @@ -1742,7 +1850,6 @@ def init_db() -> None: existing_message_attachment_counts = db.execute( "SELECT COUNT(*) AS count FROM message_attachments" ).fetchone()["count"] - if existing_message_attachment_counts == 0: rows = db.execute( """ @@ -1766,23 +1873,20 @@ def init_db() -> None: 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( """ @@ -1813,7 +1917,6 @@ def bootstrap_admin() -> None: (username, generate_password_hash(password), now_iso()), ) user_id = cursor.lastrowid - db.execute( """ INSERT OR IGNORE INTO user_profiles ( @@ -1829,7 +1932,6 @@ def bootstrap_admin() -> None: """, (user_id, username, now_iso()), ) - db.execute( """ INSERT OR IGNORE INTO user_settings ( @@ -1843,7 +1945,6 @@ def bootstrap_admin() -> None: """, (user_id, now_iso()), ) - db.commit() @@ -1868,16 +1969,14 @@ def api_me(): } ) + @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: @@ -1889,10 +1988,8 @@ def register(): 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: @@ -1923,7 +2020,6 @@ def register(): "status": "active", "restricted_until": None, } - db_write( """ INSERT OR IGNORE INTO user_profiles ( @@ -1939,7 +2035,6 @@ def register(): """, (user["id"], username, now_iso()), ) - db_write( """ INSERT OR IGNORE INTO user_settings ( @@ -1956,7 +2051,6 @@ def register(): authenticated = login_user(user) audit("account_registered", user["id"], "user", str(user["id"])) - return jsonify( { "status": "registered", @@ -1965,12 +2059,12 @@ def register(): } ), 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 @@ -2004,14 +2098,12 @@ def login(): 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", @@ -2020,6 +2112,7 @@ def login(): } ) + @app.route("/api/logout", methods=["POST"]) def logout(): if not csrf_valid(): @@ -2029,10 +2122,12 @@ def logout(): def _maybe_cleanup_orphaned_attachments(): + from features import cleanup_orphaned_attachments + maybe_cleanup_orphaned_attachments( _runtime_state, ATTACHMENT_CLEANUP_INTERVAL_SECONDS, - lambda: __import__("features").cleanup_orphaned_attachments( + lambda: cleanup_orphaned_attachments( get_db, UPLOAD_DIR, utc_now, @@ -2043,6 +2138,20 @@ def _maybe_cleanup_orphaned_attachments(): ) +def _maybe_dispatch_scheduled_messages(): + from features import dispatch_scheduled_messages + + now_ts = time.time() + last = _runtime_state.get("last_scheduled_dispatch", 0.0) + if now_ts - last < SCHEDULED_MESSAGE_POLL_SECONDS: + return + _runtime_state["last_scheduled_dispatch"] = now_ts + try: + dispatch_scheduled_messages(get_db, now_iso, app.logger) + except Exception: + app.logger.exception("Could not dispatch scheduled messages.") + + register_routes( app, { @@ -2101,6 +2210,9 @@ register_routes( "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, + "maybe_dispatch_scheduled_messages": _maybe_dispatch_scheduled_messages, + "SHARE_TOKEN_TTL_SECONDS": SHARE_TOKEN_TTL_SECONDS, + "MAX_PAGE_SHARE_TTL_SECONDS": MAX_PAGE_SHARE_TTL_SECONDS, "constants": { "MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH, "MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES, @@ -2126,6 +2238,7 @@ register_routes( "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, + "MAX_PAGE_COMMENT_LENGTH": MAX_PAGE_COMMENT_LENGTH, "ROOM_VISIBILITIES": ROOM_VISIBILITIES, "ROLES": ROLES, "STATUSES": STATUSES,