Update app.py
This commit is contained in:
parent
af19566309
commit
5e080893e2
215
app.py
215
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",
|
||||
)
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user