Update app.py

This commit is contained in:
admin 2026-10-08 00:09:48 +00:00
parent 5abeb8316d
commit 78bc89113d

307
app.py
View File

@ -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,