Update app.py

This commit is contained in:
dev 2026-09-25 02:37:28 +00:00
parent af19566309
commit 5e080893e2

215
app.py
View File

@ -1,5 +1,6 @@
# app.py # app.py
import base64 import base64
import hashlib
import html import html
import hmac import hmac
import json import json
@ -22,13 +23,19 @@ from werkzeug.utils import secure_filename
from features import maybe_cleanup_orphaned_attachments from features import maybe_cleanup_orphaned_attachments
from routes import register_routes from routes import register_routes
APP_ROOT = Path(__file__).resolve().parent APP_ROOT = Path(__file__).resolve().parent
DB_PATH = os.environ.get("CHAT_DB_PATH", str(APP_ROOT / "chat.db")) 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"))) 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_REQUEST_BYTES = 6 * 1024 * 1024
MAX_MESSAGE_LENGTH = 1000 MAX_MESSAGE_LENGTH = 1000
@ -36,34 +43,33 @@ MAX_USERNAME_LENGTH = 30
MIN_USERNAME_LENGTH = 3 MIN_USERNAME_LENGTH = 3
MIN_PASSWORD_LENGTH = 7 MIN_PASSWORD_LENGTH = 7
MAX_PASSWORD_LENGTH = 256 MAX_PASSWORD_LENGTH = 256
MAX_HISTORY_MESSAGES = 100 MAX_HISTORY_MESSAGES = 100
MAX_INCREMENTAL_MESSAGES = 100 MAX_INCREMENTAL_MESSAGES = 100
MAX_FEED_MESSAGES = 50 MAX_FEED_MESSAGES = 50
MAX_THREAD_REPLIES = 100 MAX_THREAD_REPLIES = 100
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024 MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
MAX_ATTACHMENT_NAME_LENGTH = 150 MAX_ATTACHMENT_NAME_LENGTH = 150
ATTACHMENT_ORPHAN_TTL_HOURS = 24 ATTACHMENT_ORPHAN_TTL_HOURS = 24
ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60 ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60
INVITE_TTL_HOURS = 72 INVITE_TTL_HOURS = 72
TEAM_INVITE_TTL_HOURS = 72 TEAM_INVITE_TTL_HOURS = 72
TYPING_TTL_SECONDS = 7 TYPING_TTL_SECONDS = 7
PRESENCE_TTL_SECONDS = 40 PRESENCE_TTL_SECONDS = 40
MAX_PROFILE_BIO_LENGTH = 5000 MAX_PROFILE_BIO_LENGTH = 5000
MAX_DISPLAY_NAME_LENGTH = 80 MAX_DISPLAY_NAME_LENGTH = 80
MAX_LOCATION_LENGTH = 120 MAX_LOCATION_LENGTH = 120
MAX_WEBSITE_LENGTH = 300 MAX_WEBSITE_LENGTH = 300
MAX_CUSTOM_CSS_LENGTH = 20000 MAX_CUSTOM_CSS_LENGTH = 20000
MAX_OWNED_TEAMS_PER_USER = 3 MAX_OWNED_TEAMS_PER_USER = 3
MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60 MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60
MAX_SEARCH_QUERY_LENGTH = 120 MAX_SEARCH_QUERY_LENGTH = 120
MAX_SEARCH_RESULTS = 100 MAX_SEARCH_RESULTS = 100
READ_RECEIPT_BATCH_LIMIT = 200 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 = { ALLOWED_ATTACHMENT_EXTENSIONS = {
"txt", "txt",
"pdf", "pdf",
@ -189,7 +195,12 @@ app.secret_key = SECRET_KEY
trusted_proxy_hops = int(os.environ.get("TRUSTED_PROXY_HOPS", "0")) trusted_proxy_hops = int(os.environ.get("TRUSTED_PROXY_HOPS", "0"))
if 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( app.config.update(
MAX_CONTENT_LENGTH=MAX_REQUEST_BYTES, MAX_CONTENT_LENGTH=MAX_REQUEST_BYTES,
@ -204,7 +215,8 @@ app.config.update(
def get_asset_version() -> str: def get_asset_version() -> str:
try: try:
js_dir = APP_ROOT / "static" / "js" 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)) return str(max(values))
except OSError: except OSError:
return "1" 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 = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON") conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL") conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 8000") conn.execute("PRAGMA busy_timeout = 8000")
@ -240,7 +251,7 @@ def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor:
raise raise
if attempt == 4: if attempt == 4:
raise raise
time.sleep(0.05 * (2**attempt)) time.sleep(0.05 * (2 ** attempt))
raise last_error raise last_error
@ -657,20 +668,44 @@ def client_ip() -> str:
return (request.remote_addr or "unknown")[:128] 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(): def ctx_hash():
import hashlib
return hashlib.sha256 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: def current_client_hash() -> str:
return hash_identifier(client_ip()) 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( db_write(
""" """
INSERT INTO audit_log ( INSERT INTO audit_log (
@ -683,7 +718,15 @@ def audit(action: str, actor_user_id: Optional[int] = None, target_type: Optiona
created_at created_at
) VALUES (?, ?, ?, ?, ?, ?, ?) ) 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) retry_after = check_rate_limit(scope)
if retry_after is None: if retry_after is None:
return 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: def safe_json() -> dict:
@ -858,7 +910,6 @@ def login_user(row: sqlite3.Row | dict) -> dict:
session.permanent = True session.permanent = True
session["user_id"] = row["id"] session["user_id"] = row["id"]
session["csrf_token"] = secrets.token_urlsafe(32) session["csrf_token"] = secrets.token_urlsafe(32)
return { return {
"id": row["id"], "id": row["id"],
"username": row["username"], "username": row["username"],
@ -879,10 +930,17 @@ def require_writable_user():
user, error = require_user() user, error = require_user()
if error: if error:
return None, error return None, error
if user["status"] == "restricted": 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 return user, None
@ -905,7 +963,10 @@ def require_admin():
def get_asset_template_values(): 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 @app.before_request
@ -942,7 +1003,6 @@ def add_security_headers(response):
"script-src 'self'; " "script-src 'self'; "
"connect-src 'self';" "connect-src 'self';"
) )
if request.path.startswith("/static/"): if request.path.startswith("/static/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable" response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
else: 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"): if not column_exists(db, "messages", "room_id"):
db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER") db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER")
if not column_exists(db, "messages", "attachment_id"): 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_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_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_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() general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone()
if not general: if not general:
@ -1457,7 +1555,10 @@ def init_db() -> None:
else: else:
general_id = general["id"] 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( db.execute(
""" """
@ -1517,10 +1618,12 @@ def init_db() -> None:
).fetchall() ).fetchall()
for room in legacy_rooms: 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: if existing_channel:
continue continue
db.execute( db.execute(
""" """
INSERT INTO channels ( INSERT INTO channels (
@ -1534,7 +1637,15 @@ def init_db() -> None:
is_active is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, 1) ) 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( db.execute(
@ -1620,7 +1731,10 @@ def init_db() -> None:
(now_iso(),), (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: if existing_message_attachment_counts == 0:
rows = db.execute( rows = db.execute(
""" """
@ -1630,7 +1744,6 @@ def init_db() -> None:
ORDER BY id ASC ORDER BY id ASC
""" """
).fetchall() ).fetchall()
for row in rows: for row in rows:
db.execute( db.execute(
""" """
@ -1658,7 +1771,10 @@ def bootstrap_admin() -> None:
return return
with get_db() as db: 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: if existing:
db.execute( db.execute(
@ -1736,7 +1852,13 @@ def api_csrf():
@app.route("/api/me") @app.route("/api/me")
def api_me(): def api_me():
user = current_user() 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"]) @app.route("/api/register", methods=["POST"])
@ -1822,7 +1944,13 @@ def register():
authenticated = login_user(user) authenticated = login_user(user)
audit("account_registered", user["id"], "user", str(user["id"])) 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"]) @app.route("/api/login", methods=["POST"])
@ -1868,14 +1996,19 @@ def login():
authenticated = login_user(row) authenticated = login_user(row)
audit("account_logged_in", row["id"], "user", str(row["id"])) 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"]) @app.route("/api/logout", methods=["POST"])
def logout(): def logout():
if not csrf_valid(): if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
session.clear() session.clear()
return jsonify({"status": "logged_out", "csrf_token": csrf_token()}) return jsonify({"status": "logged_out", "csrf_token": csrf_token()})
@ -1914,6 +2047,10 @@ register_routes(
"normalize_username": normalize_username, "normalize_username": normalize_username,
"audit": audit, "audit": audit,
"hash_identifier": hash_identifier, "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, "room_by_id": room_by_id,
"get_room_by_slug": get_room_by_slug, "get_room_by_slug": get_room_by_slug,
"can_access_room": can_access_room, "can_access_room": can_access_room,
@ -1936,6 +2073,7 @@ register_routes(
"team_payload": team_payload, "team_payload": team_payload,
"channel_payload": channel_payload, "channel_payload": channel_payload,
"user_payload": user_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, "MESSAGE_SELECT": MESSAGE_SELECT,
"team_page_select": team_page_select, "team_page_select": team_page_select,
"UPLOAD_DIR": UPLOAD_DIR, "UPLOAD_DIR": UPLOAD_DIR,
@ -1978,7 +2116,6 @@ register_routes(
}, },
) )
init_db() init_db()
bootstrap_admin() bootstrap_admin()
@ -1987,4 +2124,4 @@ if __name__ == "__main__":
host="0.0.0.0", host="0.0.0.0",
port=int(os.environ.get("PORT", 5000)), port=int(os.environ.get("PORT", 5000)),
debug=os.environ.get("FLASK_DEBUG", "0") == "1", debug=os.environ.get("FLASK_DEBUG", "0") == "1",
) )