Compare commits

...

106 Commits

Author SHA1 Message Date
dev
b5434e31ba Delete static/js/.env 2026-11-03 06:19:00 +00:00
dev
b27f2013fd Update docs/CSS.md 2026-11-03 04:56:26 +00:00
bee7213430 refactor 2026-11-03 04:49:14 +00:00
dev
2ffc414444 Update docs/README.md 2026-11-03 04:39:44 +00:00
dev
0c0770ab57 Update docs/CSS.md 2026-11-03 04:37:57 +00:00
dev
d9d3ed0131 Update docs/CSS.md 2026-11-03 04:32:33 +00:00
dev
5a27857303 Update docs/CSS.md 2026-11-03 04:31:41 +00:00
b488bdd080 add route helpers 2026-11-03 00:22:48 +00:00
dev
0256d10718 Update docs/README.md 2026-11-02 22:45:04 +00:00
216ca382b3 v1.6 backend refactor and api compatibility 2026-11-02 22:44:04 +00:00
dev
2ef3309fb8 Update docs/README.md 2026-11-02 07:22:00 +00:00
dev
b84214011a Update static/js/12-calls.js 2026-11-02 06:25:15 +00:00
dev
35ef91db09 Update templates/index.html 2026-11-02 06:14:31 +00:00
dev
4becc9d790 Update static/js/01-api.js 2026-11-02 06:11:39 +00:00
dev
daa3ac6948 Update static/js/04-main.js 2026-11-02 06:10:25 +00:00
dev
d60d1909f4 Update static/js/12-calls.js 2026-11-02 05:18:11 +00:00
dev
eb5ba57785 Update static/js/12-calls.js 2026-11-02 05:11:49 +00:00
dev
8529ae06e1 Update static/js/05-feed.js 2026-11-02 05:05:12 +00:00
dev
1945329f40 Update static/js/02-chat.js 2026-11-02 05:00:02 +00:00
dev
cef6288cfd Update static/js/02-chat.js 2026-11-02 04:55:44 +00:00
dev
7b39b8957d Update app.py 2026-11-02 04:46:50 +00:00
dev
48a035401a Add templates/blocked.html 2026-11-02 04:45:29 +00:00
dev
20af884e7e Update static/js/08-settings.js 2026-11-02 04:42:25 +00:00
dev
13b3564b84 Update static/js/07-team-channel-adapter.js 2026-11-02 04:42:00 +00:00
dev
f08e24696c Update static/js/04-main.js 2026-11-02 04:41:43 +00:00
dev
86b0a7a5ab Update static/js/03-admin.js 2026-11-02 04:37:34 +00:00
dev
c0a58bbde8 Update templates/index.html 2026-11-02 04:34:01 +00:00
dev
deba4b4cfd Update static/js/01-api.js 2026-11-02 04:32:12 +00:00
dev
35b6dc53d9 Update static/js/06-pages.js 2026-11-02 04:31:52 +00:00
dev
463a3a1fcb Update static/js/02-chat.js 2026-11-02 04:29:12 +00:00
dev
842f97428d Update features.py 2026-10-31 09:38:14 +00:00
dev
513ea3e577 Update app.py 2026-10-31 09:37:17 +00:00
dev
98997e26ca Update routes.py 2026-10-31 09:36:13 +00:00
dev
49494e5399 Update static/js/04-main.js 2026-10-31 09:23:39 +00:00
dev
c04b19602e Update static/js/09-profile.js 2026-10-31 09:23:01 +00:00
dev
e65ccce19c Update static/js/00-config.js 2026-10-31 09:22:27 +00:00
dev
301260caa5 Update templates/index.html 2026-10-31 09:21:45 +00:00
dev
e2373bcb64 Update static/js/08-settings.js 2026-10-31 09:18:30 +00:00
dev
0a31e32f1e Update static/js/08-settings.js 2026-10-31 09:17:00 +00:00
dev
6aeac4659c Update features.py 2026-10-31 09:12:19 +00:00
dev
36482c425a Update .env.example 2026-10-31 08:59:47 +00:00
dev
c4dfbfca71 Update docker-compose.yml 2026-10-31 08:59:32 +00:00
dev
c5ba80129e Update static/js/02-chat.js 2026-10-31 08:52:49 +00:00
dev
f684f6ff69 Update static/js/06-pages.js 2026-10-31 08:52:30 +00:00
dev
d6e2ebf750 Update static/js/05-feed.js 2026-10-31 08:52:14 +00:00
dev
8e93be2307 Update docs/README.md 2026-10-31 08:50:03 +00:00
dev
cb078eacfd Update static/js/07-team-channel-adapter.js 2026-10-31 08:47:44 +00:00
dev
368fd18aa7 Update static/js/03-admin.js 2026-10-31 08:47:19 +00:00
dev
9e0b24e29a Update static/style.css 2026-10-31 08:46:57 +00:00
dev
ead6a4f709 Update static/js/10-reactions.js 2026-10-31 08:46:36 +00:00
dev
b85df7fdbd Update static/js/11-media.js 2026-10-31 08:46:10 +00:00
dev
6063da595c Update static/js/12-calls.js 2026-10-31 08:45:58 +00:00
dev
6b5cd404df Update features.py 2026-10-31 08:41:34 +00:00
dev
80a5dc5c15 Update static/js/01-api.js 2026-10-31 08:41:02 +00:00
dev
1cbdae8717 Update routes.py 2026-10-31 08:40:26 +00:00
dev
502836153d Update features.py 2026-10-31 08:39:05 +00:00
dev
952c755e1d backup 2026-10-31 08:34:19 +00:00
dev
2cd955f835 admin_bootstrap 2026-10-31 08:29:03 +00:00
dev
39c4067305 Update routes.py 2026-10-31 08:26:57 +00:00
dev
612c64b4d2 cleanup 2026-10-31 08:20:49 +00:00
dev
c1fdaa73e6 init_db patch 2026-10-31 08:17:25 +00:00
dev
e77b7fe2e5 Update docs/README.md 2026-10-31 05:08:05 +00:00
dev
e1bb8eff25 Update docs/README.md 2026-10-31 05:05:26 +00:00
dev
a4ff63e89a Update static/js/02-chat.js 2026-10-30 06:16:28 +00:00
dev
de82a88091 Update static/js/02-chat.js 2026-10-30 06:14:53 +00:00
dev
0f0698f443 Update static/js/08-settings.js 2026-10-30 05:59:12 +00:00
dev
832acd7cde Update static/js/08-settings.js 2026-10-30 05:58:54 +00:00
dev
3259ceffba Update static/js/09-profile.js 2026-10-30 05:57:41 +00:00
dev
559161cd65 Update routes.py 2026-10-30 05:51:28 +00:00
dev
f7404645e6 Update routes.py 2026-10-30 05:49:38 +00:00
dev
c8e0501d3c Update routes.py 2026-10-30 05:46:56 +00:00
dev
cbf6a0498b Update app.py 2026-10-30 05:41:05 +00:00
9dc2bc6311 wip: local changes before rebasing 2026-10-30 05:34:54 +00:00
c4167ded8f fix calls ui visibility and call panel behavior 2026-10-30 05:34:54 +00:00
dev
b738a6ed18 Update static/js/04-main.js 2026-10-30 05:25:17 +00:00
dev
746d655b73 Update static/js/12-calls.js 2026-10-30 05:24:29 +00:00
dev
2baa0d8d4a Update templates/index.html 2026-10-30 05:23:27 +00:00
dev
0d8bc7a7cb Update static/js/12-calls.js 2026-10-30 04:50:46 +00:00
dev
a841e8a0ee Update helper/server.js 2026-10-30 04:49:40 +00:00
dev
5b3c4c3c55 Update static/js/12-calls.js 2026-10-30 03:41:24 +00:00
dev
fc4f7472d1 Update app.py 2026-10-30 03:22:50 +00:00
dev
6196186b22 Update helper/package.json 2026-10-30 01:28:49 +00:00
dev
defec49d2c Add helper/scripts/generate-secret.js 2026-10-30 01:26:53 +00:00
dev
a7776b9923 Add helper/.env.example 2026-10-30 01:26:31 +00:00
dev
3e2f679000 Add helper/docker-compose.yml 2026-10-30 01:25:49 +00:00
dev
4084d56b61 Add helper/Dockerfile 2026-10-30 01:25:11 +00:00
dev
b99fe06a80 Add helper/package.json 2026-10-30 01:24:46 +00:00
dev
e67dc50105 Add helper/server.js 2026-10-30 01:23:44 +00:00
dev
02ecabf0ea Update app.py 2026-10-30 01:02:31 +00:00
dev
11b6848099 Update routes.py 2026-10-30 01:02:11 +00:00
dev
a648fcdcbb Update templates/index.html 2026-10-30 01:01:41 +00:00
dev
8a3358aec7 Update static/js/04-main.js 2026-10-30 00:59:10 +00:00
dev
18f2670519 Update static/js/01-api.js 2026-10-30 00:58:51 +00:00
dev
6098483251 Update static/js/00-config.js 2026-10-30 00:58:19 +00:00
dev
869b2857e4 Update static/style.css 2026-10-30 00:57:46 +00:00
dev
8a2ee3af05 Update static/js/12-calls.js 2026-10-30 00:57:03 +00:00
dev
aa702bb2a9 Update docs/CSS.md 2026-10-26 23:00:57 +00:00
dev
07f119ee3e Update docs/CSS.md 2026-10-26 22:28:56 +00:00
dev
df43115fe4 Delete docs/V2.md 2026-10-25 06:15:42 +00:00
dev
7a1691b781 Add docs/V2.md 2026-10-25 03:55:56 +00:00
dev
555799bb3d Update docs/README.md 2026-10-25 03:52:11 +00:00
dev
359a8962c3 Update docs/README.md 2026-10-25 02:41:35 +00:00
dev
d4c4d7c3e5 Update docs/CSS.md 2026-10-20 09:17:56 +00:00
dev
e91216d3a7 Update docs/README.md 2026-10-16 00:59:48 +00:00
dev
0cbd50f97c Update desktop/main.py 2026-10-14 23:38:41 +00:00
dev
d5e044d728 Update desktop/main.py 2026-10-14 23:35:27 +00:00
51 changed files with 16042 additions and 11763 deletions

View File

@ -1,3 +1,4 @@
DOMAIN_URL=
ADMIN_USERNAME=
ADMIN_PASSWORD=
PRIVATE_ACCESS_KEY=333

2219
app.py

File diff suppressed because it is too large Load Diff

335
attachments.py Normal file
View File

@ -0,0 +1,335 @@
from datetime import timedelta
from pathlib import Path
from typing import Optional
from flask import abort, jsonify, send_from_directory
from db import get_db
IMAGE_MIME_PREFIX = "image/"
AUDIO_MIME_PREFIX = "audio/"
VIDEO_MIME_PREFIX = "video/"
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"}
VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"}
def attachment_kind_from_name_and_mime(
original_name: Optional[str],
mime_type: Optional[str],
) -> str:
mime_type = str(mime_type or "").lower()
name = str(original_name or "")
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
return "image"
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
return "audio"
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
return "video"
return "file"
def attachment_payload_from_row(row) -> Optional[dict]:
if not row or not row["id"]:
return None
kind = attachment_kind_from_name_and_mime(
row["original_name"],
row["mime_type"],
)
return {
"id": row["id"],
"original_name": row["original_name"],
"mime_type": row["mime_type"],
"size_bytes": row["size_bytes"],
"kind": kind,
"url": f"/api/attachments/{row['id']}/download",
}
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
if not row["attachment_id"]:
return None
kind = attachment_kind_from_name_and_mime(
row["attachment_original_name"],
row["attachment_mime_type"],
)
return {
"id": row["attachment_id"],
"original_name": row["attachment_original_name"],
"mime_type": row["attachment_mime_type"],
"size_bytes": row["attachment_size_bytes"],
"kind": kind,
"url": f"/api/attachments/{row['attachment_id']}/download",
}
def attachment_list_for_message(message_id: int) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
a.id,
a.original_name,
a.mime_type,
a.size_bytes
FROM message_attachments AS ma
JOIN attachments AS a
ON a.id = ma.attachment_id
WHERE ma.message_id = ?
ORDER BY ma.position ASC, a.id ASC
""",
(message_id,),
).fetchall()
return [attachment_payload_from_row(row) for row in rows]
def cleanup_orphaned_attachments(
upload_dir: Path,
utc_now_func,
orphan_ttl_hours: int,
app_logger,
) -> None:
cutoff = (utc_now_func() - timedelta(hours=orphan_ttl_hours)).isoformat()
with get_db() as db:
orphaned = db.execute(
"""
SELECT
a.id,
a.stored_name
FROM attachments AS a
WHERE a.created_at < ?
AND NOT EXISTS (
SELECT 1
FROM messages AS m
WHERE m.attachment_id = a.id
)
AND NOT EXISTS (
SELECT 1
FROM message_attachments AS ma
WHERE ma.attachment_id = a.id
)
""",
(cutoff,),
).fetchall()
for attachment in orphaned:
db.execute(
"""
DELETE FROM attachments
WHERE id = ?
""",
(attachment["id"],),
)
db.commit()
for attachment in orphaned:
try:
(upload_dir / attachment["stored_name"]).unlink(missing_ok=True)
except OSError:
app_logger.warning(
"Could not remove orphaned attachment file: %s",
attachment["stored_name"],
)
def maybe_cleanup_orphaned_attachments(
state: dict,
cleanup_interval_seconds: int,
cleanup_func,
app_logger,
) -> None:
import time
now_value = time.time()
last = state.get("last_attachment_cleanup", 0.0)
if now_value - last < cleanup_interval_seconds:
return
state["last_attachment_cleanup"] = now_value
try:
cleanup_func()
except Exception:
app_logger.exception("Could not clean orphaned attachments.")
def resolve_owned_attachment_ids(
attachment_ids: object,
user_id: int,
) -> tuple[Optional[list[int]], Optional[tuple]]:
if attachment_ids is None:
return [], None
if not isinstance(attachment_ids, list):
return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
parsed = []
seen = set()
for item in attachment_ids[:10]:
try:
attachment_id = int(item)
except (TypeError, ValueError):
return None, (jsonify({"error": "Invalid attachment."}), 400)
if attachment_id in seen:
continue
seen.add(attachment_id)
with get_db() as db:
row = db.execute(
"""
SELECT id, uploader_user_id
FROM attachments
WHERE id = ?
""",
(attachment_id,),
).fetchone()
if not row:
return None, (jsonify({"error": "Attachment not found."}), 404)
if row["uploader_user_id"] != user_id:
return None, (
jsonify({"error": "You can only attach files you uploaded."}),
403,
)
parsed.append(attachment_id)
return parsed, None
def resolve_owned_attachment(
attachment_id: object,
user_id: int,
) -> tuple[Optional[int], Optional[tuple]]:
if attachment_id is None:
return None, None
try:
parsed_id = int(attachment_id)
except (TypeError, ValueError):
return None, (jsonify({"error": "Invalid attachment."}), 400)
with get_db() as db:
attachment = db.execute(
"""
SELECT
id,
uploader_user_id
FROM attachments
WHERE id = ?
""",
(parsed_id,),
).fetchone()
if not attachment:
return None, (jsonify({"error": "Attachment not found."}), 404)
if attachment["uploader_user_id"] != user_id:
return None, (
jsonify({"error": "You can only attach files you uploaded."}),
403,
)
return parsed_id, None
def can_download_attachment(
attachment_id: int,
user: Optional[dict],
can_access_room,
can_access_channel,
get_channel_by_id,
) -> bool:
if not user:
return False
with get_db() as db:
room_rows = db.execute(
"""
SELECT DISTINCT
r.id,
r.slug,
r.name,
r.description,
r.visibility,
r.created_by_user_id,
r.is_active
FROM message_attachments AS ma
JOIN messages AS m
ON m.id = ma.message_id
JOIN rooms AS r
ON r.id = m.room_id
WHERE ma.attachment_id = ?
AND r.is_active = 1
""",
(attachment_id,),
).fetchall()
channel_rows = db.execute(
"""
SELECT DISTINCT
m.channel_id
FROM message_attachments AS ma
JOIN messages AS m
ON m.id = ma.message_id
WHERE ma.attachment_id = ?
AND m.channel_id IS NOT NULL
""",
(attachment_id,),
).fetchall()
dm_rows = db.execute(
"""
SELECT DISTINCT dc.id
FROM message_attachments AS ma
JOIN messages AS m
ON m.id = ma.message_id
JOIN direct_conversations AS dc
ON dc.id = m.direct_conversation_id
JOIN direct_conversation_members AS dcm
ON dcm.direct_conversation_id = dc.id
WHERE ma.attachment_id = ?
AND dcm.user_id = ?
""",
(attachment_id, user["id"]),
).fetchall()
if dm_rows:
return True
if any(can_access_room(room, user) for room in room_rows):
return True
for item in channel_rows:
channel = get_channel_by_id(item["channel_id"])
if channel and can_access_channel(channel, user):
return True
return False
def send_attachment_file(
upload_dir: Path,
attachment_id: int,
user: Optional[dict],
can_access_room,
can_access_channel,
get_channel_by_id,
):
with get_db() as db:
attachment = db.execute(
"""
SELECT
id,
stored_name,
original_name,
mime_type
FROM attachments
WHERE id = ?
""",
(attachment_id,),
).fetchone()
if not attachment:
abort(404)
if not can_download_attachment(
attachment_id,
user,
can_access_room,
can_access_channel,
get_channel_by_id,
):
abort(403)
return send_from_directory(
upload_dir,
attachment["stored_name"],
as_attachment=True,
download_name=attachment["original_name"],
mimetype=attachment["mime_type"],
)

498
auth.py Normal file
View File

@ -0,0 +1,498 @@
import base64
import hashlib
import hmac
import json
import re
import secrets
import time
from typing import Optional
from flask import jsonify, request, session
from werkzeug.exceptions import BadRequest
from werkzeug.security import check_password_hash, generate_password_hash
from config import (
CLIENT_HASH_KEY,
PLACEHOLDER_SECRETS,
PRIVATE_ACCESS_KEY,
RATE_LIMITS,
ROLES,
STATUSES,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
MAX_USERNAME_LENGTH,
MIN_USERNAME_LENGTH,
now_iso,
parse_iso,
utc_now,
)
from db import db_write, get_db
USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$")
def normalize_username(value: object) -> str:
return value.strip().lower() if isinstance(value, str) else ""
def validate_username(username: str) -> Optional[str]:
if not username:
return "Username is required."
if len(username) < MIN_USERNAME_LENGTH:
return f"Username must be at least {MIN_USERNAME_LENGTH} characters."
if len(username) > MAX_USERNAME_LENGTH:
return f"Username must be {MAX_USERNAME_LENGTH} characters or less."
if not USERNAME_RE.fullmatch(username):
return "Username contains invalid characters."
return None
def validate_password(password: object, username: str = "") -> Optional[str]:
if not isinstance(password, str) or not password:
return "Password is required."
if len(password) < MIN_PASSWORD_LENGTH:
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
if len(password) > MAX_PASSWORD_LENGTH:
return f"Password must be {MAX_PASSWORD_LENGTH} characters or less."
if not re.search(r"[a-z]", password):
return "Password must include a lowercase letter."
if not re.search(r"[A-Z]", password):
return "Password must include an uppercase letter."
if not re.search(r"\d", password):
return "Password must include a number."
if not re.search(r"[^A-Za-z0-9]", password):
return "Password must include a symbol."
if username and username in password.lower():
return "Password must not contain your username."
return None
def safe_json() -> dict:
if not request.is_json:
raise ValueError("Request body must be JSON.")
try:
data = request.get_json(silent=False)
except BadRequest as error:
raise ValueError("Invalid JSON request body.") from error
if not isinstance(data, dict):
raise ValueError("JSON body must be an object.")
return data
def csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return token
def csrf_valid() -> bool:
supplied = request.headers.get("X-CSRF-Token", "")
expected = session.get("csrf_token", "")
return bool(supplied and expected and hmac.compare_digest(supplied, expected))
def client_ip() -> str:
return (request.remote_addr or "unknown")[:128]
def ctx_hash():
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, shared_secret: str) -> str:
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
payload_bytes = payload_json.encode("utf-8")
signature = hmac.new(
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:
db_write(
"""
INSERT INTO audit_log (
actor_user_id,
action,
target_type,
target_id,
details,
client_hash,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
actor_user_id,
action,
target_type,
target_id,
details,
current_client_hash(),
now_iso(),
),
)
def active_blacklist_match(kind: str, value_hash: str) -> bool:
with get_db() as db:
row = db.execute(
"""
SELECT id
FROM blacklist_entries
WHERE kind = ?
AND value_hash = ?
AND (
expires_at IS NULL
OR expires_at > ?
)
""",
(kind, value_hash, now_iso()),
).fetchone()
return bool(row)
def request_blacklisted() -> bool:
if request.endpoint in {"static"}:
return False
if active_blacklist_match("ip", current_client_hash()):
return True
user_id = session.get("user_id")
if user_id and active_blacklist_match("user", hash_identifier(str(user_id))):
return True
return False
def check_rate_limit(scope: str) -> Optional[int]:
limit, window_seconds = RATE_LIMITS[scope]
now_value = time.time()
cutoff = now_value - 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(
"""
SELECT created_at
FROM rate_limit_events
WHERE scope = ?
AND subject_hash = ?
AND created_at >= ?
ORDER BY created_at ASC
""",
(scope, subject_hash, cutoff),
).fetchall()
if len(rows) >= limit:
oldest = rows[0]["created_at"]
return max(1, int(window_seconds - (now_value - oldest)) + 1)
db.execute(
"""
INSERT INTO rate_limit_events (
scope,
subject_hash,
created_at
) VALUES (?, ?, ?)
""",
(scope, subject_hash, now_value),
)
db.commit()
return None
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,
)
def user_payload(row, settings_row=None) -> dict:
status = row["status"]
restricted_until = parse_iso(row["restricted_until"])
if status == "restricted" and restricted_until and restricted_until <= utc_now():
status = "active"
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"status": status,
"restricted_until": row["restricted_until"],
}
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(
"""
SELECT
id,
username,
role,
status,
restricted_until
FROM users
WHERE id = ?
""",
(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():
db_write(
"""
UPDATE users
SET
status = 'active',
restricted_until = NULL,
restriction_reason = NULL,
is_active = 1
WHERE id = ?
""",
(user["id"],),
)
user["status"] = "active"
user["restricted_until"] = None
if user["status"] in {"disabled", "blacklisted"}:
session.clear()
return None
return user
def login_user(row) -> dict:
session.clear()
session.permanent = True
session["user_id"] = row["id"]
session["csrf_token"] = secrets.token_urlsafe(32)
return {
"id": row["id"],
"username": row["username"],
"role": row["role"],
"status": row["status"],
"restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"],
}
def require_user():
user = current_user()
if not user:
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
return user, None
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 user, None
def require_moderator():
user, error = require_writable_user()
if error:
return None, error
if user["role"] not in {"moderator", "admin"}:
return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403)
return user, None
def require_admin():
user, error = require_writable_user()
if error:
return None, error
if user["role"] != "admin":
return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403)
return user, None
def private_access_key_valid(candidate: object) -> bool:
configured = (PRIVATE_ACCESS_KEY or "").strip()
if not configured:
return True
if not isinstance(candidate, str):
return False
return candidate.strip() == configured
def register_account(data: dict):
access_key = data.get("access_key", "")
if not private_access_key_valid(access_key):
return None, (jsonify({"error": "Invalid private access key."}), 403)
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 None, (jsonify({"error": username_error}), 400)
if password_error:
return None, (jsonify({"error": password_error}), 400)
try:
cursor = db_write(
"""
INSERT INTO users (
username,
password_hash,
role,
status,
is_admin,
is_active,
created_at
) VALUES (?, ?, 'user', 'active', 0, 1, ?)
""",
(username, generate_password_hash(password), now_iso()),
)
except Exception as error:
if "UNIQUE" in str(error).upper():
return None, (jsonify({"error": "Unable to create account with those details."}), 409)
raise
user = {
"id": cursor.lastrowid,
"username": username,
"role": "user",
"status": "active",
"restricted_until": None,
}
db_write(
"""
INSERT OR IGNORE INTO user_profiles (
user_id,
display_name,
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
updated_at
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
""",
(user["id"], username, now_iso()),
)
db_write(
"""
INSERT OR IGNORE INTO user_settings (
user_id,
accent_color,
name_color,
custom_css,
theme_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
presence_mode,
dm_layout_mode,
show_presence_badge,
updated_at
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
""",
(user["id"], now_iso()),
)
authenticated = login_user(user)
audit("account_registered", user["id"], "user", str(user["id"]))
return authenticated, None
def authenticate_account(data: dict):
access_key = data.get("access_key", "")
if not private_access_key_valid(access_key):
return None, (jsonify({"error": "Invalid private access key."}), 403)
username = normalize_username(data.get("username"))
password = data.get("password", "")
with get_db() as db:
row = db.execute(
"""
SELECT
id,
username,
password_hash,
role,
status,
restricted_until
FROM users
WHERE username = ?
""",
(username,),
).fetchone()
if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password):
return None, (jsonify({"error": "Invalid username or password."}), 401)
if row["status"] in {"disabled", "blacklisted"}:
return None, (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 authenticated, None

464
calls.py Normal file
View File

@ -0,0 +1,464 @@
import json
import time
from db import db_write, get_db
from config import utc_now, now_iso, GROUP_CALL_PARTICIPANT_STALE_SECONDS
def create_call_session(dm_id: int, started_by_user_id: int) -> int:
cursor = db_write(
"""
INSERT INTO call_sessions (
direct_conversation_id,
started_by_user_id,
status,
created_at,
ended_at
) VALUES (?, ?, 'active', ?, NULL)
""",
(dm_id, started_by_user_id, now_iso()),
)
return cursor.lastrowid
def append_call_signal(call_id: int, from_user_id: int, signal_type: str, payload: dict) -> int:
cursor = db_write(
"""
INSERT INTO call_signals (
call_session_id,
from_user_id,
signal_type,
payload_json,
created_at
) VALUES (?, ?, ?, ?, ?)
""",
(
call_id,
from_user_id,
signal_type,
json.dumps(payload or {}),
now_iso(),
),
)
return cursor.lastrowid
def list_call_signals(call_id: int, after_id: int = 0) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
cs.id,
cs.call_session_id,
cs.from_user_id,
u.username,
cs.signal_type,
cs.payload_json,
cs.created_at
FROM call_signals AS cs
JOIN users AS u
ON u.id = cs.from_user_id
WHERE cs.call_session_id = ?
AND cs.id > ?
ORDER BY cs.id ASC
LIMIT 200
""",
(call_id, after_id),
).fetchall()
items = []
for row in rows:
try:
payload = json.loads(row["payload_json"] or "{}")
except json.JSONDecodeError:
payload = {}
items.append(
{
"id": row["id"],
"call_session_id": row["call_session_id"],
"from_user_id": row["from_user_id"],
"username": row["username"],
"signal_type": row["signal_type"],
"payload": payload,
"created_at": row["created_at"],
}
)
return items
def end_call(call_id: int) -> None:
db_write(
"""
UPDATE call_sessions
SET
status = 'ended',
ended_at = ?
WHERE id = ?
""",
(now_iso(), call_id),
)
def get_call_with_dm(call_id: int):
with get_db() as db:
row = db.execute(
"""
SELECT
cs.id,
cs.direct_conversation_id,
cs.started_by_user_id,
cs.status,
cs.created_at,
cs.ended_at
FROM call_sessions AS cs
WHERE cs.id = ?
""",
(call_id,),
).fetchone()
return row
def call_payload(call_id: int):
row = get_call_with_dm(call_id)
if not row:
return None
return {
"id": row["id"],
"direct_conversation_id": row["direct_conversation_id"],
"started_by_user_id": row["started_by_user_id"],
"status": row["status"],
"created_at": row["created_at"],
"ended_at": row["ended_at"],
}
def build_room_helper_call_id(room) -> str:
return f"room-{room['slug']}"
def build_channel_helper_call_id(channel) -> str:
return f"channel-{channel['team_slug']}--{channel['slug']}"
def get_or_create_group_call_session(
scope_kind: str,
scope_id: int,
scope_slug: str,
helper_call_id: str,
created_by_user_id: int,
):
with get_db() as db:
existing = db.execute(
"""
SELECT
id,
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
status,
created_at,
ended_at
FROM group_call_sessions
WHERE helper_call_id = ?
LIMIT 1
""",
(helper_call_id,),
).fetchone()
if existing and existing["status"] == "active":
return existing
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
existing = db.execute(
"""
SELECT
id,
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
status,
created_at,
ended_at
FROM group_call_sessions
WHERE helper_call_id = ?
LIMIT 1
""",
(helper_call_id,),
).fetchone()
if existing and existing["status"] == "active":
db.commit()
return existing
if existing:
db.execute(
"""
UPDATE group_call_sessions
SET
status = 'active',
ended_at = NULL,
created_at = ?
WHERE id = ?
""",
(now_iso(), existing["id"]),
)
db.commit()
refreshed = db.execute(
"""
SELECT
id,
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
status,
created_at,
ended_at
FROM group_call_sessions
WHERE id = ?
""",
(existing["id"],),
).fetchone()
return refreshed
cursor = db.execute(
"""
INSERT INTO group_call_sessions (
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
status,
created_at,
ended_at
) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL)
""",
(
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
now_iso(),
),
)
session_id = cursor.lastrowid
db.commit()
created = db.execute(
"""
SELECT
id,
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
status,
created_at,
ended_at
FROM group_call_sessions
WHERE id = ?
""",
(session_id,),
).fetchone()
return created
def group_call_session_payload(row):
if not row:
return None
return {
"id": row["id"],
"scope_kind": row["scope_kind"],
"scope_id": row["scope_id"],
"scope_slug": row["scope_slug"],
"helper_call_id": row["helper_call_id"],
"created_by_user_id": row["created_by_user_id"],
"status": row["status"],
"created_at": row["created_at"],
"ended_at": row["ended_at"],
}
def get_group_call_session_by_id(session_id: int):
with get_db() as db:
return db.execute(
"""
SELECT
id,
scope_kind,
scope_id,
scope_slug,
helper_call_id,
created_by_user_id,
status,
created_at,
ended_at
FROM group_call_sessions
WHERE id = ?
LIMIT 1
""",
(session_id,),
).fetchone()
def cleanup_stale_group_call_participants(session_id: int):
cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS
db_write(
"""
UPDATE group_call_participants
SET
left_at = COALESCE(left_at, ?)
WHERE group_call_session_id = ?
AND left_at IS NULL
AND (
updated_at IS NULL
OR strftime('%s', updated_at) < ?
)
""",
(now_iso(), session_id, int(cutoff)),
)
def upsert_group_call_participant(session_id: int, user_id: int):
db_write(
"""
INSERT INTO group_call_participants (
group_call_session_id,
user_id,
joined_at,
updated_at,
left_at
) VALUES (?, ?, ?, ?, NULL)
ON CONFLICT(group_call_session_id, user_id)
DO UPDATE SET
updated_at = excluded.updated_at,
left_at = NULL
""",
(session_id, user_id, now_iso(), now_iso()),
)
def heartbeat_group_call_participant(session_id: int, user_id: int):
db_write(
"""
UPDATE group_call_participants
SET
updated_at = ?,
left_at = NULL
WHERE group_call_session_id = ?
AND user_id = ?
""",
(now_iso(), session_id, user_id),
)
def mark_group_call_participant_left(session_id: int, user_id: int):
db_write(
"""
UPDATE group_call_participants
SET
updated_at = ?,
left_at = ?
WHERE group_call_session_id = ?
AND user_id = ?
AND left_at IS NULL
""",
(now_iso(), now_iso(), session_id, user_id),
)
def list_group_call_participants(session_id: int) -> list[dict]:
cleanup_stale_group_call_participants(session_id)
with get_db() as db:
rows = db.execute(
"""
SELECT
gcp.group_call_session_id,
gcp.user_id,
gcp.joined_at,
gcp.updated_at,
gcp.left_at,
u.username,
u.role,
up.display_name,
us.name_color
FROM group_call_participants AS gcp
JOIN users AS u
ON u.id = gcp.user_id
LEFT JOIN user_profiles AS up
ON up.user_id = u.id
LEFT JOIN user_settings AS us
ON us.user_id = u.id
WHERE gcp.group_call_session_id = ?
AND gcp.left_at IS NULL
ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE
""",
(session_id,),
).fetchall()
return [
{
"user_id": row["user_id"],
"username": row["username"],
"display_name": row["display_name"] or row["username"],
"role": row["role"],
"name_color": row["name_color"],
"joined_at": row["joined_at"],
"updated_at": row["updated_at"],
}
for row in rows
]
def active_group_call_participant_count(session_id: int) -> int:
cleanup_stale_group_call_participants(session_id)
with get_db() as db:
row = db.execute(
"""
SELECT COUNT(*) AS count
FROM group_call_participants
WHERE group_call_session_id = ?
AND left_at IS NULL
""",
(session_id,),
).fetchone()
return int(row["count"]) if row else 0
def maybe_end_group_call_if_empty(session_id: int):
if active_group_call_participant_count(session_id) > 0:
return
db_write(
"""
UPDATE group_call_sessions
SET
status = 'ended',
ended_at = ?
WHERE id = ?
AND status != 'ended'
""",
(now_iso(), session_id),
)
db_write(
"""
UPDATE group_call_invites
SET
status = 'dismissed',
responded_at = ?
WHERE group_call_session_id = ?
AND status = 'pending'
""",
(now_iso(), session_id),
)

231
config.py Normal file
View File

@ -0,0 +1,231 @@
import os
import secrets
from datetime import timedelta, timezone, datetime
from pathlib import Path
from typing import Optional
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"),
)
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
MAX_REQUEST_BYTES = max(110 * 1024 * 1024, MAX_ATTACHMENT_BYTES + 10 * 1024 * 1024)
MAX_MESSAGE_LENGTH = 1000
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_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_PROFILE_BADGE_LENGTH = 48
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
GROUP_CALL_PARTICIPANT_STALE_SECONDS = 45
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"))
ALLOWED_ATTACHMENT_EXTENSIONS = {
"txt",
"pdf",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"mp3",
"wav",
"ogg",
"m4a",
"aac",
"flac",
"mp4",
"webm",
"mov",
"m4v",
"ogv",
}
ALLOWED_ATTACHMENT_MIME_TYPES = {
"text/plain",
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"audio/mpeg",
"audio/mp3",
"audio/wav",
"audio/x-wav",
"audio/ogg",
"audio/mp4",
"audio/aac",
"audio/flac",
"video/mp4",
"video/webm",
"video/quicktime",
"video/ogg",
}
ROLES = {"user", "moderator", "admin"}
STATUSES = {"active", "disabled", "restricted", "blacklisted"}
ROOM_VISIBILITIES = {"public", "private"}
RATE_LIMITS = {
"login": (8, 10 * 60),
"register": (5, 60 * 60),
"send": (30, 60),
"admin": (60, 60),
"attachment": (20, 60),
"typing": (60, 60),
"reaction": (120, 60),
"profile": (30, 60),
"settings": (30, 60),
}
PLACEHOLDER_SECRETS = {
"",
"change-this-secret-key",
"replace-this-with-a-long-random-secret",
"replace-me",
"secret",
"changeme",
}
RUNTIME_STATE = {
"last_attachment_cleanup": 0.0,
}
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def now_iso() -> str:
return utc_now().isoformat()
def parse_iso(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
if not parsed.tzinfo:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> str:
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")
os.chmod(path, 0o600)
except OSError:
pass
return generated
SECRET_KEY = load_or_create_key(SECRET_KEY_PATH, "FLASK_SECRET_KEY")
CLIENT_HASH_KEY = load_or_create_key(CLIENT_HASH_KEY_PATH, "CHAT_CLIENT_HASH_KEY")
TRUSTED_PROXY_HOPS = int(os.environ.get("TRUSTED_PROXY_HOPS", "0"))
SESSION_COOKIE_SECURE = (os.environ.get("SESSION_COOKIE_SECURE", "1") == "1")
PERMANENT_SESSION_LIFETIME = timedelta(days=14)
def get_asset_version() -> str:
try:
js_dir = APP_ROOT / "static" / "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"
def flask_config() -> dict:
return {
"MAX_CONTENT_LENGTH": MAX_REQUEST_BYTES,
"SESSION_COOKIE_NAME": "chat_session",
"SESSION_COOKIE_HTTPONLY": True,
"SESSION_COOKIE_SAMESITE": "Lax",
"SESSION_COOKIE_SECURE": SESSION_COOKIE_SECURE,
"PERMANENT_SESSION_LIFETIME": PERMANENT_SESSION_LIFETIME,
}
CONSTANTS = {
"MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH,
"MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES,
"MAX_INCREMENTAL_MESSAGES": MAX_INCREMENTAL_MESSAGES,
"MAX_FEED_MESSAGES": MAX_FEED_MESSAGES,
"MAX_THREAD_REPLIES": MAX_THREAD_REPLIES,
"MAX_ATTACHMENT_BYTES": MAX_ATTACHMENT_BYTES,
"MAX_ATTACHMENT_NAME_LENGTH": MAX_ATTACHMENT_NAME_LENGTH,
"ALLOWED_ATTACHMENT_EXTENSIONS": ALLOWED_ATTACHMENT_EXTENSIONS,
"ALLOWED_ATTACHMENT_MIME_TYPES": ALLOWED_ATTACHMENT_MIME_TYPES,
"INVITE_TTL_HOURS": INVITE_TTL_HOURS,
"TEAM_INVITE_TTL_HOURS": TEAM_INVITE_TTL_HOURS,
"TYPING_TTL_SECONDS": TYPING_TTL_SECONDS,
"PRESENCE_TTL_SECONDS": PRESENCE_TTL_SECONDS,
"MAX_SEARCH_QUERY_LENGTH": MAX_SEARCH_QUERY_LENGTH,
"MAX_SEARCH_RESULTS": MAX_SEARCH_RESULTS,
"READ_RECEIPT_BATCH_LIMIT": READ_RECEIPT_BATCH_LIMIT,
"MAX_MESSAGE_EDIT_WINDOW_SECONDS": MAX_MESSAGE_EDIT_WINDOW_SECONDS,
"MAX_DISPLAY_NAME_LENGTH": MAX_DISPLAY_NAME_LENGTH,
"MAX_PROFILE_BIO_LENGTH": MAX_PROFILE_BIO_LENGTH,
"MAX_LOCATION_LENGTH": MAX_LOCATION_LENGTH,
"MAX_WEBSITE_LENGTH": MAX_WEBSITE_LENGTH,
"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,
"ROOM_VISIBILITIES": ROOM_VISIBILITIES,
"ROLES": ROLES,
"STATUSES": STATUSES,
"GROUP_CALL_PARTICIPANT_STALE_SECONDS": GROUP_CALL_PARTICIPANT_STALE_SECONDS,
}

948
db.py Normal file
View File

@ -0,0 +1,948 @@
import os
import sqlite3
import time
from typing import Optional
from werkzeug.security import generate_password_hash
from config import (
DB_PATH,
UPLOAD_DIR,
ROLES,
STATUSES,
now_iso,
)
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")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA busy_timeout = 8000")
conn.execute("PRAGMA synchronous = NORMAL")
return conn
def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor:
last_error = None
for attempt in range(5):
try:
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
cursor = db.execute(query, params)
db.commit()
return cursor
except sqlite3.OperationalError as error:
last_error = error
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
raise
if attempt == 4:
raise
time.sleep(0.05 * (2 ** attempt))
raise last_error
def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool:
rows = db.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
def init_db() -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
with get_db() as db:
db.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT COLLATE NOCASE UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active',
restricted_until TEXT,
restriction_reason TEXT,
created_at TEXT NOT NULL,
last_login_at TEXT
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS rooms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
visibility TEXT NOT NULL DEFAULT 'public',
created_by_user_id INTEGER,
created_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS room_members (
room_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
added_at TEXT NOT NULL,
PRIMARY KEY (room_id, user_id),
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uploader_user_id INTEGER NOT NULL,
stored_name TEXT UNIQUE NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by_user_id INTEGER,
created_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS team_members (
team_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
added_at TEXT NOT NULL,
PRIMARY KEY (team_id, user_id),
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS team_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
created_by_user_id INTEGER NOT NULL,
membership_role TEXT NOT NULL DEFAULT 'member',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT,
used_by_user_id INTEGER,
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
legacy_room_id INTEGER UNIQUE,
slug TEXT COLLATE NOCASE NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by_user_id INTEGER,
created_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
UNIQUE(team_id, slug),
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
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 (
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_id INTEGER,
parent_message_id INTEGER,
created_at TEXT NOT NULL,
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,
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL,
FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS room_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room_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,
used_at TEXT,
used_by_user_id INTEGER,
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS typing_events (
room_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (room_id, user_id),
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS channel_typing_events (
channel_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
updated_at REAL NOT NULL,
PRIMARY KEY (channel_id, user_id),
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS dm_typing_events (
direct_conversation_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
updated_at REAL 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 blacklist_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
value_hash TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL,
expires_at TEXT,
created_by_user_id INTEGER,
UNIQUE(kind, value_hash),
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS rate_limit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL,
subject_hash TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
details TEXT,
client_hash TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER NOT NULL,
channel_id INTEGER,
slug TEXT COLLATE NOCASE NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL DEFAULT 'team',
page_type TEXT NOT NULL DEFAULT 'document',
created_by_user_id INTEGER NOT NULL,
updated_by_user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
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 (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 user_profiles (
user_id INTEGER PRIMARY KEY,
display_name TEXT,
badge_text TEXT,
bio TEXT NOT NULL DEFAULT '',
bio_mode TEXT NOT NULL DEFAULT 'txt',
website TEXT,
location TEXT,
status_message TEXT NOT NULL DEFAULT '',
banner_attachment_id INTEGER,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY,
accent_color TEXT,
name_color TEXT,
custom_css TEXT NOT NULL DEFAULT '',
theme_mode TEXT NOT NULL DEFAULT 'dark',
profile_background_color TEXT,
profile_banner_color TEXT,
profile_visibility TEXT NOT NULL DEFAULT 'public',
last_active_visibility TEXT NOT NULL DEFAULT 'everyone',
presence_mode TEXT NOT NULL DEFAULT 'online',
dm_layout_mode TEXT NOT NULL DEFAULT 'cards',
show_presence_badge INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS message_reactions (
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, user_id, emoji),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS message_edits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
edited_by_user_id INTEGER NOT NULL,
previous_text TEXT NOT NULL,
new_text TEXT NOT NULL,
previous_attachments_json TEXT NOT NULL DEFAULT '[]',
new_attachments_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS message_receipts (
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
seen_at TEXT NOT NULL,
PRIMARY KEY (message_id, user_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS message_attachments (
message_id INTEGER NOT NULL,
attachment_id INTEGER NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (message_id, attachment_id),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS presence_events (
user_id INTEGER PRIMARY KEY,
updated_at REAL NOT NULL,
view_name TEXT,
room_id INTEGER,
channel_id INTEGER,
direct_conversation_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS call_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direct_conversation_id INTEGER NOT NULL,
started_by_user_id INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
ended_at TEXT,
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS call_signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
call_session_id INTEGER NOT NULL,
from_user_id INTEGER NOT NULL,
signal_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
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
)
"""
)
db.execute(
"""
CREATE TABLE IF NOT EXISTS group_call_participants (
group_call_session_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
joined_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
left_at TEXT,
PRIMARY KEY (group_call_session_id, user_id),
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (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"):
db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER")
if not column_exists(db, "messages", "parent_message_id"):
db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER")
if not column_exists(db, "messages", "channel_id"):
db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER")
if not column_exists(db, "messages", "direct_conversation_id"):
db.execute("ALTER TABLE messages 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, "user_profiles", "status_message"):
db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''")
if not column_exists(db, "user_profiles", "banner_attachment_id"):
db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER")
if not column_exists(db, "user_settings", "name_color"):
db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT")
if not column_exists(db, "user_settings", "profile_background_color"):
db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT")
if not column_exists(db, "user_settings", "profile_banner_color"):
db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT")
if not column_exists(db, "user_settings", "profile_visibility"):
db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'")
if not column_exists(db, "user_settings", "last_active_visibility"):
db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'")
if not column_exists(db, "user_settings", "presence_mode"):
db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'")
if not column_exists(db, "user_settings", "dm_layout_mode"):
db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'")
if not column_exists(db, "user_settings", "show_presence_badge"):
db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1")
if not column_exists(db, "presence_events", "direct_conversation_id"):
db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_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)")
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)")
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)")
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)")
db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)")
db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)")
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_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)")
db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)")
db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)")
db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)")
db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)")
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)")
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)")
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)")
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)")
general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone()
if not general:
cursor = db.execute(
"""
INSERT INTO rooms (
slug,
name,
description,
visibility,
created_at,
is_active
) VALUES (?, ?, ?, ?, ?, 1)
""",
("general", "general", "Default public chat room", "public", now_iso()),
)
general_id = cursor.lastrowid
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 users
SET role = CASE
WHEN is_admin = 1 THEN 'admin'
ELSE 'user'
END
WHERE role IS NULL
OR role = ''
"""
)
db.execute(
"""
UPDATE users
SET status = CASE
WHEN is_active = 1 THEN 'active'
ELSE 'disabled'
END
WHERE status IS NULL
OR status = ''
"""
)
general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone()
if general_team:
general_team_id = general_team["id"]
else:
cursor = db.execute(
"""
INSERT INTO teams (
slug,
name,
description,
created_by_user_id,
created_at,
is_active
) VALUES (?, ?, ?, NULL, ?, 1)
""",
("general", "General", "Default team for existing chat rooms.", now_iso()),
)
general_team_id = cursor.lastrowid
legacy_rooms = db.execute(
"""
SELECT
id,
slug,
name,
description,
created_by_user_id,
created_at
FROM rooms
WHERE is_active = 1
"""
).fetchall()
for room in legacy_rooms:
existing_channel = db.execute(
"SELECT id FROM channels WHERE legacy_room_id = ?",
(room["id"],),
).fetchone()
if existing_channel:
continue
db.execute(
"""
INSERT INTO channels (
team_id,
legacy_room_id,
slug,
name,
description,
created_by_user_id,
created_at,
is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
""",
(
general_team_id,
room["id"],
room["slug"],
room["name"],
room["description"],
room["created_by_user_id"],
room["created_at"],
),
)
db.execute(
"""
UPDATE messages
SET channel_id = (
SELECT c.id
FROM channels AS c
WHERE c.legacy_room_id = messages.room_id
LIMIT 1
)
WHERE channel_id IS NULL
AND room_id IS NOT NULL
AND direct_conversation_id IS NULL
"""
)
admin_users = db.execute(
"""
SELECT id
FROM users
WHERE role = 'admin'
AND status = 'active'
"""
).fetchall()
for admin_user in admin_users:
db.execute(
"""
INSERT OR IGNORE INTO team_members (
team_id,
user_id,
membership_role,
added_at
) VALUES (?, ?, 'admin', ?)
""",
(general_team_id, admin_user["id"], now_iso()),
)
db.execute(
"""
INSERT OR IGNORE INTO user_profiles (
user_id,
display_name,
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
updated_at
)
SELECT
id,
username,
'',
'',
'txt',
'',
'',
'',
NULL,
?
FROM users
""",
(now_iso(),),
)
db.execute(
"""
INSERT OR IGNORE INTO user_settings (
user_id,
accent_color,
name_color,
custom_css,
theme_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
presence_mode,
dm_layout_mode,
show_presence_badge,
updated_at
)
SELECT
id,
'#f7dce8',
NULL,
'',
'dark',
NULL,
NULL,
'public',
'everyone',
'online',
'cards',
1,
?
FROM users
""",
(now_iso(),),
)
existing_message_attachment_counts = db.execute(
"SELECT COUNT(*) AS count FROM message_attachments"
).fetchone()["count"]
if existing_message_attachment_counts == 0:
rows = db.execute(
"""
SELECT id, attachment_id
FROM messages
WHERE attachment_id IS NOT NULL
ORDER BY id ASC
"""
).fetchall()
for row in rows:
db.execute(
"""
INSERT OR IGNORE INTO message_attachments (
message_id,
attachment_id,
position
) VALUES (?, ?, 0)
""",
(row["id"], row["attachment_id"]),
)
db.commit()
def bootstrap_admin() -> None:
username = os.environ.get("ADMIN_USERNAME", "").strip().lower()
password = os.environ.get("ADMIN_PASSWORD", "")
if not username or not password:
return
with get_db() as db:
existing = db.execute(
"SELECT id FROM users WHERE username = ?",
(username,),
).fetchone()
if existing:
db.execute(
"""
UPDATE users
SET
role = 'admin',
status = 'active',
is_admin = 1,
is_active = 1
WHERE id = ?
""",
(existing["id"],),
)
user_id = existing["id"]
else:
cursor = db.execute(
"""
INSERT INTO users (
username,
password_hash,
role,
status,
is_admin,
is_active,
created_at
) VALUES (?, ?, 'admin', 'active', 1, 1, ?)
""",
(username, generate_password_hash(password), now_iso()),
)
user_id = cursor.lastrowid
db.execute(
"""
INSERT OR IGNORE INTO user_profiles (
user_id,
display_name,
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
updated_at
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
""",
(user_id, username, now_iso()),
)
db.execute(
"""
INSERT OR IGNORE INTO user_settings (
user_id,
accent_color,
name_color,
custom_css,
theme_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
presence_mode,
dm_layout_mode,
show_presence_badge,
updated_at
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
""",
(user_id, now_iso()),
)
db.commit()

View File

@ -2,19 +2,23 @@ import logging
import os
import platform
import signal
import stat
import sys
import time
import traceback
import webbrowser
from logging.handlers import RotatingFileHandler
from pathlib import Path
APP_NAME = "angelhome"
APP_VERSION = "2.1.1"
START_URL = "https://angel.33.backend-3.com"
ALLOWED_HOSTS = {"angel.33.backend-3.com"}
ALLOW_SUBDOMAINS = False
ALLOWED_PATH_PREFIXES = ["/"]
OPEN_EXTERNAL_LINKS_IN_SYSTEM_BROWSER = False
# Set False so cookies/local storage/session data can persist
OFF_THE_RECORD = False
WINDOW_TITLE = APP_NAME
@ -25,17 +29,71 @@ LOG_LEVEL = logging.INFO
LOG_MAX_BYTES = 2 * 1024 * 1024
LOG_BACKUP_COUNT = 3
ALLOW_CLIPBOARD = False
ALLOW_GEOLOCATION = False
ALLOW_NOTIFICATIONS = False
ALLOW_MEDIA_AUDIO_CAPTURE = False
ALLOW_MEDIA_VIDEO_CAPTURE = False
ALLOW_SCREEN_CAPTURE = False
ALLOW_WEBRTC = False
ENABLE_SINGLE_INSTANCE_IPC = True
IPC_CONNECT_TIMEOUT_MS = 1500
ACTIVATE_WINDOW_MESSAGE = "ACTIVATE"
MAX_RENDER_CRASHES = 3
RENDER_CRASH_WINDOW_SECONDS = 60
BLOCK_UNKNOWN_SCHEMES = True
# ---------- Logging / runtime behavior ----------
# In production/frozen mode, default to no console logging.
ENABLE_CONSOLE_LOGGING_WHEN_FROZEN = False
# If True, redact path too, leaving scheme://host[:port] only.
REDACT_URL_PATH = False
# When frozen on Windows, silence stdout/stderr by default.
DETACH_STDIO_WHEN_FROZEN = True
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def detach_stdio_if_needed() -> None:
"""
Best-effort suppression of terminal output in frozen production builds.
Packaging still matters:
- PyInstaller: use --noconsole / windowed
- cx_Freeze: use Win32GUI base
"""
try:
if not is_frozen():
return
if not DETACH_STDIO_WHEN_FROZEN:
return
devnull_r = open(os.devnull, "r", encoding="utf-8", errors="ignore")
devnull_w = open(os.devnull, "a", encoding="utf-8", errors="ignore")
try:
sys.stdin = devnull_r
except Exception:
pass
try:
sys.stdout = devnull_w
except Exception:
pass
try:
sys.stderr = devnull_w
except Exception:
pass
except Exception:
pass
def resource_dir() -> Path:
"""
Directory where bundled resources live.
In PyInstaller one-file mode, this may point to _MEIPASS.
In one-folder mode, it is usually the executable folder.
"""
if is_frozen() and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS)
if is_frozen():
@ -44,27 +102,41 @@ def resource_dir() -> Path:
def base_dir() -> Path:
"""
Writable base directory for persistent app data.
Always use executable folder when frozen.
Never write into _MEIPASS.
"""
if is_frozen():
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def ensure_dir(path: Path) -> Path:
def set_directory_permissions(path: Path, mode: int) -> None:
try:
if os.name == "posix":
os.chmod(path, mode)
except Exception:
pass
def ensure_dir(path: Path, mode: int | None = None) -> Path:
path.mkdir(parents=True, exist_ok=True)
if mode is not None:
set_directory_permissions(path, mode)
return path
DATA_DIR = ensure_dir(base_dir() / f"{APP_NAME}_data")
LOG_DIR = ensure_dir(DATA_DIR / "logs")
RUNTIME_DIR = ensure_dir(DATA_DIR / "runtime")
CACHE_DIR = ensure_dir(DATA_DIR / "cache")
STORAGE_DIR = ensure_dir(DATA_DIR / "storage")
def harden_directory_permissions(paths: list[tuple[Path, int]]) -> None:
for path, mode in paths:
try:
set_directory_permissions(path, mode)
except Exception:
pass
DATA_DIR = ensure_dir(base_dir() / f"{APP_NAME}_data", 0o700)
LOG_DIR = ensure_dir(DATA_DIR / "logs", 0o700)
RUNTIME_DIR = ensure_dir(DATA_DIR / "runtime", 0o700)
CACHE_DIR = ensure_dir(DATA_DIR / "cache", 0o700)
STORAGE_DIR = ensure_dir(DATA_DIR / "storage", 0o700)
LOCK_FILE = RUNTIME_DIR / f"{APP_NAME}.lock"
IPC_SOCKET_NAME = f"{APP_NAME}_single_instance_socket"
def configure_runtime_environment() -> None:
@ -80,8 +152,17 @@ def configure_runtime_environment() -> None:
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
if system == "linux":
ensure_dir(RUNTIME_DIR, 0o700)
try:
current_mode = stat.S_IMODE(os.stat(RUNTIME_DIR).st_mode)
if current_mode != 0o700:
os.chmod(RUNTIME_DIR, 0o700)
except Exception:
pass
os.environ["XDG_RUNTIME_DIR"] = str(RUNTIME_DIR)
os.environ.setdefault("QTWEBENGINE_DISABLE_SANDBOX", "1")
os.environ.setdefault("XDG_RUNTIME_DIR", str(RUNTIME_DIR))
add_flag("--no-sandbox")
add_flag("--disable-extensions")
add_flag("--disable-plugins")
@ -101,30 +182,64 @@ def configure_runtime_environment() -> None:
add_flag("--no-default-browser-check")
add_flag("--no-first-run")
if not ALLOW_WEBRTC:
add_flag("--disable-webrtc")
if flags:
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = " ".join(flags)
detach_stdio_if_needed()
configure_runtime_environment()
from PySide6.QtCore import (
QCoreApplication,
QLockFile,
QStandardPaths,
QTimer,
QUrl,
Slot,
)
from PySide6.QtCore import QCoreApplication, QLockFile, QStandardPaths, QTimer, QUrl, Slot
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import QApplication, QMainWindow, QMessageBox
from PySide6.QtWebEngineCore import (
QWebEngineCertificateError,
QWebEnginePage,
QWebEngineProfile,
QWebEngineSettings,
QWebEngineUrlRequestInfo,
QWebEngineUrlRequestInterceptor,
)
from PySide6.QtWebEngineWidgets import QWebEngineView
def safe_url_string(url) -> str:
try:
return url.toString() if isinstance(url, QUrl) else str(url)
except Exception:
return "<unavailable-url>"
def redact_url(url: QUrl | str, hide_path: bool = REDACT_URL_PATH) -> str:
try:
qurl = url if isinstance(url, QUrl) else QUrl(str(url))
if not qurl.isValid():
return "<invalid-url>"
scheme = (qurl.scheme() or "").lower()
host = qurl.host() or ""
port = qurl.port()
path = qurl.path() or "/"
if not scheme or not host:
return "<redacted-url>"
if port and port > 0:
base = f"{scheme}://{host}:{port}"
else:
base = f"{scheme}://{host}"
if hide_path:
return base
return f"{base}{path}"
except Exception:
return "<unavailable-url>"
def configure_logging() -> logging.Logger:
logger = logging.getLogger(APP_NAME)
logger.setLevel(LOG_LEVEL)
@ -134,6 +249,7 @@ def configure_logging() -> logging.Logger:
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
if not (is_frozen() and not ENABLE_CONSOLE_LOGGING_WHEN_FROZEN):
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(sh)
@ -161,13 +277,6 @@ def is_root() -> bool:
return False
def safe_url_string(url) -> str:
try:
return url.toString() if isinstance(url, QUrl) else str(url)
except Exception:
return "<unavailable-url>"
def is_allowed_host(host: str) -> bool:
host = (host or "").lower().strip()
@ -183,6 +292,11 @@ def is_allowed_host(host: str) -> bool:
return False
def is_allowed_path(path: str) -> bool:
path = path or "/"
return any(path.startswith(prefix) for prefix in ALLOWED_PATH_PREFIXES)
def is_allowed_url(url: QUrl) -> bool:
try:
return bool(
@ -190,6 +304,9 @@ def is_allowed_url(url: QUrl) -> bool:
and url.isValid()
and url.scheme().lower() == "https"
and is_allowed_host(url.host())
and is_allowed_path(url.path())
and not url.userName()
and not url.password()
)
except Exception:
return False
@ -215,19 +332,78 @@ def open_in_system_browser(url: QUrl) -> None:
url_str = safe_url_string(url)
ok = webbrowser.open(url_str)
if ok:
logger.info("Opened external link in system browser: %s", url_str)
logger.info("Opened external link in system browser: %s", redact_url(url))
else:
logger.warning("System browser did not confirm opening URL: %s", url_str)
logger.warning("System browser did not confirm opening URL: %s", redact_url(url))
except Exception:
logger.exception(
"Failed to open URL in system browser: %s", safe_url_string(url)
logger.exception("Failed to open URL in system browser: %s", redact_url(url))
class RequestInterceptor(QWebEngineUrlRequestInterceptor):
def interceptRequest(self, info: QWebEngineUrlRequestInfo) -> None:
try:
url = info.requestUrl()
scheme = (url.scheme() or "").lower()
host = (url.host() or "").lower()
method = bytes(info.requestMethod()).decode("utf-8", "ignore")
if BLOCK_UNKNOWN_SCHEMES and scheme not in {"https", "wss"}:
logger.warning(
"[BLOCKED REQUEST] reason=scheme scheme=%s method=%s url=%s resource_type=%s",
scheme,
method,
redact_url(url),
info.resourceType(),
)
info.block(True)
return
if not is_allowed_host(host):
logger.warning(
"[BLOCKED REQUEST] reason=host scheme=%s host=%s method=%s url=%s resource_type=%s",
scheme,
host,
method,
redact_url(url),
info.resourceType(),
)
info.block(True)
return
if scheme == "https":
if not is_allowed_path(url.path()):
logger.warning(
"[BLOCKED REQUEST] reason=path method=%s url=%s resource_type=%s",
method,
redact_url(url),
info.resourceType(),
)
info.block(True)
return
elif scheme == "wss":
if not is_allowed_host(host):
logger.warning(
"[BLOCKED REQUEST] reason=websocket-host method=%s url=%s",
method,
redact_url(url),
)
info.block(True)
return
except Exception:
logger.exception("Error while intercepting request")
try:
info.block(True)
except Exception:
pass
class SingleInstance:
def __init__(self, lock_path: Path):
self.lock = QLockFile(str(lock_path))
try:
self.lock.setStaleLockTime(0)
except Exception:
pass
def acquire(self) -> bool:
try:
@ -243,6 +419,90 @@ class SingleInstance:
logger.debug("Failed to release single-instance lock", exc_info=True)
class ActivationServer:
def __init__(self, server_name: str, activate_callback):
self.server_name = server_name
self.activate_callback = activate_callback
self.server = None
def start(self) -> bool:
if not ENABLE_SINGLE_INSTANCE_IPC:
return True
try:
QLocalServer.removeServer(self.server_name)
except Exception:
pass
self.server = QLocalServer()
self.server.newConnection.connect(self._handle_connection)
if not self.server.listen(self.server_name):
logger.warning(
"Failed to start local activation server: %s",
self.server.errorString() if hasattr(self.server, "errorString") else "unknown",
)
return False
logger.info("Activation server listening: %s", self.server_name)
return True
def close(self) -> None:
try:
if self.server is not None:
self.server.close()
except Exception:
logger.debug("Failed to close activation server", exc_info=True)
try:
QLocalServer.removeServer(self.server_name)
except Exception:
logger.debug("Failed to remove activation server name", exc_info=True)
@Slot()
def _handle_connection(self) -> None:
try:
while self.server.hasPendingConnections():
socket_ = self.server.nextPendingConnection()
if socket_ is None:
continue
socket_.waitForReadyRead(500)
data = bytes(socket_.readAll()).decode("utf-8", "ignore").strip()
logger.info("Received IPC message: %s", data)
if data == ACTIVATE_WINDOW_MESSAGE:
try:
self.activate_callback()
except Exception:
logger.exception("Failed to activate existing window")
socket_.disconnectFromServer()
except Exception:
logger.exception("Failed to handle IPC activation connection")
def signal_existing_instance(server_name: str) -> bool:
if not ENABLE_SINGLE_INSTANCE_IPC:
return False
try:
socket_ = QLocalSocket()
socket_.connectToServer(server_name)
if not socket_.waitForConnected(IPC_CONNECT_TIMEOUT_MS):
return False
socket_.write(ACTIVATE_WINDOW_MESSAGE.encode("utf-8"))
socket_.flush()
socket_.waitForBytesWritten(IPC_CONNECT_TIMEOUT_MS)
socket_.disconnectFromServer()
return True
except Exception:
logger.debug("Failed to signal existing instance", exc_info=True)
return False
class LockedPage(QWebEnginePage):
def __init__(self, profile: QWebEngineProfile, parent=None):
super().__init__(profile, parent)
@ -253,9 +513,7 @@ class LockedPage(QWebEnginePage):
logger.debug("certificateError signal unavailable", exc_info=True)
try:
self.featurePermissionRequested.connect(
self._on_feature_permission_requested
)
self.featurePermissionRequested.connect(self._on_feature_permission_requested)
except Exception:
logger.debug("featurePermissionRequested signal unavailable", exc_info=True)
@ -283,15 +541,12 @@ class LockedPage(QWebEnginePage):
logger.warning(
"[BLOCKED NAVIGATION] url=%s is_main_frame=%s nav_type=%s",
safe_url_string(url),
redact_url(url),
is_main_frame,
nav_type,
)
if OPEN_EXTERNAL_LINKS_IN_SYSTEM_BROWSER and url.scheme().lower() in {
"http",
"https",
}:
if OPEN_EXTERNAL_LINKS_IN_SYSTEM_BROWSER and url.scheme().lower() in {"http", "https"}:
open_in_system_browser(url)
return False
@ -310,24 +565,65 @@ class LockedPage(QWebEnginePage):
@Slot(QUrl, object)
def _on_feature_permission_requested(self, security_origin, feature):
try:
allowed = False
try:
feature_enum = QWebEnginePage.Feature
except Exception:
feature_enum = None
if feature_enum is not None:
feature_map = {
getattr(feature_enum, "Geolocation", None): ALLOW_GEOLOCATION,
getattr(feature_enum, "Notifications", None): ALLOW_NOTIFICATIONS,
getattr(feature_enum, "MediaAudioCapture", None): ALLOW_MEDIA_AUDIO_CAPTURE,
getattr(feature_enum, "MediaVideoCapture", None): ALLOW_MEDIA_VIDEO_CAPTURE,
getattr(feature_enum, "MediaAudioVideoCapture", None): (
ALLOW_MEDIA_AUDIO_CAPTURE and ALLOW_MEDIA_VIDEO_CAPTURE
),
getattr(feature_enum, "DesktopVideoCapture", None): ALLOW_SCREEN_CAPTURE,
getattr(feature_enum, "DesktopAudioVideoCapture", None): (
ALLOW_SCREEN_CAPTURE and ALLOW_MEDIA_AUDIO_CAPTURE
),
getattr(feature_enum, "ClipboardReadWrite", None): ALLOW_CLIPBOARD,
getattr(feature_enum, "ClipboardRead", None): ALLOW_CLIPBOARD,
}
if feature in feature_map:
allowed = bool(feature_map[feature])
logger.warning(
"[DENIED PERMISSION] origin=%s feature=%s",
safe_url_string(security_origin),
feature,
"[FEATURE PERMISSION] origin=%s feature=%s allowed=%s",
redact_url(security_origin, hide_path=True),
str(feature),
allowed,
)
self.setFeaturePermission(
security_origin,
feature,
QWebEnginePage.PermissionPolicy.PermissionGrantedByUser
if allowed
else QWebEnginePage.PermissionPolicy.PermissionDeniedByUser,
)
except Exception:
logger.exception("Failed to process feature permission request")
try:
self.setFeaturePermission(
security_origin,
feature,
QWebEnginePage.PermissionPolicy.PermissionDeniedByUser,
)
except Exception:
logger.exception("Failed to deny feature permission request")
pass
@Slot(QWebEngineCertificateError)
def _on_certificate_error(self, error):
try:
logger.error(
"[TLS ERROR] %s @ %s", error.description(), safe_url_string(error.url())
"[TLS ERROR] %s @ %s",
error.description(),
redact_url(error.url()),
)
error.rejectCertificate()
except Exception:
@ -337,6 +633,7 @@ class LockedPage(QWebEnginePage):
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.render_crash_times = []
self.setWindowTitle(WINDOW_TITLE)
self.resize(WINDOW_WIDTH, WINDOW_HEIGHT)
@ -354,8 +651,8 @@ class MainWindow(QMainWindow):
def _build_profile(self) -> QWebEngineProfile:
try:
ensure_dir(CACHE_DIR)
ensure_dir(STORAGE_DIR)
ensure_dir(CACHE_DIR, 0o700)
ensure_dir(STORAGE_DIR, 0o700)
if OFF_THE_RECORD:
profile = QWebEngineProfile(self.browser)
@ -366,8 +663,8 @@ class MainWindow(QMainWindow):
profile.setPersistentStoragePath(str(STORAGE_DIR))
logger.info(
"Using persistent profile cache=%s storage=%s",
CACHE_DIR,
STORAGE_DIR,
str(CACHE_DIR),
str(STORAGE_DIR),
)
try:
@ -391,6 +688,14 @@ class MainWindow(QMainWindow):
except Exception:
logger.debug("setHttpCacheType unavailable", exc_info=True)
try:
self.request_interceptor = RequestInterceptor()
profile.setUrlRequestInterceptor(self.request_interceptor)
logger.info("Installed QWebEngineUrlRequestInterceptor")
except Exception:
logger.exception("Failed to install request interceptor")
raise
try:
profile.downloadRequested.connect(self._deny_download)
except Exception:
@ -425,14 +730,15 @@ class MainWindow(QMainWindow):
("HyperlinkAuditingEnabled", False),
("PdfViewerEnabled", False),
("PlaybackRequiresUserGesture", True),
("JavascriptCanAccessClipboard", ALLOW_CLIPBOARD),
("WebRTCPublicInterfacesOnly", not ALLOW_WEBRTC),
("ScreenCaptureEnabled", ALLOW_SCREEN_CAPTURE),
):
try:
attr = getattr(QWebEngineSettings.WebAttribute, attr_name)
settings.setAttribute(attr, value)
except Exception:
logger.debug(
"WebAttribute unavailable: %s", attr_name, exc_info=True
)
logger.debug("WebAttribute unavailable: %s", attr_name, exc_info=True)
logger.info("Applied WebEngine security settings")
except Exception:
@ -460,21 +766,39 @@ class MainWindow(QMainWindow):
raise ValueError("Refusing to load disallowed START_URL")
self.browser.setUrl(qurl)
logger.info("Loading start URL: %s", START_URL)
logger.info("Loading start URL: %s", redact_url(qurl))
except Exception:
logger.exception("Failed to load start URL")
QMessageBox.critical(
self, APP_NAME, "Failed to load the application start URL."
)
QMessageBox.critical(self, APP_NAME, "Failed to load the application start URL.")
raise
def _deny_download(self, item) -> None:
try:
logger.warning("[BLOCKED DOWNLOAD] %s", safe_url_string(item.url()))
logger.warning("[BLOCKED DOWNLOAD] %s", redact_url(item.url()))
item.cancel()
except Exception:
logger.exception("Failed while denying download request")
def activate_and_raise(self) -> None:
try:
if self.isMinimized():
self.showNormal()
else:
self.show()
self.raise_()
self.activateWindow()
logger.info("Activated existing main window")
except Exception:
logger.exception("Failed to activate main window")
def _record_render_crash(self) -> int:
now = time.time()
self.render_crash_times.append(now)
self.render_crash_times = [
t for t in self.render_crash_times if now - t <= RENDER_CRASH_WINDOW_SECONDS
]
return len(self.render_crash_times)
@Slot()
def _on_load_started(self):
logger.info("Page load started")
@ -485,7 +809,7 @@ class MainWindow(QMainWindow):
@Slot(bool)
def _on_load_finished(self, ok):
current_url = safe_url_string(self.browser.url())
current_url = redact_url(self.browser.url())
if ok:
logger.info("Page load finished successfully: %s", current_url)
else:
@ -501,7 +825,7 @@ class MainWindow(QMainWindow):
@Slot(QUrl)
def _on_url_changed(self, url):
logger.info("URL changed: %s", safe_url_string(url))
logger.info("URL changed: %s", redact_url(url))
@Slot(str)
def _on_title_changed(self, title):
@ -512,11 +836,28 @@ class MainWindow(QMainWindow):
@Slot(object, int)
def _on_render_process_terminated(self, termination_status, exit_code):
crash_count = self._record_render_crash()
logger.error(
"Render process terminated: status=%s exit_code=%s",
"Render process terminated: status=%s exit_code=%s recent_crashes=%s",
termination_status,
exit_code,
crash_count,
)
if crash_count >= MAX_RENDER_CRASHES:
try:
QMessageBox.critical(
self,
APP_NAME,
"The embedded browser process has crashed repeatedly.\n\n"
"Automatic reload has been stopped to avoid a crash loop.\n"
"Please restart the application or contact support.",
)
except Exception:
logger.exception("Failed showing crash-loop dialog")
return
try:
QMessageBox.critical(
self,
@ -529,18 +870,14 @@ class MainWindow(QMainWindow):
try:
self.browser.reload()
except Exception:
logger.exception(
"Failed to reload browser after render process termination"
)
logger.exception("Failed to reload browser after render process termination")
def excepthook(exc_type, exc_value, exc_tb):
tb_text = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
logger.critical("Unhandled exception:\n%s", tb_text)
try:
QMessageBox.critical(
None, APP_NAME, f"An unexpected error occurred:\n\n{exc_value}"
)
QMessageBox.critical(None, APP_NAME, f"An unexpected error occurred:\n\n{exc_value}")
except Exception:
pass
@ -561,7 +898,7 @@ def configure_qt_paths() -> None:
try:
QCoreApplication.setApplicationName(APP_NAME)
QCoreApplication.setOrganizationName(APP_NAME)
QCoreApplication.setApplicationVersion("2.0")
QCoreApplication.setApplicationVersion(APP_VERSION)
if is_frozen():
helper_name = (
@ -580,33 +917,48 @@ def configure_qt_paths() -> None:
]
for candidate in candidates:
if candidate.exists():
if candidate.exists() and candidate.is_file():
os.environ["QTWEBENGINEPROCESS_PATH"] = str(candidate)
logger.info("Using QtWebEngineProcess: %s", candidate)
break
else:
logger.warning(
"QtWebEngineProcess helper not found in expected locations"
)
logger.warning("QtWebEngineProcess helper not found in expected locations")
else:
os.environ.pop("QTWEBENGINEPROCESS_PATH", None)
except Exception:
logger.debug("Failed to configure Qt paths/environment", exc_info=True)
def harden_runtime_paths() -> None:
try:
harden_directory_permissions(
[
(DATA_DIR, 0o700),
(LOG_DIR, 0o700),
(RUNTIME_DIR, 0o700),
(CACHE_DIR, 0o700),
(STORAGE_DIR, 0o700),
]
)
logger.info("Hardened directory permissions for app data paths")
except Exception:
logger.exception("Failed to harden runtime paths")
def main():
instance = None
activation_server = None
try:
validate_configuration()
if is_root():
logger.error("Refusing to run as root")
print("Refusing to run as root.")
sys.exit(1)
sys.excepthook = excepthook
configure_qt_paths()
harden_runtime_paths()
QApplication.setApplicationName(APP_NAME)
QApplication.setOrganizationName(APP_NAME)
@ -617,6 +969,10 @@ def main():
instance = SingleInstance(LOCK_FILE)
if not instance.acquire():
logger.warning("Another instance is already running")
activated = signal_existing_instance(IPC_SOCKET_NAME)
if activated:
logger.info("Signaled existing instance to activate window")
else:
QMessageBox.warning(
None,
APP_NAME,
@ -635,13 +991,30 @@ def main():
logger.debug("Failed to query Qt standard paths", exc_info=True)
window = MainWindow()
activation_server = ActivationServer(IPC_SOCKET_NAME, window.activate_and_raise)
activation_server.start()
try:
app.aboutToQuit.connect(
lambda: activation_server.close() if activation_server else None
)
except Exception:
logger.debug("Could not connect activation server cleanup", exc_info=True)
try:
app.aboutToQuit.connect(lambda: instance.release() if instance else None)
except Exception:
logger.debug("Could not connect lock cleanup", exc_info=True)
window.show()
logger.info(
"Application started platform=%s python=%s frozen=%s resource_dir=%s base_dir=%s data_dir=%s",
"Application started platform=%s python=%s frozen=%s version=%s resource_dir=%s base_dir=%s data_dir=%s",
platform.platform(),
sys.version.split()[0],
is_frozen(),
APP_VERSION,
resource_dir(),
base_dir(),
DATA_DIR,
@ -649,6 +1022,9 @@ def main():
rc = app.exec()
if activation_server:
activation_server.close()
if instance:
instance.release()
@ -660,10 +1036,14 @@ def main():
try:
if QApplication.instance() is not None:
QMessageBox.critical(None, APP_NAME, f"Fatal startup error:\n\n{exc}")
else:
print(f"Fatal startup error: {exc}", file=sys.stderr)
except Exception:
print(f"Fatal startup error: {exc}", file=sys.stderr)
pass
try:
if activation_server:
activation_server.close()
except Exception:
pass
try:
if instance:

View File

@ -19,11 +19,11 @@ services:
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-}
CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-}
PRIVATE_ACCESS_KEY: ${PRIVATE_ACCESS_KEY:-}
# VPS2 helper integration
CALLS_BASE: ${CALLS_BASE:-https://calls.backend-3.com}
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET:-}
CALL_TOKEN_TTL_SECONDS: ${CALL_TOKEN_TTL_SECONDS:-60}
# set CALLS_BASE to turn server config
# helper/.env
volumes:
- chat_data:/app/data
@ -43,8 +43,10 @@ services:
retries: 3
start_period: 20s
labels:
caddy: 300.backend-3.com
caddy: ${DOMAIN_URL:-angel.33.backend-3.com}
caddy.reverse_proxy: "{{upstreams 5000}}"
# set DOMAIN_URL to domain
# .env
volumes:
chat_data:

View File

@ -1,34 +1,148 @@
themes
copy + paste themes in settings > custom css
<pre>
lowercase classic
monospace
```
ios amoled black / soft glass / lowercase
* {
text-transform: lowercase;
font-family: "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
font-size: 95.7%;
font-weight: 445 !important;
letter-spacing: -0.012em;
-webkit-font-smoothing: antialiased;
color: rgba(245, 240, 255, 0.93);
background: transparent !important;
}
div {
font-size: 94.5%;
font-weight: 450 !important;
line-height: 1.23rem;
text-shadow: 0 0 20px rgba(255, 230, 245, 0.33);
backdrop-filter: blur(12px) saturate(0.33) brightness(0.33);
-webkit-backdrop-filter: blur(12px) saturate(1) brightness(0.99);
background: rgba(20, 23, 20, 0) !important;
}
small {
opacity: 0.86;
}
```
```
ios amoled black / lowercase
* {
text-transform: lowercase;
font-family: "SF Pro Text", system-ui, "Helvetica Neue", sans-serif;
font-size: 97%;
font-weight: 440 !important;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
text-rendering: geometricprecision;
color: rgba(255, 255, 255, 0.94);
background: #000 !important;
}
div {
font-size: 96%;
font-weight: 500 !important;
line-height: 1.26rem;
text-shadow: 0px 0px 18px rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px) saturate(1.01) brightness(0.82);
-webkit-backdrop-filter: blur(4px) saturate(1.01) brightness(0.82);
background: rgba(0, 0, 0, 0.88) !important;
}
small {
opacity: 0.88;
}
```
```
ios / lowercase
* {
text-transform: lowercase;
font-family: "SF Pro Text", system-ui, "Helvetica Neue", sans-serif;
font-size: 97%;
font-weight: 440 !important;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
text-rendering: geometricprecision;
}
div {
font-size: 96%;
font-weight: 500 !important;
line-height: 1.26rem;
text-shadow: 0px 0px 22px rgba(255, 255, 255, 0.16);
backdrop-filter: blur(6px) saturate(1.02);
-webkit-backdrop-filter: blur(6px) saturate(1.02);
}
small {
opacity: 0.9;
}
```
```
classic / monospace / lowercase
* {
font-weight: 500 !important;
text-transform: lowercase;
font-family: monospace;
font-size: 94%;
letter-spacing: -0.02em;
font-size: 96.7%;
}
div {
font-size: 95%;
font-weight: 600 !important;
font-size: 96%;
font-weight: 500 !important;
text-shadow: 0px 3px 33px #fff;
}
* {
font-weight: 400 !important;
}
</pre>
<pre>
lowercase modern
helvetica
```
```
wood / lowercase
* {
text-transform: lowercase;
font-family: "SF Pro Text", system-ui, "Helvetica Neue", sans-serif;
font-size: 96%;
font-weight: 435 !important;
letter-spacing: -0.01em;
-webkit-font-smoothing: antialiased;
color: rgba(255, 245, 230, 0.92);
background: TRANSPARENT !important;
}
div {
font-size: 96%;
font-weight: 500 !important;
line-height: 1.27rem;
text-shadow: 0 0 12px rgba(255, 220, 180, 0.05);
backdrop-filter: blur(5px) saturate(0.96) brightness(0.88);
-webkit-backdrop-filter: blur(5px) saturate(1.0) brightness(1.0);
background: rgba(18, 14, 11, 0.9) !important;
}
small {
opacity: 0.81;
}
```
```
classic / helvetica / lowercase
* {
font-weight: 500 !important;
text-transform: lowercase;
font-family: monospace;
font-size: 100%;
font-family: "SF SANS", HELVETICA, SYSTEM-UI, MONOSPACE;
font-family: "SF Sans", helvetica, system-ui, monospace;
}
div {
font-size: 96%;
@ -36,4 +150,5 @@ div {
line-height: 1.2rem;
text-shadow: 0px 0px 75px #fff;
}
</pre>
```

View File

@ -1,6 +1,7 @@
<pre>
[themes](CSS.md)
v1.0
<pre>
ver. 1.6
- sign in, register, logout, session check
- private access key for sign up
@ -23,16 +24,14 @@ v1.0
- add multiple attachments per message
- image, audio, video, and file previews
- attachment downloads
- unread message counter
- read receipts in settings
- jump to latest
- add teams
- team channels
- team member management
- team invite links
- team pages
- create, edit, read, and delete team pages
- page types: project, document, update, resource
- page filters and visibility settings
@ -45,7 +44,8 @@ v1.0
- join date
- user settings
- dark and light theme
- dark and light themes
- presence controls
- custom css
- accent color
- username color
@ -65,11 +65,15 @@ v1.0
- persistent database and uploads
- rate limiting
- calls
- mute, leave, end call controls
- group calls for rooms and channels
*needs testing*
- dm calling api
- group voice calls for rooms and channels
- dm calls
- call invites
- mute and hangup controls
- reconnect handling
- desktop client
<pre>

File diff suppressed because it is too large Load Diff

18
helper/.env.example Normal file
View File

@ -0,0 +1,18 @@
CALL_HELPER_SHARED_SECRET=replace-with-same-secret-as-main-app
PUBLIC_BASE=https://calls.backend-3.com
HELPER_LOG_LEVEL=info
HELPER_DEFAULT_ROUTE_MODE=direct
ENABLE_OVERLAY_METADATA=1
OVERLAY_YGG_ENABLED=0
OVERLAY_YGG_BRIDGE_WSS=
OVERLAY_YGG_BRIDGE_LABEL=ygg-bridge
OVERLAY_I2P_ENABLED=0
OVERLAY_I2P_BRIDGE_HTTPS=
OVERLAY_I2P_BRIDGE_LABEL=i2p-bridge
RELAY_ENABLED=1
RELAY_WSS=

14
helper/Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY server.js ./
COPY scripts ./scripts
ENV PORT=8080
EXPOSE 8080
CMD ["npm", "start"]

38
helper/docker-compose.yml Normal file
View File

@ -0,0 +1,38 @@
services:
calls-helper:
build:
context: .
dockerfile: Dockerfile
container_name: calls-helper
env_file:
- .env
environment:
PORT: 8080
HOST: 0.0.0.0
PUBLIC_BASE: ${PUBLIC_BASE:-https://calls.backend-3.com}
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET}
HELPER_LOG_LEVEL: ${HELPER_LOG_LEVEL:-info}
HELPER_DEFAULT_ROUTE_MODE: ${HELPER_DEFAULT_ROUTE_MODE:-direct}
ENABLE_OVERLAY_METADATA: ${ENABLE_OVERLAY_METADATA:-1}
OVERLAY_YGG_ENABLED: ${OVERLAY_YGG_ENABLED:-0}
OVERLAY_YGG_BRIDGE_WSS: ${OVERLAY_YGG_BRIDGE_WSS:-}
OVERLAY_YGG_BRIDGE_LABEL: ${OVERLAY_YGG_BRIDGE_LABEL:-ygg-bridge}
OVERLAY_I2P_ENABLED: ${OVERLAY_I2P_ENABLED:-0}
OVERLAY_I2P_BRIDGE_HTTPS: ${OVERLAY_I2P_BRIDGE_HTTPS:-}
OVERLAY_I2P_BRIDGE_LABEL: ${OVERLAY_I2P_BRIDGE_LABEL:-i2p-bridge}
RELAY_ENABLED: ${RELAY_ENABLED:-1}
RELAY_WSS: ${RELAY_WSS:-}
restart: unless-stopped
networks:
- privatenet
labels:
caddy: calls.backend-3.com
caddy.reverse_proxy: "{{upstreams 8080}}"
networks:
privatenet:
external: true

14
helper/package.json Normal file
View File

@ -0,0 +1,14 @@
{
"name": "calls-helper",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js",
"gen:secret": "node scripts/generate-secret.js"
},
"dependencies": {
"express": "^4.21.0",
"ws": "^8.18.0"
}
}

View File

@ -0,0 +1,4 @@
import crypto from "crypto";
const value = crypto.randomBytes(48).toString("base64url");
console.log(value);

761
helper/server.js Normal file
View File

@ -0,0 +1,761 @@
import crypto from "crypto";
import express from "express";
import http from "http";
import { WebSocketServer } from "ws";
const TURN_URLS = String(process.env.TURN_URLS || "")
.split(",")
.map(value => value.trim())
.filter(Boolean);
const TURN_USERNAME = String(process.env.TURN_USERNAME || "").trim();
const TURN_PASSWORD = String(process.env.TURN_PASSWORD || "").trim();
const PORT = Number(process.env.PORT || 8080);
const HOST = process.env.HOST || "0.0.0.0";
const CALL_HELPER_SHARED_SECRET = String(
process.env.CALL_HELPER_SHARED_SECRET || ""
).trim();
const PUBLIC_BASE = String(
process.env.PUBLIC_BASE || `http://127.0.0.1:${PORT}`
).replace(/\/+$/, "");
const HELPER_LOG_LEVEL = String(process.env.HELPER_LOG_LEVEL || "info").toLowerCase();
const HELPER_DEFAULT_ROUTE_MODE = String(
process.env.HELPER_DEFAULT_ROUTE_MODE || "direct"
).toLowerCase();
const ENABLE_OVERLAY_METADATA = String(
process.env.ENABLE_OVERLAY_METADATA || "1"
) === "1";
const OVERLAY_YGG_ENABLED = String(
process.env.OVERLAY_YGG_ENABLED || "0"
) === "1";
const OVERLAY_YGG_BRIDGE_WSS = String(
process.env.OVERLAY_YGG_BRIDGE_WSS || ""
).trim();
const OVERLAY_YGG_BRIDGE_LABEL = String(
process.env.OVERLAY_YGG_BRIDGE_LABEL || "ygg-bridge"
).trim();
const OVERLAY_I2P_ENABLED = String(
process.env.OVERLAY_I2P_ENABLED || "0"
) === "1";
const OVERLAY_I2P_BRIDGE_HTTPS = String(
process.env.OVERLAY_I2P_BRIDGE_HTTPS || ""
).trim();
const OVERLAY_I2P_BRIDGE_LABEL = String(
process.env.OVERLAY_I2P_BRIDGE_LABEL || "i2p-bridge"
).trim();
const RELAY_ENABLED = String(process.env.RELAY_ENABLED || "1") === "1";
const RELAY_WSS = String(process.env.RELAY_WSS || "").trim();
const CORS_ALLOWED_ORIGINS = String(
process.env.CORS_ALLOWED_ORIGINS || "https://angel.33.backend-3.com"
)
.split(",")
.map(value => value.trim())
.filter(Boolean);
const allowedOrigins = new Set(CORS_ALLOWED_ORIGINS);
if (!CALL_HELPER_SHARED_SECRET) {
console.error("missing CALL_HELPER_SHARED_SECRET");
process.exit(1);
}
const app = express();
app.use(express.json({ limit: "64kb" }));
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });
/**
* in-memory state
*
* calls:
* callId -> {
* callId,
* room,
* routeMode,
* participants: Map(peerId -> participant),
* createdAt,
* updatedAt
* }
*
* tickets:
* ticket -> {
* ticket,
* callId,
* peerId,
* sub,
* username,
* role,
* room,
* exp,
* routeProfile
* }
*/
const calls = new Map();
const tickets = new Map();
const recentLogs = [];
const MAX_LOGS = 500;
function nowMs() {
return Date.now();
}
function nowSeconds() {
return Math.floor(Date.now() / 1000);
}
function isoNow() {
return new Date().toISOString();
}
function log(level, message, meta = {}) {
const allowed = ["debug", "info", "warn", "error"];
const currentIdx = Math.max(0, allowed.indexOf(HELPER_LOG_LEVEL));
const entryIdx = Math.max(0, allowed.indexOf(level));
const item = {
ts: isoNow(),
level,
message,
meta
};
recentLogs.push(item);
if (recentLogs.length > MAX_LOGS) {
recentLogs.shift();
}
if (entryIdx >= currentIdx) {
const method =
level === "error" ? "error" :
level === "warn" ? "warn" :
"log";
console[method](`[${item.ts}] [${level}] ${message}`, meta);
}
}
function isAllowedOrigin(origin) {
if (!origin) return false;
return allowedOrigins.has(origin);
}
function applyCors(req, res) {
const origin = String(req.headers.origin || "").trim();
if (origin && isAllowedOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
}
}
app.use((req, res, next) => {
applyCors(req, res);
if (req.method === "OPTIONS") {
return res.status(204).end();
}
next();
});
function b64urlDecode(value) {
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + pad;
return Buffer.from(normalized, "base64");
}
function verifySignedToken(token) {
if (!token || typeof token !== "string" || !token.includes(".")) {
throw new Error("invalid token format");
}
const [payloadPart, sigPart] = token.split(".", 2);
const payloadBytes = b64urlDecode(payloadPart);
const sigBytes = b64urlDecode(sigPart);
const expected = crypto
.createHmac("sha256", CALL_HELPER_SHARED_SECRET)
.update(payloadBytes)
.digest();
if (
sigBytes.length !== expected.length ||
!crypto.timingSafeEqual(sigBytes, expected)
) {
throw new Error("invalid token signature");
}
let payload;
try {
payload = JSON.parse(payloadBytes.toString("utf8"));
} catch {
throw new Error("invalid token payload json");
}
if (!payload || typeof payload !== "object") {
throw new Error("invalid token payload");
}
if (!payload.exp || Number(payload.exp) <= nowSeconds()) {
throw new Error("token expired");
}
if (!payload.can_call) {
throw new Error("token not authorized for calls");
}
if (!payload.call_id || !payload.sub) {
throw new Error("token missing required fields");
}
return payload;
}
function randomId(prefix = "") {
return `${prefix}${crypto.randomBytes(8).toString("hex")}`;
}
function chooseRouteMode(payload) {
const requested = String(payload.route_mode || "").trim().toLowerCase();
if (requested === "overlay_ygg" && OVERLAY_YGG_ENABLED) {
return "overlay_ygg";
}
if (requested === "overlay_i2p" && OVERLAY_I2P_ENABLED) {
return "overlay_i2p";
}
if (HELPER_DEFAULT_ROUTE_MODE === "overlay_ygg" && OVERLAY_YGG_ENABLED) {
return "overlay_ygg";
}
if (HELPER_DEFAULT_ROUTE_MODE === "overlay_i2p" && OVERLAY_I2P_ENABLED) {
return "overlay_i2p";
}
return "direct";
}
function buildRouteCandidates(callId) {
const routes = [];
routes.push({
kind: "direct",
target: callId,
priority: 100,
available: true,
label: "direct"
});
if (ENABLE_OVERLAY_METADATA && OVERLAY_YGG_ENABLED) {
routes.push({
kind: "overlay_ygg",
target: OVERLAY_YGG_BRIDGE_WSS || PUBLIC_BASE.replace(/^http/, "ws"),
priority: 90,
available: Boolean(OVERLAY_YGG_BRIDGE_WSS),
label: OVERLAY_YGG_BRIDGE_LABEL
});
}
if (ENABLE_OVERLAY_METADATA && OVERLAY_I2P_ENABLED) {
routes.push({
kind: "overlay_i2p",
target: OVERLAY_I2P_BRIDGE_HTTPS || PUBLIC_BASE,
priority: 40,
available: Boolean(OVERLAY_I2P_BRIDGE_HTTPS),
label: OVERLAY_I2P_BRIDGE_LABEL
});
}
if (RELAY_ENABLED) {
routes.push({
kind: "relay",
target: RELAY_WSS || PUBLIC_BASE.replace(/^http/, "ws"),
priority: 20,
available: true,
label: "relay"
});
}
return routes;
}
function buildTransportPolicy(routeMode) {
const order = [];
if (routeMode === "overlay_ygg") {
order.push("overlay_ygg", "direct", "relay");
} else if (routeMode === "overlay_i2p") {
order.push("direct", "relay");
} else {
order.push("direct", "relay");
}
return {
preferred_order: order,
allow_relay_fallback: RELAY_ENABLED,
private_service_transport: OVERLAY_I2P_ENABLED ? "overlay_i2p" : null
};
}
function getIceServers() {
const servers = [
{
urls: ["stun:stun.l.google.com:19302"]
}
];
if (TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD) {
servers.push({
urls: TURN_URLS,
username: TURN_USERNAME,
credential: TURN_PASSWORD
});
}
return servers;
}
function getOrCreateCall(callId, room, routeMode = "direct") {
let call = calls.get(callId);
if (!call) {
call = {
callId,
room,
routeMode,
participants: new Map(),
createdAt: nowMs(),
updatedAt: nowMs()
};
calls.set(callId, call);
log("info", "created call", { callId, room, routeMode });
}
return call;
}
function buildWsUrl(ticket) {
const publicBase = PUBLIC_BASE.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
return `${publicBase}/ws?ticket=${encodeURIComponent(ticket)}`;
}
function sendJson(ws, payload) {
if (!ws || ws.readyState !== ws.OPEN) return;
ws.send(JSON.stringify(payload));
}
function broadcast(call, payload, exceptPeerId = null) {
for (const [peerId, participant] of call.participants.entries()) {
if (exceptPeerId && peerId === exceptPeerId) continue;
sendJson(participant.ws, payload);
}
}
function callSnapshot(call) {
return {
call_id: call.callId,
room: call.room,
route_mode: call.routeMode,
participant_count: call.participants.size,
created_at: new Date(call.createdAt).toISOString(),
updated_at: new Date(call.updatedAt).toISOString(),
participants: [...call.participants.values()].map(participant => ({
peer_id: participant.peerId,
sub: participant.sub,
username: participant.username,
role: participant.role,
route_mode: participant.routeMode,
joined_at: new Date(participant.joinedAt).toISOString()
}))
};
}
function statusSnapshot() {
return {
ok: true,
ts: isoNow(),
public_base: PUBLIC_BASE,
route_defaults: {
default_route_mode: HELPER_DEFAULT_ROUTE_MODE,
overlay_metadata_enabled: ENABLE_OVERLAY_METADATA,
relay_enabled: RELAY_ENABLED
},
overlays: {
ygg: {
enabled: OVERLAY_YGG_ENABLED,
bridge_label: OVERLAY_YGG_BRIDGE_LABEL,
bridge_wss: OVERLAY_YGG_BRIDGE_WSS || null
},
i2p: {
enabled: OVERLAY_I2P_ENABLED,
bridge_label: OVERLAY_I2P_BRIDGE_LABEL,
bridge_https: OVERLAY_I2P_BRIDGE_HTTPS || null
}
},
turn: {
enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD),
urls: TURN_URLS
},
cors: {
allowed_origins: [...allowedOrigins]
},
counts: {
active_calls: calls.size,
active_tickets: tickets.size
}
};
}
function removeParticipant(callId, peerId, notify = true) {
const call = calls.get(callId);
if (!call) return;
const participant = call.participants.get(peerId);
if (!participant) return;
call.participants.delete(peerId);
call.updatedAt = nowMs();
log("info", "participant removed", {
callId,
peerId,
username: participant.username
});
if (notify) {
broadcast(call, {
type: "participant-left",
participantId: peerId,
roomId: callId
});
}
if (call.participants.size === 0) {
calls.delete(callId);
log("info", "call removed because empty", { callId });
}
}
app.get("/healthz", (req, res) => {
applyCors(req, res);
res.json(statusSnapshot());
});
app.get("/status", (req, res) => {
applyCors(req, res);
res.json({
...statusSnapshot(),
calls: [...calls.values()].map(callSnapshot)
});
});
app.get("/logs", (req, res) => {
applyCors(req, res);
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)));
res.json({
ok: true,
logs: recentLogs.slice(-limit)
});
});
app.post("/api/call/session", (req, res) => {
applyCors(req, res);
try {
const token = req.body?.token;
const payload = verifySignedToken(token);
const callId = String(payload.call_id);
const room = String(payload.room || callId);
const peerId = randomId("peer_");
const ticket = randomId("ticket_");
const routeMode = chooseRouteMode(payload);
const routeCandidates = buildRouteCandidates(callId);
const transportPolicy = buildTransportPolicy(routeMode);
getOrCreateCall(callId, room, routeMode);
tickets.set(ticket, {
ticket,
callId,
peerId,
sub: String(payload.sub),
username: String(payload.username || peerId),
role: String(payload.role || "user"),
room,
exp: Number(payload.exp),
routeProfile: {
routeMode,
routeCandidates,
transportPolicy
}
});
log("info", "created call session ticket", {
callId,
peerId,
username: payload.username,
routeMode,
origin: req.headers.origin || null,
turnEnabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD)
});
return res.json({
ok: true,
call_id: callId,
peer_id: peerId,
ws_url: buildWsUrl(ticket),
iceServers: getIceServers(),
transport_policy: transportPolicy,
route_candidates: routeCandidates
});
} catch (error) {
log("warn", "call session rejected", {
error: error.message,
origin: req.headers.origin || null
});
return res.status(401).json({
ok: false,
error: error.message || "invalid call token"
});
}
});
server.on("upgrade", (req, socket, head) => {
const origin = String(req.headers.origin || "").trim();
if (origin && !isAllowedOrigin(origin)) {
log("warn", "websocket upgrade rejected by origin", { origin });
socket.destroy();
return;
}
const url = new URL(req.url, `http://${req.headers.host}`);
if (url.pathname !== "/ws") {
socket.destroy();
return;
}
const ticket = url.searchParams.get("ticket");
const ticketData = ticket ? tickets.get(ticket) : null;
if (!ticketData || ticketData.exp <= nowSeconds()) {
log("warn", "websocket upgrade rejected by invalid/expired ticket", {
origin: origin || null
});
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, ws => {
ws._ticketData = ticketData;
wss.emit("connection", ws, req);
});
});
wss.on("connection", ws => {
const ticketData = ws._ticketData;
if (!ticketData) {
ws.close();
return;
}
tickets.delete(ticketData.ticket);
const call = getOrCreateCall(
ticketData.callId,
ticketData.room,
ticketData.routeProfile?.routeMode || "direct"
);
const participant = {
peerId: ticketData.peerId,
sub: ticketData.sub,
username: ticketData.username,
role: ticketData.role,
room: ticketData.room,
routeMode: ticketData.routeProfile?.routeMode || "direct",
routeCandidates: ticketData.routeProfile?.routeCandidates || [],
transportPolicy: ticketData.routeProfile?.transportPolicy || {},
ws,
joinedAt: nowMs()
};
call.participants.set(ticketData.peerId, participant);
call.updatedAt = nowMs();
log("info", "participant joined websocket", {
callId: call.callId,
peerId: participant.peerId,
username: participant.username,
routeMode: participant.routeMode
});
sendJson(ws, {
type: "joined",
participantId: ticketData.peerId,
roomId: call.callId,
transportPolicy: participant.transportPolicy,
routeCandidates: participant.routeCandidates
});
for (const [peerId, existing] of call.participants.entries()) {
if (peerId === ticketData.peerId) continue;
sendJson(ws, {
type: "participant-joined",
participantId: peerId,
roomId: call.callId,
username: existing.username,
routeMode: existing.routeMode
});
}
broadcast(
call,
{
type: "participant-joined",
participantId: ticketData.peerId,
roomId: call.callId,
username: participant.username,
routeMode: participant.routeMode
},
ticketData.peerId
);
ws.on("message", raw => {
let msg;
try {
msg = JSON.parse(String(raw));
} catch {
log("warn", "invalid ws json from peer", {
callId: call.callId,
peerId: participant.peerId
});
return;
}
const senderPeerId = ticketData.peerId;
call.updatedAt = nowMs();
if (!msg || typeof msg !== "object" || !msg.type) {
return;
}
if (msg.type === "hangup") {
log("info", "peer requested hangup", {
callId: call.callId,
peerId: senderPeerId
});
removeParticipant(call.callId, senderPeerId, true);
try {
ws.close();
} catch {}
return;
}
if (msg.type === "offer" || msg.type === "answer") {
const to = String(msg.to || "").trim();
if (!to) return;
const target = call.participants.get(to);
if (!target) {
log("warn", "signal target missing", {
callId: call.callId,
from: senderPeerId,
to,
type: msg.type
});
return;
}
sendJson(target.ws, {
type: msg.type,
from: senderPeerId,
sdp: msg.sdp,
username: participant.username,
roomId: call.callId,
routeMode: participant.routeMode
});
return;
}
if (msg.type === "ice") {
const to = String(msg.to || "").trim();
if (!to) return;
const target = call.participants.get(to);
if (!target) {
log("warn", "ice target missing", {
callId: call.callId,
from: senderPeerId,
to
});
return;
}
sendJson(target.ws, {
type: "ice",
from: senderPeerId,
candidate: msg.candidate,
sdpMid: msg.sdpMid,
sdpMLineIndex: msg.sdpMLineIndex,
username: participant.username,
roomId: call.callId,
routeMode: participant.routeMode
});
return;
}
});
ws.on("close", () => {
log("info", "websocket closed", {
callId: call.callId,
peerId: participant.peerId
});
removeParticipant(call.callId, ticketData.peerId, true);
});
ws.on("error", error => {
log("warn", "websocket error", {
callId: call.callId,
peerId: participant.peerId,
error: error?.message || "unknown"
});
removeParticipant(call.callId, ticketData.peerId, true);
});
});
server.listen(PORT, HOST, () => {
log("info", "calls helper listening", {
host: HOST,
port: PORT,
publicBase: PUBLIC_BASE,
overlays: {
ygg: OVERLAY_YGG_ENABLED,
i2p: OVERLAY_I2P_ENABLED
},
turn: {
enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD),
urls: TURN_URLS
},
corsAllowedOrigins: [...allowedOrigins]
});
});

642
models.py Normal file
View File

@ -0,0 +1,642 @@
import json
import re
import time
from typing import Optional
from config import utc_now
from db import get_db
from auth import user_payload
ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$")
MESSAGE_SELECT = """
SELECT
m.id,
m.room_id,
m.channel_id,
m.direct_conversation_id,
m.user_id,
m.username,
m.text,
m.created_at,
m.attachment_id,
m.parent_message_id,
(
SELECT COUNT(*)
FROM messages AS thread_reply
WHERE thread_reply.parent_message_id = m.id
) AS thread_reply_count,
a.original_name AS attachment_original_name,
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
FROM messages AS m
JOIN users AS u
ON u.id = m.user_id
LEFT JOIN user_settings AS us
ON us.user_id = u.id
LEFT JOIN attachments AS a
ON a.id = m.attachment_id
"""
def team_page_select() -> str:
has_parent_page = False
try:
with get_db() as db:
rows = db.execute("PRAGMA table_info(pages)").fetchall()
has_parent_page = any(row["name"] == "parent_page_id" for row in rows)
except Exception:
has_parent_page = False
if has_parent_page:
return """
SELECT
p.id,
p.team_id,
p.channel_id,
p.parent_page_id,
parent.slug AS parent_page_slug,
parent.title AS parent_page_title,
p.slug,
p.title,
p.body,
p.visibility,
p.page_type,
p.created_by_user_id,
p.updated_by_user_id,
p.created_at,
p.updated_at,
p.is_active,
t.slug AS team_slug,
t.name AS team_name,
c.slug AS channel_slug,
c.name AS channel_name
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
"""
return """
SELECT
p.id,
p.team_id,
p.channel_id,
NULL AS parent_page_id,
NULL AS parent_page_slug,
NULL AS parent_page_title,
p.slug,
p.title,
p.body,
p.visibility,
p.page_type,
p.created_by_user_id,
p.updated_by_user_id,
p.created_at,
p.updated_at,
p.is_active,
t.slug AS team_slug,
t.name AS team_name,
c.slug AS channel_slug,
c.name AS channel_name
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
"""
def team_slug_from_name(name: str) -> str:
return re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")[:48].strip("-")
def page_slug_from_title(title: str) -> str:
return re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")[:48].strip("-")
def room_by_id(room_id: int):
with get_db() as db:
return db.execute(
"""
SELECT
id,
slug,
name,
description,
visibility,
created_by_user_id,
is_active
FROM rooms
WHERE id = ?
AND is_active = 1
""",
(room_id,),
).fetchone()
def get_room_by_slug(slug: str):
with get_db() as db:
return db.execute(
"""
SELECT
id,
slug,
name,
description,
visibility,
created_by_user_id,
is_active
FROM rooms
WHERE slug = ?
AND is_active = 1
""",
(slug,),
).fetchone()
def room_member(room_id: int, user_id: int) -> bool:
with get_db() as db:
row = db.execute(
"""
SELECT 1
FROM room_members
WHERE room_id = ?
AND user_id = ?
""",
(room_id, user_id),
).fetchone()
return bool(row)
def room_owner(room_id: int, user_id: int) -> bool:
with get_db() as db:
row = db.execute(
"""
SELECT 1
FROM room_members
WHERE room_id = ?
AND user_id = ?
AND membership_role = 'owner'
""",
(room_id, user_id),
).fetchone()
return bool(row)
def can_access_room(room, user: Optional[dict]) -> bool:
if room["visibility"] == "public":
return True
if not user:
return False
if user["role"] in {"moderator", "admin"}:
return True
return room_member(room["id"], user["id"])
def can_write_room(room, user: Optional[dict]) -> bool:
if not user or user["status"] != "active":
return False
return can_access_room(room, user)
def can_manage_room(room, user: Optional[dict]) -> bool:
if not user:
return False
if user["role"] == "admin":
return True
return room_owner(room["id"], user["id"])
def room_payload(room, user: Optional[dict]) -> dict:
return {
"id": room["id"],
"slug": room["slug"],
"name": room["name"],
"description": room["description"],
"visibility": room["visibility"],
"can_read": can_access_room(room, user),
"can_write": can_write_room(room, user),
"can_manage": can_manage_room(room, user),
}
def get_team_by_slug(slug: str):
with get_db() as db:
return db.execute(
"""
SELECT
id,
slug,
name,
description,
created_by_user_id,
created_at,
is_active
FROM teams
WHERE slug = ?
AND is_active = 1
""",
(slug,),
).fetchone()
def get_channel_by_id(channel_id: int):
with get_db() as db:
return db.execute(
"""
SELECT
c.id,
c.team_id,
c.legacy_room_id,
c.slug,
c.name,
c.description,
c.created_by_user_id,
c.created_at,
c.is_active,
t.slug AS team_slug,
t.name AS team_name,
t.description AS team_description,
t.is_active AS team_is_active
FROM channels AS c
JOIN teams AS t
ON t.id = c.team_id
WHERE c.id = ?
AND c.is_active = 1
AND t.is_active = 1
""",
(channel_id,),
).fetchone()
def get_channel_by_slug(team_slug: str, channel_slug: str):
with get_db() as db:
return db.execute(
"""
SELECT
c.id,
c.team_id,
c.legacy_room_id,
c.slug,
c.name,
c.description,
c.created_by_user_id,
c.created_at,
c.is_active,
t.slug AS team_slug,
t.name AS team_name,
t.description AS team_description,
t.is_active AS team_is_active
FROM channels AS c
JOIN teams AS t
ON t.id = c.team_id
WHERE t.slug = ?
AND c.slug = ?
AND t.is_active = 1
AND c.is_active = 1
""",
(team_slug, channel_slug),
).fetchone()
def get_channel_by_legacy_room_id(room_id: int):
with get_db() as db:
return db.execute(
"""
SELECT
c.id,
c.team_id,
c.legacy_room_id,
c.slug,
c.name,
c.description,
c.created_by_user_id,
c.created_at,
c.is_active,
t.slug AS team_slug,
t.name AS team_name,
t.description AS team_description,
t.is_active AS team_is_active
FROM channels AS c
JOIN teams AS t
ON t.id = c.team_id
WHERE c.legacy_room_id = ?
AND c.is_active = 1
AND t.is_active = 1
""",
(room_id,),
).fetchone()
def team_member_role(team_id: int, user_id: int) -> Optional[str]:
with get_db() as db:
row = db.execute(
"""
SELECT membership_role
FROM team_members
WHERE team_id = ?
AND user_id = ?
""",
(team_id, user_id),
).fetchone()
return row["membership_role"] if row else None
def is_team_member(team_id: int, user_id: int) -> bool:
return team_member_role(team_id, user_id) is not None
def is_team_admin(team_id: int, user: Optional[dict]) -> bool:
if not user:
return False
if user["role"] == "admin":
return True
return team_member_role(team_id, user["id"]) in {"owner", "admin"}
def can_edit_team(team_id: int, user: Optional[dict]) -> bool:
return is_team_admin(team_id, user)
def can_access_team(team, user: Optional[dict]) -> bool:
if not team["is_active"]:
return False
if user and user["role"] in {"moderator", "admin"}:
return True
return bool(user and is_team_member(team["id"], user["id"]))
def legacy_room_for_channel(channel):
if not channel["legacy_room_id"]:
return None
return room_by_id(channel["legacy_room_id"])
def can_access_channel(channel, user: Optional[dict]) -> bool:
if user and user["role"] in {"moderator", "admin"}:
return True
legacy_room = legacy_room_for_channel(channel)
if legacy_room:
return can_access_room(legacy_room, user)
return bool(user and is_team_member(channel["team_id"], user["id"]))
def can_write_channel(channel, user: Optional[dict]) -> bool:
return bool(user and user["status"] == "active" and can_access_channel(channel, user))
def team_payload(team, user: Optional[dict]) -> dict:
membership_role = team_member_role(team["id"], user["id"]) if user else None
return {
"id": team["id"],
"slug": team["slug"],
"name": team["name"],
"description": team["description"],
"membership_role": membership_role,
"can_read": can_access_team(team, user),
"can_manage": is_team_admin(team["id"], user),
}
def channel_payload(channel, user: Optional[dict]) -> dict:
return {
"id": channel["id"],
"team_id": channel["team_id"],
"team_slug": channel["team_slug"],
"team_name": channel["team_name"],
"legacy_room_id": channel["legacy_room_id"],
"slug": channel["slug"],
"name": channel["name"],
"description": channel["description"],
"can_read": can_access_channel(channel, user),
"can_write": can_write_channel(channel, user),
"can_manage": is_team_admin(channel["team_id"], user),
}
def get_dm_for_user(dm_id: int, user_id: int):
with get_db() as db:
row = db.execute(
"""
SELECT
dc.id,
dc.created_at
FROM direct_conversations AS dc
JOIN direct_conversation_members AS dcm
ON dcm.direct_conversation_id = dc.id
WHERE dc.id = ?
AND dcm.user_id = ?
""",
(dm_id, user_id),
).fetchone()
return row
def get_dm_other_user(dm_id: int, user_id: int):
with get_db() as db:
row = db.execute(
"""
SELECT
u.id,
u.username,
u.role,
up.display_name,
up.status_message,
us.name_color,
us.presence_mode
FROM direct_conversation_members AS dcm
JOIN users AS u
ON u.id = dcm.user_id
LEFT JOIN user_profiles AS up
ON up.user_id = u.id
LEFT JOIN user_settings AS us
ON us.user_id = u.id
WHERE dcm.direct_conversation_id = ?
AND dcm.user_id != ?
LIMIT 1
""",
(dm_id, user_id),
).fetchone()
return row
def validate_dm_access(dm_id: int, user: Optional[dict]) -> bool:
if not user:
return False
return bool(get_dm_for_user(dm_id, user["id"]))
def dm_payload(dm_id: int, user_id: int, resolve_name_color) -> dict:
dm = get_dm_for_user(dm_id, user_id)
other = get_dm_other_user(dm_id, user_id)
return {
"id": dm["id"],
"created_at": dm["created_at"],
"other_user": {
"id": other["id"],
"username": other["username"],
"display_name": other["display_name"] or other["username"],
"role": other["role"],
"name_color": other["name_color"],
"resolved_name_color": resolve_name_color(other["role"], other["name_color"]),
"status_message": other["status_message"] or "",
"presence_mode": other["presence_mode"] or "online",
},
}
def find_or_create_dm(user_a_id: int, user_b_id: int, now_iso_func) -> int:
low_id, high_id = sorted([int(user_a_id), int(user_b_id)])
with get_db() as db:
existing = db.execute(
"""
SELECT dc.id
FROM direct_conversations AS dc
JOIN direct_conversation_members AS m1
ON m1.direct_conversation_id = dc.id AND m1.user_id = ?
JOIN direct_conversation_members AS m2
ON m2.direct_conversation_id = dc.id AND m2.user_id = ?
WHERE dc.member_pair_key = ?
LIMIT 1
""",
(low_id, high_id, f"{low_id}:{high_id}"),
).fetchone()
if existing:
return existing["id"]
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
existing = db.execute(
"""
SELECT id
FROM direct_conversations
WHERE member_pair_key = ?
LIMIT 1
""",
(f"{low_id}:{high_id}",),
).fetchone()
if existing:
db.commit()
return existing["id"]
cursor = db.execute(
"""
INSERT INTO direct_conversations (
member_pair_key,
created_at
) VALUES (?, ?)
""",
(f"{low_id}:{high_id}", now_iso_func()),
)
dm_id = cursor.lastrowid
db.execute(
"""
INSERT INTO direct_conversation_members (
direct_conversation_id,
user_id,
added_at
) VALUES (?, ?, ?)
""",
(dm_id, low_id, now_iso_func()),
)
db.execute(
"""
INSERT INTO direct_conversation_members (
direct_conversation_id,
user_id,
added_at
) VALUES (?, ?, ?)
""",
(dm_id, high_id, now_iso_func()),
)
db.commit()
return dm_id
def update_presence(
db_write_func,
user_id: int,
view_name: Optional[str] = None,
room_id: Optional[int] = None,
channel_id: Optional[int] = None,
direct_conversation_id: Optional[int] = None,
) -> None:
db_write_func(
"""
INSERT INTO presence_events (
user_id,
updated_at,
view_name,
room_id,
channel_id,
direct_conversation_id
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id)
DO UPDATE SET
updated_at = excluded.updated_at,
view_name = excluded.view_name,
room_id = excluded.room_id,
channel_id = excluded.channel_id,
direct_conversation_id = excluded.direct_conversation_id
""",
(
user_id,
time.time(),
view_name,
room_id,
channel_id,
direct_conversation_id,
),
)
def room_presence_count(ttl_seconds: int, room_id: int) -> int:
cutoff = time.time() - ttl_seconds
with get_db() as db:
row = db.execute(
"""
SELECT COUNT(*) AS count
FROM presence_events
WHERE room_id = ?
AND updated_at >= ?
""",
(room_id, cutoff),
).fetchone()
return row["count"] if row else 0
def channel_presence_count(ttl_seconds: int, channel_id: int) -> int:
cutoff = time.time() - ttl_seconds
with get_db() as db:
row = db.execute(
"""
SELECT COUNT(*) AS count
FROM presence_events
WHERE channel_id = ?
AND updated_at >= ?
""",
(channel_id, cutoff),
).fetchone()
return row["count"] if row else 0
def dm_presence_count(ttl_seconds: int, dm_id: int) -> int:
cutoff = time.time() - ttl_seconds
with get_db() as db:
row = db.execute(
"""
SELECT COUNT(*) AS count
FROM presence_events
WHERE direct_conversation_id = ?
AND updated_at >= ?
""",
(dm_id, cutoff),
).fetchone()
return row["count"] if row else 0

749
profiles.py Normal file
View File

@ -0,0 +1,749 @@
import html
import json
import re
import time
from typing import Optional
import markdown
from html.parser import HTMLParser
from db import get_db
DEFAULT_ROLE_NAME_COLORS = {
"admin": "#ff7b7b",
"moderator": "#8ab4ff",
"user": "#f5f5f7",
}
_ALLOWED_TAGS = {
"b",
"strong",
"i",
"em",
"u",
"p",
"br",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"a",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
_ALLOWED_ATTRS = {
"a": {"href", "title", "target", "rel"},
}
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
def _escape_attr(value: str) -> str:
return html.escape(value, quote=True)
def sanitize_html_fragment(raw: str) -> str:
class SafeHtmlParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag not in _ALLOWED_TAGS:
return
allowed = _ALLOWED_ATTRS.get(tag, set())
clean_attrs = []
for key, value in attrs:
key = (key or "").lower()
value = value or ""
if key not in allowed:
continue
if tag == "a" and key == "href":
lowered = value.strip().lower()
if not lowered.startswith(_ALLOWED_SCHEMES):
continue
clean_attrs.append((key, value))
if tag == "a":
attr_keys = {key for key, _ in clean_attrs}
if "target" not in attr_keys:
clean_attrs.append(("target", "_blank"))
if "rel" not in attr_keys:
clean_attrs.append(("rel", "noopener noreferrer"))
attr_text = "".join(
f' {key}="{_escape_attr(value)}"'
for key, value in clean_attrs
)
self.parts.append(f"<{tag}{attr_text}>")
def handle_endtag(self, tag):
tag = tag.lower()
if tag in _ALLOWED_TAGS and tag not in {"br"}:
self.parts.append(f"</{tag}>")
def handle_data(self, data):
self.parts.append(html.escape(data))
def handle_entityref(self, name):
self.parts.append(f"&{name};")
def handle_charref(self, name):
self.parts.append(f"&#{name};")
parser = SafeHtmlParser()
parser.feed(raw or "")
parser.close()
return "".join(parser.parts)
PROFILE_BIO_MODES = {"txt", "md", "html"}
THEME_MODES = {"dark", "light"}
PAGE_VISIBILITIES = {"team", "public"}
PAGE_TYPES = {"project", "document", "update", "resource"}
TEAM_MEMBER_ROLES = {"owner", "admin", "member"}
PROFILE_VISIBILITIES = {"public", "members", "private"}
LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"}
PRESENCE_MODES = {"online", "silent", "focus", "dnd"}
DM_LAYOUT_MODES = {"cards", "compact"}
REACTION_EMOJI_RE = re.compile(r"^.{1,16}$")
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE)
def valid_hex_color(value: object) -> bool:
return bool(isinstance(value, str) and HEX_COLOR_RE.fullmatch(value.strip()))
def normalized_hex_color(value: object) -> Optional[str]:
if not valid_hex_color(value):
return None
return str(value).strip().lower()
def role_default_name_color(role: Optional[str]) -> str:
return DEFAULT_ROLE_NAME_COLORS.get(str(role or "user"), DEFAULT_ROLE_NAME_COLORS["user"])
def resolve_name_color(user_role: Optional[str], explicit_color: Optional[str]) -> str:
return normalized_hex_color(explicit_color) or role_default_name_color(user_role)
def render_markdown_to_safe_html(text: str) -> str:
rendered = markdown.markdown(
text or "",
extensions=[
"extra",
"sane_lists",
"nl2br",
"tables",
"fenced_code",
],
output_format="html5",
)
return sanitize_html_fragment(rendered)
def safe_profile_bio_html(bio: str, bio_mode: str) -> str:
mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt"
if mode == "html":
return sanitize_html_fragment(bio or "")
if mode == "md":
return render_markdown_to_safe_html(bio or "")
return "<p>" + html.escape(bio or "").replace("\n", "<br>") + "</p>"
def extract_page_reference_slugs(markdown_text: str) -> list[str]:
text = str(markdown_text or "")
matches = PAGE_LINK_RE.findall(text)
seen = set()
slugs = []
for slug in matches:
normalized = slug.lower().strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
slugs.append(normalized)
return slugs
def detect_website_brand(website: str) -> dict:
value = str(website or "").strip().lower()
if not value:
return {"host": "", "brand": "web", "label": "website"}
host = value
host = host.replace("https://", "").replace("http://", "")
host = host.split("/", 1)[0]
host = host.removeprefix("www.")
mappings = [
("github.com", "github", "GitHub"),
("youtube.com", "youtube", "YouTube"),
("youtu.be", "youtube", "YouTube"),
("spotify.com", "spotify", "Spotify"),
("soundcloud.com", "soundcloud", "SoundCloud"),
("twitter.com", "x", "X"),
("x.com", "x", "X"),
]
for suffix, brand, label in mappings:
if host.endswith(suffix):
return {"host": host, "brand": brand, "label": label}
return {"host": host, "brand": "web", "label": "website"}
def sqlite_table_exists(table_name: str) -> bool:
with get_db() as db:
row = db.execute(
"""
SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = ?
""",
(table_name,),
).fetchone()
return bool(row)
def sqlite_column_exists(table_name: str, column_name: str) -> bool:
with get_db() as db:
rows = db.execute(f"PRAGMA table_info({table_name})").fetchall()
return any(row["name"] == column_name for row in rows)
def load_page_children(page_id: int) -> list[dict]:
if not sqlite_column_exists("pages", "parent_page_id"):
return []
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
slug,
title,
updated_at
FROM pages
WHERE parent_page_id = ?
AND is_active = 1
ORDER BY title COLLATE NOCASE, id ASC
""",
(page_id,),
).fetchall()
return [dict(row) for row in rows]
def load_page_backlinks(page_id: int, page_slug: str, team_id: int) -> list[dict]:
pattern_1 = f"%]({page_slug})%"
pattern_2 = f"%/pages/{page_slug})%"
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
slug,
title,
updated_at
FROM pages
WHERE team_id = ?
AND is_active = 1
AND id != ?
AND (body LIKE ? OR body LIKE ?)
ORDER BY updated_at DESC, id DESC
""",
(team_id, page_id, pattern_1, pattern_2),
).fetchall()
return [dict(row) for row in rows]
def load_page_comments(page_id: int) -> list[dict]:
if not sqlite_table_exists("page_comments"):
return []
with get_db() as db:
rows = db.execute(
"""
SELECT
pc.id,
pc.page_id,
pc.user_id,
pc.username,
pc.body,
pc.created_at,
pc.updated_at,
u.role AS user_role,
us.name_color AS user_name_color
FROM page_comments AS pc
JOIN users AS u
ON u.id = pc.user_id
LEFT JOIN user_settings AS us
ON us.user_id = u.id
WHERE pc.page_id = ?
AND pc.deleted_at IS NULL
ORDER BY pc.created_at ASC, pc.id ASC
""",
(page_id,),
).fetchall()
return [
{
"id": row["id"],
"page_id": row["page_id"],
"user_id": row["user_id"],
"username": row["username"],
"body": row["body"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"user_role": row["user_role"],
"user_name_color": resolve_name_color(row["user_role"], row["user_name_color"]),
}
for row in rows
]
def reaction_summary_for_message(message_id: int, user: Optional[dict]) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
emoji,
COUNT(*) AS count,
MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted
FROM message_reactions
WHERE message_id = ?
GROUP BY emoji
ORDER BY count DESC, emoji ASC
""",
(user["id"] if user else -1, message_id),
).fetchall()
return [
{
"emoji": row["emoji"],
"count": row["count"],
"reacted": bool(row["reacted"]),
}
for row in rows
]
def build_profile_privacy_context(viewer: Optional[dict], target_user_id: int) -> dict:
is_self = bool(viewer and viewer["id"] == target_user_id)
is_staff = bool(viewer and viewer["role"] in {"moderator", "admin"})
is_authenticated = bool(viewer)
return {
"is_self": is_self,
"is_staff": is_staff,
"is_authenticated": is_authenticated,
}
def can_view_profile_record(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
visibility = "public"
if settings_row and "profile_visibility" in settings_row.keys():
visibility = (settings_row["profile_visibility"] or "public").lower()
if visibility not in PROFILE_VISIBILITIES:
visibility = "public"
ctx = build_profile_privacy_context(viewer, target_user_id)
if visibility == "public":
return True
if visibility == "members":
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
if visibility == "private":
return ctx["is_self"] or ctx["is_staff"]
return True
def can_view_last_active(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
visibility = "everyone"
if settings_row and "last_active_visibility" in settings_row.keys():
visibility = (settings_row["last_active_visibility"] or "everyone").lower()
if visibility not in LAST_ACTIVE_VISIBILITIES:
visibility = "everyone"
ctx = build_profile_privacy_context(viewer, target_user_id)
if visibility == "everyone":
return True
if visibility == "members":
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
if visibility == "nobody":
return ctx["is_self"] or ctx["is_staff"]
return False
def profile_presence_badge_visible(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
ctx = build_profile_privacy_context(viewer, target_user_id)
if ctx["is_self"] or ctx["is_staff"]:
return True
if settings_row and "show_presence_badge" in settings_row.keys():
return bool(settings_row["show_presence_badge"])
return True
def profile_payload(user_payload_func, user_row, profile_row, settings_row, viewer: Optional[dict]) -> dict:
is_self = bool(viewer and viewer["id"] == user_row["id"])
status_payload = user_payload_func(user_row, settings_row)
bio = profile_row["bio"] or ""
bio_mode = profile_row["bio_mode"] or "txt"
explicit_name_color = settings_row["name_color"] if settings_row else None
resolved = resolve_name_color(user_row["role"], explicit_name_color)
profile_visibility = settings_row["profile_visibility"] if settings_row and "profile_visibility" in settings_row.keys() else "public"
if profile_visibility not in PROFILE_VISIBILITIES:
profile_visibility = "public"
last_active_visibility = settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone"
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
last_active_visibility = "everyone"
presence_mode = settings_row["presence_mode"] if settings_row and "presence_mode" in settings_row.keys() else "online"
if presence_mode not in PRESENCE_MODES:
presence_mode = "online"
dm_layout_mode = settings_row["dm_layout_mode"] if settings_row and "dm_layout_mode" in settings_row.keys() else "cards"
if dm_layout_mode not in DM_LAYOUT_MODES:
dm_layout_mode = "cards"
show_presence_badge = profile_presence_badge_visible(settings_row, viewer, user_row["id"])
last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None
if not can_view_last_active(settings_row, viewer, user_row["id"]):
last_active_at = None
website = profile_row["website"] or ""
website_meta = detect_website_brand(website) if website else {"host": "", "brand": "web", "label": "website"}
return {
"id": user_row["id"],
"username": user_row["username"],
"role": user_row["role"],
"status": status_payload["status"],
"presence_mode": presence_mode,
"created_at": user_row["created_at"],
"last_active_at": last_active_at,
"display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"],
"badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "",
"bio": bio,
"bio_mode": bio_mode,
"bio_html": safe_profile_bio_html(bio, bio_mode),
"website": website,
"website_meta": website_meta,
"location": profile_row["location"] or "",
"status_message": profile_row["status_message"] if "status_message" in profile_row.keys() else "",
"banner_attachment_id": profile_row["banner_attachment_id"] if "banner_attachment_id" in profile_row.keys() else None,
"banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if ("banner_attachment_id" in profile_row.keys() and profile_row["banner_attachment_id"]) else "",
"updated_at": profile_row["updated_at"],
"is_self": is_self,
"name_color": explicit_name_color,
"resolved_name_color": resolved,
"profile_background_color": settings_row["profile_background_color"] if settings_row and "profile_background_color" in settings_row.keys() else None,
"profile_banner_color": settings_row["profile_banner_color"] if settings_row and "profile_banner_color" in settings_row.keys() else None,
"profile_visibility": profile_visibility,
"last_active_visibility": last_active_visibility,
"show_presence_badge": show_presence_badge,
"dm_layout_mode": dm_layout_mode,
}
def page_payload(page, user: Optional[dict], is_team_member_func, is_team_admin_func) -> dict:
can_read = bool(
page["visibility"] == "public"
or (
user
and (
user["role"] in {"moderator", "admin"}
or is_team_member_func(page["team_id"], user["id"])
)
)
)
rendered_html = render_markdown_to_safe_html(page["body"] or "")
parent_page_id = page["parent_page_id"] if "parent_page_id" in page.keys() else None
parent_page_slug = page["parent_page_slug"] if "parent_page_slug" in page.keys() else None
parent_page_title = page["parent_page_title"] if "parent_page_title" in page.keys() else None
return {
"id": page["id"],
"team_id": page["team_id"],
"team_slug": page["team_slug"],
"team_name": page["team_name"],
"channel_id": page["channel_id"],
"channel_slug": page["channel_slug"],
"channel_name": page["channel_name"],
"parent_page_id": parent_page_id,
"parent_page_slug": parent_page_slug,
"parent_page_title": parent_page_title,
"slug": page["slug"],
"title": page["title"],
"body": page["body"],
"body_html": rendered_html,
"visibility": page["visibility"],
"page_type": page["page_type"],
"created_by_user_id": page["created_by_user_id"],
"updated_by_user_id": page["updated_by_user_id"],
"created_at": page["created_at"],
"updated_at": page["updated_at"],
"can_read": can_read,
"can_edit": bool(
user
and (
user["role"] in {"moderator", "admin"}
or is_team_admin_func(page["team_id"], user)
)
),
"children": load_page_children(page["id"]),
"backlinks": load_page_backlinks(
page["id"],
page["slug"],
page["team_id"],
),
"references": extract_page_reference_slugs(page["body"] or ""),
"comments": load_page_comments(page["id"]),
}
def list_dms_for_user(user_id: int) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
dc.id,
dc.created_at,
other.id AS other_user_id,
other.username AS other_username,
other.role AS other_role,
us.name_color AS other_name_color,
up.display_name AS other_display_name,
up.status_message AS other_status_message,
us.presence_mode AS other_presence_mode,
(
SELECT m.text
FROM messages AS m
WHERE m.direct_conversation_id = dc.id
ORDER BY m.id DESC
LIMIT 1
) AS last_message_text,
(
SELECT m.created_at
FROM messages AS m
WHERE m.direct_conversation_id = dc.id
ORDER BY m.id DESC
LIMIT 1
) AS last_message_created_at,
(
SELECT m.id
FROM messages AS m
WHERE m.direct_conversation_id = dc.id
ORDER BY m.id DESC
LIMIT 1
) AS last_message_id,
(
SELECT COUNT(*)
FROM messages AS m2
WHERE m2.direct_conversation_id = dc.id
AND m2.user_id != ?
AND NOT EXISTS (
SELECT 1
FROM message_receipts AS mr
WHERE mr.message_id = m2.id
AND mr.user_id = ?
)
) AS unread_count
FROM direct_conversations AS dc
JOIN direct_conversation_members AS self_member
ON self_member.direct_conversation_id = dc.id
JOIN direct_conversation_members AS other_member
ON other_member.direct_conversation_id = dc.id
AND other_member.user_id != self_member.user_id
JOIN users AS other
ON other.id = other_member.user_id
LEFT JOIN user_profiles AS up
ON up.user_id = other.id
LEFT JOIN user_settings AS us
ON us.user_id = other.id
WHERE self_member.user_id = ?
ORDER BY COALESCE(last_message_id, 0) DESC, dc.id DESC
""",
(user_id, user_id, user_id),
).fetchall()
items = []
for row in rows:
items.append(
{
"id": row["id"],
"created_at": row["created_at"],
"other_user": {
"id": row["other_user_id"],
"username": row["other_username"],
"display_name": row["other_display_name"] or row["other_username"],
"role": row["other_role"],
"name_color": row["other_name_color"],
"resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]),
"status_message": row["other_status_message"] or "",
"presence_mode": row["other_presence_mode"] or "online",
},
"last_message_text": row["last_message_text"],
"last_message_created_at": row["last_message_created_at"],
"last_message_id": row["last_message_id"],
"unread_count": int(row["unread_count"] or 0),
}
)
return items
def richer_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
with get_db() as db:
edited = db.execute(
"""
SELECT created_at
FROM message_edits
WHERE message_id = ?
ORDER BY id DESC
LIMIT 1
""",
(row["id"],),
).fetchone()
receipt_count = db.execute(
"""
SELECT COUNT(*) AS count
FROM message_receipts
WHERE message_id = ?
""",
(row["id"],),
).fetchone()["count"]
seen_by_user = False
is_bookmarked = False
is_pinned = False
pin_row = None
bookmark_table_exists = bool(
db.execute(
"""
SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = 'message_bookmarks'
"""
).fetchone()
)
pin_table_exists = bool(
db.execute(
"""
SELECT 1
FROM sqlite_master
WHERE type = 'table'
AND name = 'message_pins'
"""
).fetchone()
)
if user:
seen_by_user = bool(
db.execute(
"""
SELECT 1
FROM message_receipts
WHERE message_id = ?
AND user_id = ?
""",
(row["id"], user["id"]),
).fetchone()
)
if bookmark_table_exists:
is_bookmarked = bool(
db.execute(
"""
SELECT 1
FROM message_bookmarks
WHERE message_id = ?
AND user_id = ?
""",
(row["id"], user["id"]),
).fetchone()
)
if pin_table_exists:
pin_row = db.execute(
"""
SELECT pinned_at, pinned_by_user_id
FROM message_pins
WHERE message_id = ?
""",
(row["id"],),
).fetchone()
is_pinned = bool(pin_row)
primary_attachment = attachment_payload_legacy_from_message_select_row(row)
attachments = attachment_list_for_message(row["id"])
if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments):
attachments.insert(0, primary_attachment)
user_role = row["user_role"] if "user_role" in row.keys() else "user"
user_name_color = row["user_name_color"] if "user_name_color" in row.keys() else None
resolved_color = resolve_name_color(user_role, user_name_color)
return {
"id": row["id"],
"room_id": row["room_id"],
"channel_id": row["channel_id"],
"direct_conversation_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
"user_id": row["user_id"],
"username": row["username"],
"user_role": user_role,
"user_name_color": resolved_color,
"text": row["text"],
"created_at": row["created_at"],
"updated_at": edited["created_at"] if edited else None,
"is_edited": bool(edited),
"parent_message_id": row["parent_message_id"],
"thread_reply_count": row["thread_reply_count"],
"attachment": primary_attachment,
"attachments": attachments,
"read_receipt_count": receipt_count,
"seen_by_current_user": seen_by_user,
"reactions": reaction_summary_for_message(row["id"], user),
"is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]),
"is_pinned": is_pinned,
"pinned_at": pin_row["pinned_at"] if pin_row else None,
"pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None,
"is_bookmarked": is_bookmarked,
}
def feed_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
payload = richer_message_payload(
row,
user,
attachment_payload_legacy_from_message_select_row,
attachment_list_for_message,
)
payload.update(
{
"room_slug": row["room_slug"] if "room_slug" in row.keys() else None,
"room_name": row["room_name"] if "room_name" in row.keys() else None,
"room_visibility": row["room_visibility"] if "room_visibility" in row.keys() else None,
"team_slug": row["team_slug"] if "team_slug" in row.keys() else None,
"team_name": row["team_name"] if "team_name" in row.keys() else None,
"channel_slug": row["channel_slug"] if "channel_slug" in row.keys() else None,
"channel_name": row["channel_name"] if "channel_name" in row.keys() else None,
"dm_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
"is_mention": bool(
user and f"@{user['username'].lower()}" in payload["text"].lower()
),
}
)
return payload

5795
routes.py

File diff suppressed because it is too large Load Diff

23
routes/__init__.py Normal file
View File

@ -0,0 +1,23 @@
from .attachments import register_attachment_routes
from .messages import register_message_routes
from .feed import register_feed_routes
from .presence import register_presence_routes
from .rooms import register_room_routes
from .teams import register_team_routes
from .profiles import register_profile_routes
from .dms import register_dm_routes
from .calls import register_call_routes
from .admin import register_admin_routes
def register_routes(app):
register_attachment_routes(app)
register_message_routes(app)
register_feed_routes(app)
register_presence_routes(app)
register_room_routes(app)
register_team_routes(app)
register_profile_routes(app)
register_dm_routes(app)
register_call_routes(app)
register_admin_routes(app)

268
routes/admin.py Normal file
View File

@ -0,0 +1,268 @@
from .common import (
ROLES,
STATUSES,
now_iso,
parse_iso,
utc_now,
csrf_valid,
rate_limited,
require_moderator,
require_admin,
audit,
hash_identifier,
safe_json,
jsonify,
get_db,
db_write,
)
def register_admin_routes(app):
@app.route("/api/admin/users")
def admin_list_users():
moderator, error = require_moderator()
if error:
return error
with get_db() as db:
rows = db.execute(
"""
SELECT
u.id,
u.username,
u.role,
u.status,
u.restricted_until,
u.created_at,
u.last_login_at,
COUNT(m.id) AS message_count
FROM users AS u
LEFT JOIN messages AS m
ON m.user_id = u.id
GROUP BY u.id
ORDER BY
CASE u.role
WHEN 'admin' THEN 0
WHEN 'moderator' THEN 1
ELSE 2
END,
u.username COLLATE NOCASE
"""
).fetchall()
return jsonify(
{
"users": [
{
"id": row["id"],
"username": row["username"],
"role": row["role"],
"status": row["status"],
"restricted_until": row["restricted_until"],
"created_at": row["created_at"],
"last_login_at": row["last_login_at"],
"message_count": row["message_count"],
}
for row in rows
],
"requested_by": moderator["username"],
}
)
@app.route("/api/admin/users/<int:user_id>", methods=["PATCH"])
def admin_update_user(user_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
limited = rate_limited("admin")
if limited:
return limited
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
with get_db() as db:
target = db.execute(
"""
SELECT
id,
username,
role,
status,
restricted_until
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
if not target:
return jsonify({"error": "User not found."}), 404
if target["id"] == admin["id"]:
return jsonify({"error": "You cannot modify your own account here."}), 400
role = data.get("role", target["role"])
status = data.get("status", target["status"])
restricted_until = data.get("restricted_until", target["restricted_until"])
if role not in ROLES:
return jsonify({"error": "Invalid role."}), 400
if status not in STATUSES:
return jsonify({"error": "Invalid account status."}), 400
if status == "restricted" and restricted_until:
parsed = parse_iso(restricted_until)
if not parsed or parsed <= utc_now():
return jsonify({"error": "restricted_until must be a future ISO timestamp."}), 400
elif status != "restricted":
restricted_until = None
if target["role"] == "admin" and role != "admin":
with get_db() as db:
count = db.execute(
"""
SELECT COUNT(*) AS count
FROM users
WHERE role = 'admin'
AND status = 'active'
"""
).fetchone()["count"]
if count <= 1:
return jsonify({"error": "At least one active administrator is required."}), 400
is_admin_value = 1 if role == "admin" else 0
is_active = 1 if status in {"active", "restricted"} else 0
db_write(
"""
UPDATE users
SET
role = ?,
status = ?,
restricted_until = ?,
is_admin = ?,
is_active = ?
WHERE id = ?
""",
(role, status, restricted_until, is_admin_value, is_active, user_id),
)
if status == "blacklisted":
db_write(
"""
INSERT OR REPLACE INTO blacklist_entries (
kind,
value_hash,
reason,
created_at,
created_by_user_id
) VALUES ('user', ?, ?, ?, ?)
""",
(
hash_identifier(str(user_id)),
"Account blacklisted by administrator",
now_iso(),
admin["id"],
),
)
if target["status"] == "blacklisted" and status != "blacklisted":
db_write(
"DELETE FROM blacklist_entries WHERE kind = 'user' AND value_hash = ?",
(hash_identifier(str(user_id)),),
)
audit(
"user_updated",
admin["id"],
"user",
str(user_id),
f"role={role};status={status};restricted_until={restricted_until}",
)
return jsonify({"status": "updated"})
@app.route("/api/admin/rooms")
def admin_rooms():
moderator, error = require_moderator()
if error:
return error
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
slug,
name,
description,
visibility,
created_by_user_id,
is_active
FROM rooms
WHERE is_active = 1
ORDER BY name COLLATE NOCASE
"""
).fetchall()
return jsonify(
{
"rooms": [
{
"id": row["id"],
"slug": row["slug"],
"name": row["name"],
"description": row["description"],
"visibility": row["visibility"],
}
for row in rows
]
}
)
@app.route("/api/admin/blacklist/ip", methods=["POST"])
def blacklist_ip():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
ip = str(data.get("ip", "")).strip()
reason = str(data.get("reason", "")).strip()[:300]
if not ip or len(ip) > 128:
return jsonify({"error": "A valid IP value is required."}), 400
db_write(
"""
INSERT OR REPLACE INTO blacklist_entries (
kind,
value_hash,
reason,
created_at,
created_by_user_id
) VALUES ('ip', ?, ?, ?, ?)
""",
(
hash_identifier(ip),
reason or "IP blacklisted by administrator",
now_iso(),
admin["id"],
),
)
audit("ip_blacklisted", admin["id"], "ip", hash_identifier(ip), reason)
return jsonify({"status": "blacklisted"})

125
routes/attachments.py Normal file
View File

@ -0,0 +1,125 @@
from .common import (
ALLOWED_ATTACHMENT_EXTENSIONS,
ALLOWED_ATTACHMENT_MIME_TYPES,
MAX_ATTACHMENT_BYTES,
MAX_ATTACHMENT_NAME_LENGTH,
UPLOAD_DIR,
now_iso,
csrf_valid,
rate_limited,
require_user,
require_writable_user,
audit,
safe_json,
jsonify,
request,
db_write,
can_access_room,
can_access_channel,
get_channel_by_id,
)
from attachments import send_attachment_file
def register_attachment_routes(app):
@app.route("/api/attachments", methods=["POST"])
def upload_attachment():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
limited = rate_limited("attachment")
if limited:
return limited
app.maybe_cleanup_orphaned_attachments()
uploaded = request.files.get("attachment")
if not uploaded or not uploaded.filename:
return jsonify({"error": "Choose a file to upload."}), 400
original_name = app.secure_filename(uploaded.filename)[:MAX_ATTACHMENT_NAME_LENGTH]
if not original_name or "." not in original_name:
return jsonify({"error": "Attachment must have an allowed file extension."}), 400
extension = original_name.rsplit(".", 1)[1].lower()
if extension not in ALLOWED_ATTACHMENT_EXTENSIONS:
return jsonify({"error": "This file type is not allowed."}), 400
declared_mime = (uploaded.mimetype or "").lower()
guessed_mime = app.mimetypes.guess_type(original_name)[0] or ""
if declared_mime and declared_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
return jsonify({"error": "This file MIME type is not allowed."}), 400
if guessed_mime and guessed_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
return jsonify({"error": "This file extension is not allowed."}), 400
content = uploaded.stream.read(MAX_ATTACHMENT_BYTES + 1)
if not content:
return jsonify({"error": "Attachment cannot be empty."}), 400
if len(content) > MAX_ATTACHMENT_BYTES:
return jsonify({"error": "Attachments must be 100 MB or smaller."}), 400
stored_name = f"{app.secrets.token_urlsafe(24)}.{extension}"
destination = UPLOAD_DIR / stored_name
try:
destination.write_bytes(content)
app.os.chmod(destination, 0o600)
except OSError:
app.logger.exception("Could not save attachment.")
return jsonify({"error": "Could not save attachment."}), 500
mime_type = declared_mime or guessed_mime or "application/octet-stream"
try:
cursor = db_write(
"""
INSERT INTO attachments (
uploader_user_id,
stored_name,
original_name,
mime_type,
size_bytes,
created_at
) VALUES (?, ?, ?, ?, ?, ?)
""",
(user["id"], stored_name, original_name, mime_type, len(content), now_iso()),
)
except Exception:
destination.unlink(missing_ok=True)
raise
audit("attachment_uploaded", user["id"], "attachment", str(cursor.lastrowid), original_name)
return jsonify(
{
"attachment": {
"id": cursor.lastrowid,
"original_name": original_name,
"mime_type": mime_type,
"size_bytes": len(content),
"url": f"/api/attachments/{cursor.lastrowid}/download",
}
}
), 201
@app.route("/api/attachments/<int:attachment_id>/download")
def download_attachment(attachment_id: int):
user, error = require_user()
if error:
return error
return send_attachment_file(
UPLOAD_DIR,
attachment_id,
user,
can_access_room,
can_access_channel,
get_channel_by_id,
)

876
routes/calls.py Normal file
View File

@ -0,0 +1,876 @@
import time
from .common import (
CALLS_BASE,
CALL_HELPER_SHARED_SECRET,
CALL_TOKEN_TTL_SECONDS,
now_iso,
parse_iso,
utc_now,
csrf_valid,
require_user,
require_writable_user,
audit,
safe_json,
jsonify,
request,
get_db,
db_write,
sign_call_helper_token,
normalize_username,
get_room_by_slug,
can_access_room,
get_channel_by_slug,
can_access_channel,
validate_dm_access,
get_dm_other_user,
build_room_helper_call_id,
build_channel_helper_call_id,
get_or_create_group_call_session,
group_call_session_payload,
get_group_call_session_by_id,
upsert_group_call_participant,
heartbeat_group_call_participant,
mark_group_call_participant_left,
list_group_call_participants,
maybe_end_group_call_if_empty,
create_call_session,
call_payload,
get_call_with_dm,
append_call_signal,
list_call_signals,
end_call,
can_access_group_call_session,
can_manage_group_call_session,
group_call_scope_payload,
group_call_permissions_payload,
)
def register_call_routes(app):
@app.route("/api/group-calls/token", methods=["POST"])
def api_group_call_token():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not CALL_HELPER_SHARED_SECRET:
return jsonify({"error": "Call helper is not configured."}), 503
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
scope_kind = str(data.get("scope_kind", "")).strip().lower()
helper_call_id = None
scope_id = None
scope_slug = None
room_value = None
if scope_kind == "room":
room_slug = str(data.get("room_slug", "")).strip()
room = get_room_by_slug(room_slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if not can_access_room(room, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
helper_call_id = build_room_helper_call_id(room)
scope_id = room["id"]
scope_slug = room["slug"]
room_value = room["slug"]
elif scope_kind == "channel":
team_slug = str(data.get("team_slug", "")).strip()
channel_slug = str(data.get("channel_slug", "")).strip()
channel = get_channel_by_slug(team_slug, channel_slug)
if not channel:
return jsonify({"error": "Channel not found."}), 404
if not can_access_channel(channel, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
helper_call_id = build_channel_helper_call_id(channel)
scope_id = channel["id"]
scope_slug = f"{channel['team_slug']}/{channel['slug']}"
room_value = scope_slug
else:
return jsonify({"error": "scope_kind must be room or channel."}), 400
session_row = get_or_create_group_call_session(
scope_kind=scope_kind,
scope_id=scope_id,
scope_slug=scope_slug,
helper_call_id=helper_call_id,
created_by_user_id=user["id"],
)
upsert_group_call_participant(session_row["id"], user["id"])
payload = {
"sub": f"user-{user['id']}",
"username": user["username"],
"room": room_value,
"call_id": helper_call_id,
"role": user["role"],
"can_call": True,
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
}
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
audit(
"group_call_token_issued",
user["id"],
"group_call_session",
str(session_row["id"]),
helper_call_id,
)
return jsonify(
{
"token": token,
"calls_base": CALLS_BASE,
"call_id": helper_call_id,
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(session_row["id"]),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/invite", methods=["POST"])
def api_group_call_invite():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not CALL_HELPER_SHARED_SECRET:
return jsonify({"error": "Call helper is not configured."}), 503
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
session_id_raw = data.get("group_call_session_id")
invite_username = normalize_username(data.get("username"))
try:
session_id = int(session_id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid group_call_session_id."}), 400
if not invite_username:
return jsonify({"error": "username is required."}), 400
session_row = get_group_call_session_by_id(session_id)
if not session_row or session_row["status"] != "active":
return jsonify({"error": "Active group call not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
is_active_participant = False
with get_db() as db:
is_active_participant = bool(
db.execute(
"""
SELECT 1
FROM group_call_participants
WHERE group_call_session_id = ?
AND user_id = ?
AND left_at IS NULL
""",
(session_row["id"], user["id"]),
).fetchone()
)
if not (is_active_participant or can_manage_group_call_session(session_row, user)):
return jsonify(
{
"error": "Only active participants or scope managers can invite users.",
"code": "forbidden",
}
), 403
with get_db() as db:
target = db.execute(
"""
SELECT
id,
username,
role,
status
FROM users
WHERE username = ?
""",
(invite_username,),
).fetchone()
if not target:
return jsonify({"error": "User not found."}), 404
if int(target["id"]) == int(user["id"]):
return jsonify({"error": "You are already in the call."}), 400
if target["status"] in {"disabled", "blacklisted"}:
return jsonify({"error": "User is not available."}), 400
payload = {
"sub": f"user-{target['id']}",
"username": target["username"],
"room": session_row["scope_slug"],
"call_id": session_row["helper_call_id"],
"role": target["role"],
"can_call": True,
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
}
helper_token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
expires_at = (utc_now() + app.timedelta(minutes=10)).isoformat()
cursor = db_write(
"""
INSERT INTO group_call_invites (
group_call_session_id,
inviter_user_id,
invitee_user_id,
helper_token,
status,
created_at,
expires_at,
responded_at
) VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL)
""",
(
session_row["id"],
user["id"],
target["id"],
helper_token,
now_iso(),
expires_at,
),
)
audit(
"group_call_invite_created",
user["id"],
"group_call_session",
str(session_row["id"]),
f"invitee={target['username']}",
)
return jsonify(
{
"invite": {
"id": cursor.lastrowid,
"invitee_username": target["username"],
"group_call_session_id": session_row["id"],
"status": "pending",
"expires_at": expires_at,
}
}
), 201
@app.route("/api/group-calls/invites")
def api_list_group_call_invites():
user, error = require_user()
if error:
return error
with get_db() as db:
rows = db.execute(
"""
SELECT
i.id,
i.group_call_session_id,
i.status,
i.created_at,
i.expires_at,
i.responded_at,
s.scope_kind,
s.scope_slug,
s.helper_call_id,
s.status AS session_status,
inviter.username AS inviter_username
FROM group_call_invites AS i
JOIN group_call_sessions AS s
ON s.id = i.group_call_session_id
JOIN users AS inviter
ON inviter.id = i.inviter_user_id
WHERE i.invitee_user_id = ?
AND i.status = 'pending'
AND i.expires_at > ?
AND s.status = 'active'
ORDER BY i.id DESC
""",
(user["id"], now_iso()),
).fetchall()
return jsonify(
{
"invites": [
{
"id": row["id"],
"group_call_session_id": row["group_call_session_id"],
"scope_kind": row["scope_kind"],
"scope_slug": row["scope_slug"],
"helper_call_id": row["helper_call_id"],
"inviter_username": row["inviter_username"],
"status": row["status"],
"created_at": row["created_at"],
"expires_at": row["expires_at"],
}
for row in rows
]
}
)
@app.route("/api/group-calls/invites/<int:invite_id>/accept", methods=["POST"])
def api_accept_group_call_invite(invite_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
with get_db() as db:
row = db.execute(
"""
SELECT
i.id,
i.group_call_session_id,
i.invitee_user_id,
i.helper_token,
i.status,
i.expires_at,
s.helper_call_id
FROM group_call_invites AS i
JOIN group_call_sessions AS s
ON s.id = i.group_call_session_id
WHERE i.id = ?
AND i.invitee_user_id = ?
LIMIT 1
""",
(invite_id, user["id"]),
).fetchone()
if not row:
return jsonify({"error": "Invite not found."}), 404
if row["status"] != "pending" or parse_iso(row["expires_at"]) <= utc_now():
return jsonify({"error": "Invite expired or unavailable."}), 400
db_write(
"""
UPDATE group_call_invites
SET
status = 'accepted',
responded_at = ?
WHERE id = ?
""",
(now_iso(), invite_id),
)
upsert_group_call_participant(row["group_call_session_id"], user["id"])
audit(
"group_call_invite_accepted",
user["id"],
"group_call_invite",
str(invite_id),
row["helper_call_id"],
)
session_row = get_group_call_session_by_id(row["group_call_session_id"])
return jsonify(
{
"token": row["helper_token"],
"calls_base": CALLS_BASE,
"call_id": row["helper_call_id"],
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(row["group_call_session_id"]),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/invites/<int:invite_id>/dismiss", methods=["POST"])
def api_dismiss_group_call_invite(invite_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_user()
if error:
return error
with get_db() as db:
row = db.execute(
"""
SELECT
id,
group_call_session_id
FROM group_call_invites
WHERE id = ?
AND invitee_user_id = ?
LIMIT 1
""",
(invite_id, user["id"]),
).fetchone()
if not row:
return jsonify({"error": "Invite not found."}), 404
db_write(
"""
UPDATE group_call_invites
SET
status = 'dismissed',
responded_at = ?
WHERE id = ?
AND invitee_user_id = ?
AND status = 'pending'
""",
(now_iso(), invite_id, user["id"]),
)
audit(
"group_call_invite_dismissed",
user["id"],
"group_call_invite",
str(invite_id),
)
return jsonify(
{
"status": "dismissed",
"group_call_session_id": row["group_call_session_id"],
}
)
@app.route("/api/group-calls/<int:session_id>")
def api_get_group_call_session(session_id: int):
user, error = require_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
return jsonify(
{
"session": group_call_session_payload(session_row),
"scope": group_call_scope_payload(session_row, user),
"participants": list_group_call_participants(session_id),
"permissions": group_call_permissions_payload(session_row, user),
}
)
@app.route("/api/group-calls/<int:session_id>/participants")
def api_get_group_call_participants(session_id: int):
user, error = require_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
return jsonify(
{
"session_id": session_id,
"participants": list_group_call_participants(session_id),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/<int:session_id>/join", methods=["POST"])
def api_join_group_call_session(session_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if session_row["status"] != "active":
return jsonify({"error": "Group call session is not active."}), 400
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
upsert_group_call_participant(session_id, user["id"])
audit(
"group_call_session_joined",
user["id"],
"group_call_session",
str(session_id),
session_row["helper_call_id"],
)
return jsonify(
{
"status": "joined",
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(session_id),
"permissions": group_call_permissions_payload(session_row, user),
"scope": group_call_scope_payload(session_row, user),
}
)
@app.route("/api/group-calls/<int:session_id>/heartbeat", methods=["POST"])
def api_heartbeat_group_call_session(session_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if session_row["status"] != "active":
return jsonify({"error": "Group call session is not active."}), 400
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
heartbeat_group_call_participant(session_id, user["id"])
return jsonify(
{
"status": "ok",
"session": group_call_session_payload(session_row),
"participants": list_group_call_participants(session_id),
}
)
@app.route("/api/group-calls/<int:session_id>/leave", methods=["POST"])
def api_leave_group_call_session(session_id: int):
"""
keep csrf behavior for normal requests, but allow same-origin unload-style cleanup
where the frontend keepalive/beacon path may not be able to send the custom header.
"""
user, error = require_user()
if error:
return error
if not csrf_valid():
origin = request.headers.get("Origin", "")
sec_fetch_site = request.headers.get("Sec-Fetch-Site", "")
same_origin_hint = (not origin) or origin.rstrip("/") == request.host_url.rstrip("/") or sec_fetch_site == "same-origin"
if not same_origin_hint:
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_access_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
mark_group_call_participant_left(session_id, user["id"])
maybe_end_group_call_if_empty(session_id)
refreshed = get_group_call_session_by_id(session_id)
audit(
"group_call_session_left",
user["id"],
"group_call_session",
str(session_id),
session_row["helper_call_id"],
)
return jsonify(
{
"status": "left",
"session": group_call_session_payload(refreshed) if refreshed else None,
"participants": list_group_call_participants(session_id),
}
)
@app.route("/api/group-calls/<int:session_id>/end", methods=["POST"])
def api_end_group_call_session(session_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
session_row = get_group_call_session_by_id(session_id)
if not session_row:
return jsonify({"error": "Group call session not found."}), 404
if not can_manage_group_call_session(session_row, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
db_write(
"""
UPDATE group_call_sessions
SET
status = 'ended',
ended_at = ?
WHERE id = ?
""",
(now_iso(), session_id),
)
db_write(
"""
UPDATE group_call_participants
SET
updated_at = ?,
left_at = COALESCE(left_at, ?)
WHERE group_call_session_id = ?
AND left_at IS NULL
""",
(now_iso(), now_iso(), session_id),
)
db_write(
"""
UPDATE group_call_invites
SET
status = 'dismissed',
responded_at = COALESCE(responded_at, ?)
WHERE group_call_session_id = ?
AND status = 'pending'
""",
(now_iso(), session_id),
)
refreshed = get_group_call_session_by_id(session_id)
audit(
"group_call_session_ended",
user["id"],
"group_call_session",
str(session_id),
session_row["helper_call_id"],
)
return jsonify(
{
"status": "ended",
"session": group_call_session_payload(refreshed) if refreshed else None,
"participants": [],
}
)
@app.route("/api/call-token", methods=["POST"])
def api_call_token():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not CALL_HELPER_SHARED_SECRET:
return jsonify({"error": "Call helper is not configured."}), 503
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
room_slug = str(data.get("room", "")).strip()
dm_id_raw = data.get("dm_id")
call_id = None
room_value = None
if dm_id_raw is not None:
try:
dm_id = int(dm_id_raw)
except (TypeError, ValueError):
return jsonify({"error": "Invalid dm_id."}), 400
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
peer = get_dm_other_user(dm_id, user["id"])
if not peer:
return jsonify({"error": "Direct-message peer not found."}), 404
call_id = f"dm-{dm_id}"
room_value = call_id
elif room_slug:
room = get_room_by_slug(room_slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if not can_access_room(room, user):
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
call_id = f"room-{room['slug']}"
room_value = room["slug"]
else:
return jsonify({"error": "room or dm_id is required."}), 400
payload = {
"sub": f"user-{user['id']}",
"username": user["username"],
"room": room_value,
"call_id": call_id,
"role": user["role"],
"can_call": True,
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
}
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
audit(
"call_helper_token_issued",
user["id"],
"call",
call_id,
f"room={room_value}",
)
return jsonify(
{
"token": token,
"calls_base": CALLS_BASE,
"call_id": call_id,
}
)
@app.route("/api/dms/<int:dm_id>/calls", methods=["POST"])
def api_start_dm_call(dm_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
call_id = create_call_session(dm_id, user["id"])
audit("call_started", user["id"], "call", str(call_id), str(dm_id))
return jsonify({"call": call_payload(call_id)}), 201
@app.route("/api/calls/<int:call_id>")
def api_get_call(call_id: int):
user, error = require_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
return jsonify({"call": call_payload(call_id)})
@app.route("/api/calls/<int:call_id>/signal", methods=["POST"])
def api_post_call_signal(call_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
if call["status"] != "active":
return jsonify({"error": "Call is not active."}), 400
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
signal_type = str(data.get("signal_type", "")).strip()[:40]
payload = data.get("payload", {})
if not signal_type:
return jsonify({"error": "signal_type is required."}), 400
if not isinstance(payload, dict):
return jsonify({"error": "payload must be an object."}), 400
signal_id = append_call_signal(call_id, user["id"], signal_type, payload)
return jsonify({"status": "ok", "signal_id": signal_id}), 201
@app.route("/api/calls/<int:call_id>/signals")
def api_get_call_signals(call_id: int):
user, error = require_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
after_raw = str(request.args.get("after_id", "")).strip()
after_id = 0
if after_raw:
try:
after_id = max(0, int(after_raw))
except ValueError:
return jsonify({"error": "after_id must be a non-negative integer."}), 400
signals = list_call_signals(call_id, after_id)
latest_id = signals[-1]["id"] if signals else after_id
return jsonify({"signals": signals, "latest_id": latest_id})
@app.route("/api/calls/<int:call_id>/end", methods=["POST"])
def api_end_call(call_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
call = get_call_with_dm(call_id)
if not call:
return jsonify({"error": "Call not found."}), 404
if not validate_dm_access(call["direct_conversation_id"], user):
return jsonify({"error": "Call not found."}), 404
end_call(call_id)
audit("call_ended", user["id"], "call", str(call_id))
return jsonify({"status": "ended", "call": call_payload(call_id)})

413
routes/common.py Normal file
View File

@ -0,0 +1,413 @@
import json
from flask import jsonify, request
from config import (
CALLS_BASE,
CALL_HELPER_SHARED_SECRET,
CALL_TOKEN_TTL_SECONDS,
CONSTANTS,
PRIVATE_ACCESS_KEY,
UPLOAD_DIR,
now_iso,
parse_iso,
utc_now,
)
from db import get_db, db_write
from auth import (
safe_json,
csrf_valid,
rate_limited,
current_user,
require_user,
require_writable_user,
require_moderator,
require_admin,
normalize_username,
audit,
hash_identifier,
sign_call_helper_token,
user_payload,
)
from models import (
ROOM_SLUG_RE,
MESSAGE_SELECT,
team_page_select,
team_slug_from_name,
page_slug_from_title,
room_by_id,
get_room_by_slug,
can_access_room,
can_write_room,
can_manage_room,
room_payload,
get_team_by_slug,
get_channel_by_id,
get_channel_by_slug,
get_channel_by_legacy_room_id,
team_member_role,
is_team_member,
is_team_admin,
can_edit_team,
can_access_team,
can_access_channel,
can_write_channel,
team_payload,
channel_payload,
get_dm_for_user,
get_dm_other_user,
validate_dm_access,
dm_payload,
find_or_create_dm,
update_presence,
room_presence_count,
channel_presence_count,
dm_presence_count,
)
from attachments import (
attachment_list_for_message,
attachment_payload_legacy_from_message_select_row,
resolve_owned_attachment,
resolve_owned_attachment_ids,
)
from profiles import (
DM_LAYOUT_MODES,
LAST_ACTIVE_VISIBILITIES,
PAGE_TYPES,
PAGE_VISIBILITIES,
PRESENCE_MODES,
PROFILE_BIO_MODES,
PROFILE_VISIBILITIES,
REACTION_EMOJI_RE,
TEAM_MEMBER_ROLES,
THEME_MODES,
normalized_hex_color,
valid_hex_color,
resolve_name_color,
page_payload,
profile_payload,
reaction_summary_for_message,
list_dms_for_user,
richer_message_payload,
feed_message_payload,
can_view_profile_record,
)
from calls import (
append_call_signal,
call_payload,
create_call_session,
end_call,
get_call_with_dm,
list_call_signals,
build_room_helper_call_id,
build_channel_helper_call_id,
get_or_create_group_call_session,
group_call_session_payload,
get_group_call_session_by_id,
upsert_group_call_participant,
heartbeat_group_call_participant,
mark_group_call_participant_left,
list_group_call_participants,
maybe_end_group_call_if_empty,
)
MAX_MESSAGE_LENGTH = CONSTANTS["MAX_MESSAGE_LENGTH"]
MAX_HISTORY_MESSAGES = CONSTANTS["MAX_HISTORY_MESSAGES"]
MAX_INCREMENTAL_MESSAGES = CONSTANTS["MAX_INCREMENTAL_MESSAGES"]
MAX_FEED_MESSAGES = CONSTANTS["MAX_FEED_MESSAGES"]
MAX_THREAD_REPLIES = CONSTANTS["MAX_THREAD_REPLIES"]
MAX_ATTACHMENT_BYTES = CONSTANTS["MAX_ATTACHMENT_BYTES"]
MAX_ATTACHMENT_NAME_LENGTH = CONSTANTS["MAX_ATTACHMENT_NAME_LENGTH"]
ALLOWED_ATTACHMENT_EXTENSIONS = CONSTANTS["ALLOWED_ATTACHMENT_EXTENSIONS"]
ALLOWED_ATTACHMENT_MIME_TYPES = CONSTANTS["ALLOWED_ATTACHMENT_MIME_TYPES"]
INVITE_TTL_HOURS = CONSTANTS["INVITE_TTL_HOURS"]
TEAM_INVITE_TTL_HOURS = CONSTANTS["TEAM_INVITE_TTL_HOURS"]
TYPING_TTL_SECONDS = CONSTANTS["TYPING_TTL_SECONDS"]
PRESENCE_TTL_SECONDS = CONSTANTS["PRESENCE_TTL_SECONDS"]
MAX_SEARCH_QUERY_LENGTH = CONSTANTS["MAX_SEARCH_QUERY_LENGTH"]
MAX_SEARCH_RESULTS = CONSTANTS["MAX_SEARCH_RESULTS"]
READ_RECEIPT_BATCH_LIMIT = CONSTANTS["READ_RECEIPT_BATCH_LIMIT"]
MAX_MESSAGE_EDIT_WINDOW_SECONDS = CONSTANTS["MAX_MESSAGE_EDIT_WINDOW_SECONDS"]
MAX_DISPLAY_NAME_LENGTH = CONSTANTS["MAX_DISPLAY_NAME_LENGTH"]
MAX_PROFILE_BIO_LENGTH = CONSTANTS["MAX_PROFILE_BIO_LENGTH"]
MAX_LOCATION_LENGTH = CONSTANTS["MAX_LOCATION_LENGTH"]
MAX_WEBSITE_LENGTH = CONSTANTS["MAX_WEBSITE_LENGTH"]
MAX_CUSTOM_CSS_LENGTH = CONSTANTS["MAX_CUSTOM_CSS_LENGTH"]
MAX_PROFILE_BADGE_LENGTH = CONSTANTS["MAX_PROFILE_BADGE_LENGTH"]
MAX_OWNED_TEAMS_PER_USER = CONSTANTS["MAX_OWNED_TEAMS_PER_USER"]
GROUP_CALL_PARTICIPANT_STALE_SECONDS = CONSTANTS["GROUP_CALL_PARTICIPANT_STALE_SECONDS"]
ROOM_VISIBILITIES = CONSTANTS["ROOM_VISIBILITIES"]
ROLES = CONSTANTS["ROLES"]
STATUSES = CONSTANTS["STATUSES"]
def private_access_key_valid(candidate: object) -> bool:
configured = (PRIVATE_ACCESS_KEY or "").strip()
if not configured:
return True
if not isinstance(candidate, str):
return False
return candidate.strip() == configured
def build_message_payload(row, user):
return richer_message_payload(
row,
user,
attachment_payload_legacy_from_message_select_row,
attachment_list_for_message,
)
def build_feed_payload(row, user):
return feed_message_payload(
row,
user,
attachment_payload_legacy_from_message_select_row,
attachment_list_for_message,
)
def build_local_stub_share_url(kind: str, object_id: int) -> str:
return f"/shared/{kind}/{object_id}"
def require_csrf():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
return None
def parse_json_request():
try:
return safe_json(), None
except ValueError as error:
return None, (jsonify({"error": str(error)}), 400)
def require_dm_access(dm_id: int, user):
if not validate_dm_access(dm_id, user):
return None, (jsonify({"error": "DM not found."}), 404)
return dm_id, None
def require_room_access(
slug: str,
user,
login_message="Login required.",
forbidden_message="You do not have access to this room.",
):
room = get_room_by_slug(slug)
if not room:
return None, (jsonify({"error": "Room not found."}), 404)
if not can_access_room(room, user):
if not user:
return None, (jsonify({"error": login_message, "code": "login_required"}), 401)
return None, (jsonify({"error": forbidden_message, "code": "forbidden"}), 403)
return room, None
def require_room_write(slug: str, user):
room = get_room_by_slug(slug)
if not room:
return None, (jsonify({"error": "Room not found."}), 404)
if not can_write_room(room, user):
return None, (jsonify({"error": "You cannot post in this room.", "code": "forbidden"}), 403)
return room, None
def require_channel_access(team_slug: str, channel_slug: str, user):
channel = get_channel_by_slug(team_slug, channel_slug)
if not channel:
return None, (jsonify({"error": "Channel not found."}), 404)
if not can_access_channel(channel, user):
if not user:
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
return None, (jsonify({"error": "You do not have access to this channel.", "code": "forbidden"}), 403)
return channel, None
def require_channel_write(team_slug: str, channel_slug: str, user):
channel = get_channel_by_slug(team_slug, channel_slug)
if not channel:
return None, (jsonify({"error": "Channel not found."}), 404)
if not can_write_channel(channel, user):
return None, (jsonify({"error": "You cannot post in this channel.", "code": "forbidden"}), 403)
return channel, None
def user_can_access_message_scope(message_row, user) -> bool:
if not message_row:
return False
if message_row["direct_conversation_id"]:
return bool(user and validate_dm_access(message_row["direct_conversation_id"], user))
if message_row["channel_id"]:
channel = get_channel_by_id(message_row["channel_id"])
return bool(channel and can_access_channel(channel, user))
if message_row["room_id"]:
room = room_by_id(message_row["room_id"])
return bool(room and can_access_room(room, user))
return False
def resolve_message_attachment_inputs(data: dict, user_id: int):
attachment_ids, attachment_error = resolve_owned_attachment_ids(
data.get("attachment_ids", []),
user_id,
)
if attachment_error:
return None, None, attachment_error
if not attachment_ids and data.get("attachment_id") is not None:
single_attachment_id, single_attachment_error = resolve_owned_attachment(
data.get("attachment_id"),
user_id,
)
if single_attachment_error:
return None, None, single_attachment_error
attachment_ids = [single_attachment_id] if single_attachment_id else []
attachment_id = attachment_ids[0] if attachment_ids else None
return attachment_ids, attachment_id, None
def require_team_admin(team_slug: str, user):
team = get_team_by_slug(team_slug)
if not team:
return None, (jsonify({"error": "Team not found."}), 404)
if not is_team_admin(team["id"], user):
return None, (jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403)
return team, None
def require_team_access(team_slug: str, user):
team = get_team_by_slug(team_slug)
if not team:
return None, (jsonify({"error": "Team not found."}), 404)
if not can_access_team(team, user):
if not user:
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
return None, (jsonify({"error": "You do not have access to this team.", "code": "forbidden"}), 403)
return team, None
def load_active_page(team_slug: str, page_slug: str):
with get_db() as db:
page = db.execute(
f"""
{team_page_select()}
WHERE t.slug = ?
AND p.slug = ?
AND p.is_active = 1
AND t.is_active = 1
""",
(team_slug, page_slug),
).fetchone()
return page
def can_read_page(page, user) -> bool:
return bool(
page
and (
page["visibility"] == "public"
or (
user
and (
user["role"] in {"moderator", "admin"}
or is_team_member(page["team_id"], user["id"])
)
)
)
)
def can_edit_page(page, user) -> bool:
return bool(page and user and is_team_admin(page["team_id"], user))
def can_access_group_call_session(session_row, user: dict) -> bool:
if not session_row or not user:
return False
if session_row["scope_kind"] == "room":
room = room_by_id(session_row["scope_id"])
return bool(room and can_access_room(room, user))
if session_row["scope_kind"] == "channel":
channel = get_channel_by_id(session_row["scope_id"])
return bool(channel and can_access_channel(channel, user))
return False
def can_manage_group_call_session(session_row, user: dict) -> bool:
if not session_row or not user:
return False
if user["role"] in {"moderator", "admin"}:
return True
if int(session_row["created_by_user_id"]) == int(user["id"]):
return True
if session_row["scope_kind"] == "room":
room = room_by_id(session_row["scope_id"])
return bool(room and can_manage_room(room, user))
if session_row["scope_kind"] == "channel":
channel = get_channel_by_id(session_row["scope_id"])
return bool(channel and can_edit_team(channel["team_id"], user))
return False
def group_call_scope_payload(session_row, user):
payload = {
"scope_kind": session_row["scope_kind"],
"scope_id": session_row["scope_id"],
"scope_slug": session_row["scope_slug"],
}
if session_row["scope_kind"] == "room":
room = room_by_id(session_row["scope_id"])
if room:
payload["room"] = room_payload(room, user)
payload["scope_label"] = room["name"]
elif session_row["scope_kind"] == "channel":
channel = get_channel_by_id(session_row["scope_id"])
if channel:
payload["channel"] = channel_payload(channel, user)
payload["team"] = {
"slug": channel["team_slug"],
"name": channel["team_name"],
}
payload["scope_label"] = f"# {channel['name']}"
return payload
def group_call_permissions_payload(session_row, user):
can_manage = can_manage_group_call_session(session_row, user)
can_access = can_access_group_call_session(session_row, user)
return {
"can_manage": can_manage,
"can_end": can_manage,
"can_invite": can_access,
}

236
routes/dms.py Normal file
View File

@ -0,0 +1,236 @@
from .common import (
MAX_HISTORY_MESSAGES,
MAX_INCREMENTAL_MESSAGES,
MAX_MESSAGE_LENGTH,
now_iso,
csrf_valid,
rate_limited,
require_user,
require_writable_user,
audit,
safe_json,
jsonify,
request,
get_db,
build_message_payload,
resolve_name_color,
validate_dm_access,
dm_payload,
find_or_create_dm,
get_dm_other_user,
MESSAGE_SELECT,
resolve_owned_attachment,
resolve_owned_attachment_ids,
normalize_username,
list_dms_for_user,
)
def register_dm_routes(app):
@app.route("/api/dms")
def api_list_dms():
user, error = require_user()
if error:
return error
return jsonify({"dms": list_dms_for_user(user["id"])})
@app.route("/api/dms", methods=["POST"])
def api_create_or_open_dm():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
other_user_id = data.get("user_id")
username = normalize_username(data.get("username"))
with get_db() as db:
target = None
if other_user_id is not None:
try:
parsed_id = int(other_user_id)
except (TypeError, ValueError):
return jsonify({"error": "Invalid user_id."}), 400
target = db.execute(
"""
SELECT id, username, role, status
FROM users
WHERE id = ?
""",
(parsed_id,),
).fetchone()
elif username:
target = db.execute(
"""
SELECT id, username, role, status
FROM users
WHERE username = ?
""",
(username,),
).fetchone()
if not target:
return jsonify({"error": "User not found."}), 404
if int(target["id"]) == int(user["id"]):
return jsonify({"error": "You cannot DM yourself."}), 400
if target["status"] in {"disabled", "blacklisted"}:
return jsonify({"error": "User not available."}), 400
dm_id = find_or_create_dm(user["id"], target["id"], now_iso)
audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"]))
return jsonify({"dm": dm_payload(dm_id, user["id"], resolve_name_color)}), 201
@app.route("/api/dms/<int:dm_id>/messages")
def api_get_dm_messages(dm_id: int):
user, error = require_user()
if error:
return error
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
after_raw = request.args.get("after_id", "").strip()
after_id = 0
if after_raw:
try:
after_id = max(0, int(after_raw))
except ValueError:
return jsonify({"error": "after_id must be a non-negative integer."}), 400
with get_db() as db:
if after_raw:
rows = db.execute(
f"""
{MESSAGE_SELECT}
WHERE m.direct_conversation_id = ?
AND m.parent_message_id IS NULL
AND m.id > ?
ORDER BY m.id ASC
LIMIT ?
""",
(dm_id, after_id, MAX_INCREMENTAL_MESSAGES),
).fetchall()
else:
rows = db.execute(
f"""
SELECT *
FROM (
{MESSAGE_SELECT}
WHERE m.direct_conversation_id = ?
AND m.parent_message_id IS NULL
ORDER BY m.id DESC
LIMIT ?
)
ORDER BY id ASC
""",
(dm_id, MAX_HISTORY_MESSAGES),
).fetchall()
messages = [build_message_payload(row, user) for row in rows]
latest_id = messages[-1]["id"] if messages else after_id
return jsonify(
{
"dm": dm_payload(dm_id, user["id"], resolve_name_color),
"messages": messages,
"latest_id": latest_id,
"has_more": len(messages) == MAX_INCREMENTAL_MESSAGES,
}
)
@app.route("/api/dms/<int:dm_id>/messages", methods=["POST"])
def api_send_dm_message(dm_id: int):
limited = rate_limited("send")
if limited:
return limited
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
text = data.get("text", "")
if not isinstance(text, str):
return jsonify({"error": "Message must be text."}), 400
text = text.strip()
if len(text) > MAX_MESSAGE_LENGTH:
return jsonify({"error": "Message is too long."}), 400
attachment_ids, attachment_error = resolve_owned_attachment_ids(
data.get("attachment_ids", []),
user["id"],
)
if attachment_error:
return attachment_error
if not attachment_ids and data.get("attachment_id") is not None:
single_attachment_id, single_attachment_error = resolve_owned_attachment(
data.get("attachment_id"),
user["id"],
)
if single_attachment_error:
return single_attachment_error
attachment_ids = [single_attachment_id] if single_attachment_id else []
attachment_id = attachment_ids[0] if attachment_ids else None
if not text and not attachment_id:
return jsonify({"error": "Message cannot be empty."}), 400
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
cursor = db.execute(
"""
INSERT INTO messages (
room_id,
channel_id,
direct_conversation_id,
user_id,
username,
text,
attachment_id,
parent_message_id,
created_at
) VALUES (NULL, NULL, ?, ?, ?, ?, ?, NULL, ?)
""",
(dm_id, user["id"], user["username"], text, attachment_id, now_iso()),
)
message_id = cursor.lastrowid
for position, extra_attachment_id in enumerate(attachment_ids):
db.execute(
"""
INSERT OR IGNORE INTO message_attachments (
message_id,
attachment_id,
position
) VALUES (?, ?, ?)
""",
(message_id, extra_attachment_id, position),
)
db.commit()
with get_db() as db:
row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
audit("dm_message_sent", user["id"], "dm", str(dm_id), str(message_id))
return jsonify({"message": build_message_payload(row, user)}), 201

189
routes/feed.py Normal file
View File

@ -0,0 +1,189 @@
from .common import (
MAX_FEED_MESSAGES,
current_user,
jsonify,
request,
get_db,
build_feed_payload,
room_by_id,
can_access_room,
get_channel_by_id,
can_access_channel,
validate_dm_access,
)
def register_feed_routes(app):
@app.route("/api/feed")
def get_feed():
user = current_user()
after_raw = request.args.get("after_id", "").strip()
after_id = 0
if after_raw:
try:
after_id = max(0, int(after_raw))
except ValueError:
return jsonify({"error": "after_id must be a non-negative integer."}), 400
base_select = f"""
SELECT
m.id,
m.room_id,
m.channel_id,
m.direct_conversation_id,
m.user_id,
m.username,
m.text,
m.created_at,
m.attachment_id,
m.parent_message_id,
(
SELECT COUNT(*)
FROM messages AS thread_reply
WHERE thread_reply.parent_message_id = m.id
) AS thread_reply_count,
r.slug AS room_slug,
r.name AS room_name,
r.visibility AS room_visibility,
c.slug AS channel_slug,
c.name AS channel_name,
t.slug AS team_slug,
t.name AS team_name,
a.original_name AS attachment_original_name,
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
FROM messages AS m
JOIN users AS u
ON u.id = m.user_id
LEFT JOIN user_settings AS us
ON us.user_id = u.id
LEFT JOIN rooms AS r
ON r.id = m.room_id
LEFT JOIN channels AS c
ON c.id = m.channel_id
AND c.is_active = 1
LEFT JOIN teams AS t
ON t.id = c.team_id
AND t.is_active = 1
LEFT JOIN attachments AS a
ON a.id = m.attachment_id
"""
params = []
if user and user["role"] in {"moderator", "admin"}:
access_clause = """
(
(m.direct_conversation_id IS NULL AND r.id IS NOT NULL AND r.is_active = 1)
OR
(m.direct_conversation_id IS NULL AND c.id IS NOT NULL AND c.is_active = 1 AND t.is_active = 1)
OR
(
m.direct_conversation_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM direct_conversation_members AS dcm
WHERE dcm.direct_conversation_id = m.direct_conversation_id
AND dcm.user_id = ?
)
)
)
"""
params.append(user["id"])
elif user:
access_clause = """
(
(
m.direct_conversation_id IS NULL
AND r.id IS NOT NULL
AND r.is_active = 1
AND (
r.visibility = 'public'
OR EXISTS (
SELECT 1
FROM room_members AS rm
WHERE rm.room_id = r.id
AND rm.user_id = ?
)
)
)
OR
(
m.direct_conversation_id IS NULL
AND c.id IS NOT NULL
AND c.is_active = 1
AND t.is_active = 1
AND c.legacy_room_id IS NULL
AND EXISTS (
SELECT 1
FROM team_members AS tm
WHERE tm.team_id = c.team_id
AND tm.user_id = ?
)
)
OR
(
m.direct_conversation_id IS NOT NULL
AND EXISTS (
SELECT 1
FROM direct_conversation_members AS dcm
WHERE dcm.direct_conversation_id = m.direct_conversation_id
AND dcm.user_id = ?
)
)
)
"""
params.extend([user["id"], user["id"], user["id"]])
else:
access_clause = """
m.direct_conversation_id IS NULL
AND r.id IS NOT NULL
AND r.is_active = 1
AND r.visibility = 'public'
"""
if after_raw:
query = f"""
{base_select}
WHERE m.parent_message_id IS NULL
AND {access_clause}
AND m.id > ?
ORDER BY m.id ASC
LIMIT ?
"""
query_params = [*params, after_id, MAX_FEED_MESSAGES]
with get_db() as db:
rows = db.execute(query, tuple(query_params)).fetchall()
entries = [build_feed_payload(row, user) for row in rows]
latest_id = entries[-1]["id"] if entries else after_id
return jsonify(
{
"entries": entries,
"latest_id": latest_id,
"has_more": len(entries) == MAX_FEED_MESSAGES,
}
)
query = f"""
SELECT *
FROM (
{base_select}
WHERE m.parent_message_id IS NULL
AND {access_clause}
ORDER BY m.id DESC
LIMIT ?
)
ORDER BY id ASC
"""
query_params = [*params, MAX_FEED_MESSAGES]
with get_db() as db:
rows = db.execute(query, tuple(query_params)).fetchall()
entries = [build_feed_payload(row, user) for row in rows]
latest_id = entries[-1]["id"] if entries else 0
return jsonify({"entries": entries, "latest_id": latest_id, "has_more": False})

1179
routes/messages.py Normal file

File diff suppressed because it is too large Load Diff

336
routes/presence.py Normal file
View File

@ -0,0 +1,336 @@
import time
from .common import (
TYPING_TTL_SECONDS,
PRESENCE_TTL_SECONDS,
csrf_valid,
rate_limited,
current_user,
require_user,
require_writable_user,
safe_json,
jsonify,
get_db,
db_write,
get_room_by_slug,
can_access_room,
can_write_room,
get_channel_by_slug,
can_access_channel,
can_write_channel,
validate_dm_access,
update_presence,
room_presence_count,
channel_presence_count,
dm_presence_count,
)
def register_presence_routes(app):
@app.route("/api/presence", methods=["POST"])
def api_presence_ping():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_user()
if error:
return error
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
view_name = str(data.get("view", "")).strip()[:32] or None
room_id = data.get("room_id")
channel_id = data.get("channel_id")
dm_id = data.get("dm_id")
try:
room_id = int(room_id) if room_id is not None else None
except (TypeError, ValueError):
room_id = None
try:
channel_id = int(channel_id) if channel_id is not None else None
except (TypeError, ValueError):
channel_id = None
try:
dm_id = int(dm_id) if dm_id is not None else None
except (TypeError, ValueError):
dm_id = None
update_presence(db_write, user["id"], view_name, room_id, channel_id, dm_id)
return jsonify({"status": "ok"})
@app.route("/api/rooms/<string:slug>/presence")
def api_room_presence(slug: str):
room = get_room_by_slug(slug)
user = current_user()
if not room or not can_access_room(room, user):
return jsonify({"error": "Room not found."}), 404
return jsonify(
{
"room_id": room["id"],
"online_count": room_presence_count(PRESENCE_TTL_SECONDS, room["id"]),
}
)
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/presence")
def api_channel_presence(team_slug: str, channel_slug: str):
channel = get_channel_by_slug(team_slug, channel_slug)
user = current_user()
if not channel or not can_access_channel(channel, user):
return jsonify({"error": "Channel not found."}), 404
return jsonify(
{
"channel_id": channel["id"],
"online_count": channel_presence_count(PRESENCE_TTL_SECONDS, channel["id"]),
}
)
@app.route("/api/dms/<int:dm_id>/presence")
def api_dm_presence(dm_id: int):
user = current_user()
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
return jsonify(
{
"dm_id": dm_id,
"online_count": dm_presence_count(PRESENCE_TTL_SECONDS, dm_id),
}
)
@app.route("/api/rooms/<string:slug>/typing", methods=["POST"])
def set_typing(slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
limited = rate_limited("typing")
if limited:
return limited
room = get_room_by_slug(slug)
if not room or not can_write_room(room, user):
return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
typing = bool(data.get("typing"))
if typing:
db_write(
"""
INSERT INTO typing_events (
room_id,
user_id,
updated_at
) VALUES (?, ?, ?)
ON CONFLICT(room_id, user_id)
DO UPDATE SET updated_at = excluded.updated_at
""",
(room["id"], user["id"], time.time()),
)
else:
db_write(
"DELETE FROM typing_events WHERE room_id = ? AND user_id = ?",
(room["id"], user["id"]),
)
return jsonify({"status": "ok"})
@app.route("/api/rooms/<string:slug>/typing")
def get_typing(slug: str):
room = get_room_by_slug(slug)
user = current_user()
if not room or not can_access_room(room, user):
return jsonify({"error": "Room not found."}), 404
cutoff = time.time() - TYPING_TTL_SECONDS
with get_db() as db:
db.execute("DELETE FROM typing_events WHERE updated_at < ?", (cutoff,))
rows = db.execute(
"""
SELECT
u.id,
u.username
FROM typing_events AS t
JOIN users AS u
ON u.id = t.user_id
WHERE t.room_id = ?
AND t.user_id != ?
AND t.updated_at >= ?
ORDER BY u.username COLLATE NOCASE
""",
(room["id"], user["id"] if user else -1, cutoff),
).fetchall()
db.commit()
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/typing", methods=["POST"])
def api_set_channel_typing(team_slug: str, channel_slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
limited = rate_limited("typing")
if limited:
return limited
channel = get_channel_by_slug(team_slug, channel_slug)
if not channel:
return jsonify({"error": "Channel not found."}), 404
if not can_write_channel(channel, user):
return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
typing = bool(data.get("typing"))
if typing:
db_write(
"""
INSERT INTO channel_typing_events (
channel_id,
user_id,
updated_at
) VALUES (?, ?, ?)
ON CONFLICT(channel_id, user_id)
DO UPDATE SET updated_at = excluded.updated_at
""",
(channel["id"], user["id"], time.time()),
)
else:
db_write(
"DELETE FROM channel_typing_events WHERE channel_id = ? AND user_id = ?",
(channel["id"], user["id"]),
)
return jsonify({"status": "ok"})
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/typing")
def api_get_channel_typing(team_slug: str, channel_slug: str):
channel = get_channel_by_slug(team_slug, channel_slug)
if not channel:
return jsonify({"error": "Channel not found."}), 404
user = current_user()
if not can_access_channel(channel, user):
if not user:
return jsonify({"error": "Login required.", "code": "login_required"}), 401
return jsonify({"error": "You do not have access to this channel.", "code": "forbidden"}), 403
cutoff = time.time() - TYPING_TTL_SECONDS
with get_db() as db:
db.execute("DELETE FROM channel_typing_events WHERE updated_at < ?", (cutoff,))
rows = db.execute(
"""
SELECT
u.id,
u.username
FROM channel_typing_events AS t
JOIN users AS u
ON u.id = t.user_id
WHERE t.channel_id = ?
AND t.user_id != ?
AND t.updated_at >= ?
ORDER BY u.username COLLATE NOCASE
""",
(channel["id"], user["id"] if user else -1, cutoff),
).fetchall()
db.commit()
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
@app.route("/api/dms/<int:dm_id>/typing", methods=["POST"])
def api_set_dm_typing(dm_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
limited = rate_limited("typing")
if limited:
return limited
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
typing = bool(data.get("typing"))
if typing:
db_write(
"""
INSERT INTO dm_typing_events (
direct_conversation_id,
user_id,
updated_at
) VALUES (?, ?, ?)
ON CONFLICT(direct_conversation_id, user_id)
DO UPDATE SET updated_at = excluded.updated_at
""",
(dm_id, user["id"], time.time()),
)
else:
db_write(
"""
DELETE FROM dm_typing_events
WHERE direct_conversation_id = ?
AND user_id = ?
""",
(dm_id, user["id"]),
)
return jsonify({"status": "ok"})
@app.route("/api/dms/<int:dm_id>/typing")
def api_get_dm_typing(dm_id: int):
user = current_user()
if not validate_dm_access(dm_id, user):
return jsonify({"error": "DM not found."}), 404
cutoff = time.time() - TYPING_TTL_SECONDS
with get_db() as db:
db.execute("DELETE FROM dm_typing_events WHERE updated_at < ?", (cutoff,))
rows = db.execute(
"""
SELECT
u.id,
u.username
FROM dm_typing_events AS t
JOIN users AS u
ON u.id = t.user_id
WHERE t.direct_conversation_id = ?
AND t.user_id != ?
AND t.updated_at >= ?
ORDER BY u.username COLLATE NOCASE
""",
(dm_id, user["id"], cutoff),
).fetchall()
db.commit()
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})

518
routes/profiles.py Normal file
View File

@ -0,0 +1,518 @@
from .common import (
MAX_CUSTOM_CSS_LENGTH,
MAX_DISPLAY_NAME_LENGTH,
MAX_LOCATION_LENGTH,
MAX_MESSAGE_LENGTH,
MAX_PROFILE_BADGE_LENGTH,
MAX_PROFILE_BIO_LENGTH,
MAX_WEBSITE_LENGTH,
DM_LAYOUT_MODES,
LAST_ACTIVE_VISIBILITIES,
PRESENCE_MODES,
PROFILE_BIO_MODES,
PROFILE_VISIBILITIES,
THEME_MODES,
now_iso,
csrf_valid,
rate_limited,
current_user,
require_user,
require_writable_user,
normalize_username,
audit,
user_payload,
safe_json,
jsonify,
get_db,
db_write,
profile_payload,
can_view_profile_record,
valid_hex_color,
normalized_hex_color,
)
def register_profile_routes(app):
@app.route("/api/profile")
def api_get_my_profile():
user, error = require_user()
if error:
return error
with get_db() as db:
user_row = db.execute(
"""
SELECT
id,
username,
role,
status,
restricted_until,
created_at,
last_login_at
FROM users
WHERE id = ?
""",
(user["id"],),
).fetchone()
profile_row = db.execute(
"""
SELECT
user_id,
display_name,
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
updated_at
FROM user_profiles
WHERE user_id = ?
""",
(user["id"],),
).fetchone()
settings_row = db.execute(
"""
SELECT
name_color,
presence_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
dm_layout_mode,
show_presence_badge
FROM user_settings
WHERE user_id = ?
""",
(user["id"],),
).fetchone()
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)})
@app.route("/api/profile", methods=["PATCH"])
def api_update_my_profile():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
limited = rate_limited("profile")
if limited:
return limited
user, error = require_writable_user()
if error:
return error
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
display_name = str(data.get("display_name", "")).strip()
badge_text = str(data.get("badge_text", "")).strip()
bio = str(data.get("bio", "")).strip()
bio_mode = str(data.get("bio_mode", "txt")).strip().lower()
website = str(data.get("website", "")).strip()
location = str(data.get("location", "")).strip()
status_message = str(data.get("status_message", "")).strip()
banner_attachment_id = data.get("banner_attachment_id")
if banner_attachment_id not in (None, ""):
try:
banner_attachment_id = int(banner_attachment_id)
except (TypeError, ValueError):
return jsonify({"error": "Invalid banner attachment."}), 400
else:
banner_attachment_id = None
if len(display_name) > MAX_DISPLAY_NAME_LENGTH:
return jsonify({"error": "Display name is too long."}), 400
if len(badge_text) > MAX_PROFILE_BADGE_LENGTH:
return jsonify({"error": "Badge text is too long."}), 400
if len(bio) > MAX_PROFILE_BIO_LENGTH:
return jsonify({"error": "Bio is too long."}), 400
if bio_mode not in PROFILE_BIO_MODES:
return jsonify({"error": "Invalid bio mode."}), 400
if len(website) > MAX_WEBSITE_LENGTH:
return jsonify({"error": "Website is too long."}), 400
if len(location) > MAX_LOCATION_LENGTH:
return jsonify({"error": "Location is too long."}), 400
if len(status_message) > 160:
return jsonify({"error": "Status message is too long."}), 400
if banner_attachment_id is not None:
with get_db() as db:
attachment = db.execute(
"""
SELECT id, uploader_user_id
FROM attachments
WHERE id = ?
""",
(banner_attachment_id,),
).fetchone()
if not attachment:
return jsonify({"error": "Banner attachment not found."}), 404
if attachment["uploader_user_id"] != user["id"]:
return jsonify({"error": "You can only use your own uploaded image."}), 403
db_write(
"""
UPDATE user_profiles
SET
display_name = ?,
badge_text = ?,
bio = ?,
bio_mode = ?,
website = ?,
location = ?,
status_message = ?,
banner_attachment_id = ?,
updated_at = ?
WHERE user_id = ?
""",
(
display_name or user["username"],
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
now_iso(),
user["id"],
),
)
with get_db() as db:
user_row = db.execute(
"""
SELECT
id,
username,
role,
status,
restricted_until,
created_at,
last_login_at
FROM users
WHERE id = ?
""",
(user["id"],),
).fetchone()
profile_row = db.execute(
"""
SELECT
user_id,
display_name,
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
updated_at
FROM user_profiles
WHERE user_id = ?
""",
(user["id"],),
).fetchone()
settings_row = db.execute(
"""
SELECT
name_color,
presence_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
dm_layout_mode,
show_presence_badge
FROM user_settings
WHERE user_id = ?
""",
(user["id"],),
).fetchone()
audit("profile_updated", user["id"], "user", str(user["id"]))
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)})
@app.route("/api/profiles/<string:username>")
def api_get_public_profile(username: str):
normalized = normalize_username(username)
viewer = current_user()
with get_db() as db:
user_row = db.execute(
"""
SELECT
id,
username,
role,
status,
restricted_until,
created_at,
last_login_at
FROM users
WHERE username = ?
""",
(normalized,),
).fetchone()
if not user_row:
return jsonify({"error": "Profile not found."}), 404
profile_row = db.execute(
"""
SELECT
user_id,
display_name,
badge_text,
bio,
bio_mode,
website,
location,
status_message,
banner_attachment_id,
updated_at
FROM user_profiles
WHERE user_id = ?
""",
(user_row["id"],),
).fetchone()
settings_row = db.execute(
"""
SELECT
name_color,
presence_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
dm_layout_mode,
show_presence_badge
FROM user_settings
WHERE user_id = ?
""",
(user_row["id"],),
).fetchone()
if not can_view_profile_record(settings_row, viewer, user_row["id"]):
if not viewer:
return jsonify({"error": "Login required.", "code": "login_required"}), 401
return jsonify({"error": "You do not have access to this profile.", "code": "forbidden"}), 403
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, viewer)})
@app.route("/api/settings")
def api_get_settings():
user, error = require_user()
if error:
return error
with get_db() as db:
row = db.execute(
"""
SELECT
accent_color,
name_color,
custom_css,
theme_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
presence_mode,
dm_layout_mode,
show_presence_badge,
updated_at
FROM user_settings
WHERE user_id = ?
""",
(user["id"],),
).fetchone()
return jsonify(
{
"settings": {
"accent_color": row["accent_color"],
"name_color": row["name_color"],
"custom_css": row["custom_css"],
"theme_mode": row["theme_mode"],
"profile_background_color": row["profile_background_color"],
"profile_banner_color": row["profile_banner_color"],
"profile_visibility": row["profile_visibility"],
"last_active_visibility": row["last_active_visibility"],
"presence_mode": row["presence_mode"],
"dm_layout_mode": row["dm_layout_mode"],
"show_presence_badge": bool(row["show_presence_badge"]),
"updated_at": row["updated_at"],
}
}
)
@app.route("/api/settings", methods=["PATCH"])
def api_update_settings():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
limited = rate_limited("settings")
if limited:
return limited
user, error = require_writable_user()
if error:
return error
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
accent_color = data.get("accent_color", "#f7dce8")
name_color = data.get("name_color")
custom_css = str(data.get("custom_css", ""))
theme_mode = str(data.get("theme_mode", "dark")).strip().lower()
profile_background_color = data.get("profile_background_color")
profile_banner_color = data.get("profile_banner_color")
profile_visibility = str(data.get("profile_visibility", "public")).strip().lower()
last_active_visibility = str(data.get("last_active_visibility", "everyone")).strip().lower()
presence_mode = str(data.get("presence_mode", "online")).strip().lower()
dm_layout_mode = str(data.get("dm_layout_mode", "cards")).strip().lower()
show_presence_badge_raw = data.get("show_presence_badge", True)
if not valid_hex_color(accent_color):
return jsonify({"error": "Invalid accent color."}), 400
if name_color not in (None, "") and not valid_hex_color(name_color):
return jsonify({"error": "Invalid name color."}), 400
if profile_background_color not in (None, "") and not valid_hex_color(profile_background_color):
return jsonify({"error": "Invalid profile background color."}), 400
if profile_banner_color not in (None, "") and not valid_hex_color(profile_banner_color):
return jsonify({"error": "Invalid profile banner color."}), 400
if len(custom_css) > MAX_CUSTOM_CSS_LENGTH:
return jsonify({"error": "Custom CSS is too long."}), 400
if theme_mode not in THEME_MODES:
return jsonify({"error": "Invalid theme mode."}), 400
if profile_visibility not in PROFILE_VISIBILITIES:
return jsonify({"error": "Invalid profile visibility."}), 400
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
return jsonify({"error": "Invalid last active visibility."}), 400
if presence_mode not in PRESENCE_MODES:
return jsonify({"error": "Invalid presence mode."}), 400
if dm_layout_mode not in DM_LAYOUT_MODES:
return jsonify({"error": "Invalid dm layout mode."}), 400
show_presence_badge = 1 if bool(show_presence_badge_raw) else 0
db_write(
"""
UPDATE user_settings
SET
accent_color = ?,
name_color = ?,
custom_css = ?,
theme_mode = ?,
profile_background_color = ?,
profile_banner_color = ?,
profile_visibility = ?,
last_active_visibility = ?,
presence_mode = ?,
dm_layout_mode = ?,
show_presence_badge = ?,
updated_at = ?
WHERE user_id = ?
""",
(
str(accent_color).lower(),
normalized_hex_color(name_color) if name_color else None,
custom_css,
theme_mode,
normalized_hex_color(profile_background_color) if profile_background_color else None,
normalized_hex_color(profile_banner_color) if profile_banner_color else None,
profile_visibility,
last_active_visibility,
presence_mode,
dm_layout_mode,
show_presence_badge,
now_iso(),
user["id"],
),
)
with get_db() as db:
row = db.execute(
"""
SELECT
accent_color,
name_color,
custom_css,
theme_mode,
profile_background_color,
profile_banner_color,
profile_visibility,
last_active_visibility,
presence_mode,
dm_layout_mode,
show_presence_badge,
updated_at
FROM user_settings
WHERE user_id = ?
""",
(user["id"],),
).fetchone()
audit("settings_updated", user["id"], "user", str(user["id"]))
return jsonify(
{
"settings": {
**dict(row),
"show_presence_badge": bool(row["show_presence_badge"]),
}
}
)
@app.route("/api/scheduled-messages", methods=["POST"])
def api_create_scheduled_message():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
text = str(data.get("text", "")).strip()
deliver_at = str(data.get("deliver_at", "")).strip()
scope_kind = str(data.get("scope_kind", "")).strip()
scope_id = data.get("scope_id")
if not text:
return jsonify({"error": "Scheduled message text is required."}), 400
if len(text) > MAX_MESSAGE_LENGTH:
return jsonify({"error": "Scheduled message is too long."}), 400
if not deliver_at:
return jsonify({"error": "deliver_at is required."}), 400
return jsonify(
{
"status": "stubbed",
"scheduled_message": {
"id": 0,
"text": text,
"deliver_at": deliver_at,
"scope_kind": scope_kind,
"scope_id": scope_id,
}
}
), 201

526
routes/rooms.py Normal file
View File

@ -0,0 +1,526 @@
import re
from .common import (
INVITE_TTL_HOURS,
ROOM_SLUG_RE,
ROOM_VISIBILITIES,
now_iso,
parse_iso,
utc_now,
csrf_valid,
rate_limited,
current_user,
require_admin,
require_writable_user,
audit,
hash_identifier,
normalize_username,
safe_json,
jsonify,
get_db,
db_write,
get_room_by_slug,
room_payload,
can_access_room,
can_manage_room,
get_channel_by_legacy_room_id,
)
def register_room_routes(app):
@app.route("/api/rooms")
def list_rooms():
user = current_user()
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
slug,
name,
description,
visibility,
created_by_user_id,
is_active
FROM rooms
WHERE is_active = 1
ORDER BY
CASE WHEN slug = 'general' THEN 0 ELSE 1 END,
name COLLATE NOCASE
"""
).fetchall()
rooms = [room_payload(room, user) for room in rows if can_access_room(room, user)]
return jsonify({"rooms": rooms, "authenticated": bool(user)})
@app.route("/api/rooms", methods=["POST"])
def create_room():
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
limited = rate_limited("admin")
if limited:
return limited
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
name = str(data.get("name", "")).strip()
description = str(data.get("description", "")).strip()[:300]
visibility = data.get("visibility", "public")
if not name or len(name) > 60:
return jsonify({"error": "Room name must be 1 to 60 characters."}), 400
if visibility not in ROOM_VISIBILITIES:
return jsonify({"error": "Invalid room visibility."}), 400
base_slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")
base_slug = base_slug[:48].strip("-")
if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug):
return jsonify({"error": "Room name cannot create a valid room slug."}), 400
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
slug = base_slug
suffix = 2
while db.execute("SELECT 1 FROM rooms WHERE slug = ?", (slug,)).fetchone():
slug = f"{base_slug[:44]}-{suffix}"
suffix += 1
cursor = db.execute(
"""
INSERT INTO rooms (
slug,
name,
description,
visibility,
created_by_user_id,
created_at,
is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
""",
(slug, name, description, visibility, admin["id"], now_iso()),
)
room_id = cursor.lastrowid
db.execute(
"""
INSERT INTO room_members (
room_id,
user_id,
membership_role,
added_at
) VALUES (?, ?, 'owner', ?)
""",
(room_id, admin["id"], now_iso()),
)
general_team = db.execute(
"SELECT id FROM teams WHERE slug = 'general' AND is_active = 1"
).fetchone()
if general_team:
db.execute(
"""
INSERT INTO channels (
team_id,
legacy_room_id,
slug,
name,
description,
created_by_user_id,
created_at,
is_active
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
""",
(
general_team["id"],
room_id,
slug,
name,
description,
admin["id"],
now_iso(),
),
)
db.commit()
audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}")
return jsonify(
{
"room": {
"id": room_id,
"slug": slug,
"name": name,
"description": description,
"visibility": visibility,
"can_read": True,
"can_write": True,
"can_manage": True,
}
}
), 201
@app.route("/api/rooms/<string:slug>", methods=["PATCH"])
def update_room(slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
limited = rate_limited("admin")
if limited:
return limited
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if room["slug"] == "general" and user["role"] != "admin":
return jsonify({"error": "Only an administrator can modify the default room."}), 403
if not can_manage_room(room, user):
return jsonify({"error": "Room owner or administrator access required.", "code": "forbidden"}), 403
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
name = str(data.get("name", room["name"])).strip()
description = str(data.get("description", room["description"] or "")).strip()[:300]
visibility = data.get("visibility", room["visibility"])
if not name or len(name) > 60:
return jsonify({"error": "Room name must be 1 to 60 characters."}), 400
if visibility not in ROOM_VISIBILITIES:
return jsonify({"error": "Invalid room visibility."}), 400
db_write(
"UPDATE rooms SET name = ?, description = ?, visibility = ? WHERE id = ?",
(name, description, visibility, room["id"]),
)
db_write(
"UPDATE channels SET name = ?, description = ? WHERE legacy_room_id = ?",
(name, description, room["id"]),
)
audit("room_updated", user["id"], "room", str(room["id"]), f"name={name};visibility={visibility}")
return jsonify({"status": "updated"})
@app.route("/api/rooms/<string:slug>", methods=["DELETE"])
def delete_room(slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
limited = rate_limited("admin")
if limited:
return limited
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if room["slug"] == "general":
return jsonify({"error": "The default room cannot be deleted."}), 400
db_write("DELETE FROM rooms WHERE id = ?", (room["id"],))
audit("room_deleted", admin["id"], "room", str(room["id"]), room["slug"])
return jsonify({"status": "deleted"})
@app.route("/api/rooms/<string:slug>/messages", methods=["DELETE"])
def clear_room_messages(slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
moderator, error = require_admin()
if error:
return error
limited = rate_limited("admin")
if limited:
return limited
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
cursor = db_write(
"""
DELETE FROM messages
WHERE room_id = ?
AND direct_conversation_id IS NULL
""",
(room["id"],),
)
audit("room_messages_cleared", moderator["id"], "room", str(room["id"]), str(cursor.rowcount))
return jsonify({"status": "cleared", "deleted_messages": cursor.rowcount})
@app.route("/api/rooms/<string:slug>/members")
def list_room_members(slug: str):
admin, error = require_admin()
if error:
return error
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if room["visibility"] != "private":
return jsonify({"error": "This room is public."}), 400
with get_db() as db:
rows = db.execute(
"""
SELECT
u.id,
u.username,
u.role,
rm.membership_role,
rm.added_at
FROM room_members AS rm
JOIN users AS u
ON u.id = rm.user_id
WHERE rm.room_id = ?
ORDER BY
CASE rm.membership_role
WHEN 'owner' THEN 0
ELSE 1
END,
u.username COLLATE NOCASE
""",
(room["id"],),
).fetchall()
return jsonify(
{
"room": room_payload(room, admin),
"members": [
{
"id": row["id"],
"username": row["username"],
"role": row["role"],
"membership_role": row["membership_role"],
"added_at": row["added_at"],
}
for row in rows
],
}
)
@app.route("/api/rooms/<string:slug>/members", methods=["POST"])
def add_room_member(slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if room["visibility"] != "private":
return jsonify({"error": "Members can only be managed for private rooms."}), 400
try:
data = safe_json()
except ValueError as error:
return jsonify({"error": str(error)}), 400
username = normalize_username(data.get("username"))
with get_db() as db:
target = db.execute(
"SELECT id, username FROM users WHERE username = ?",
(username,),
).fetchone()
if not target:
return jsonify({"error": "User not found."}), 404
db_write(
"""
INSERT OR IGNORE INTO room_members (
room_id,
user_id,
membership_role,
added_at
) VALUES (?, ?, 'member', ?)
""",
(room["id"], target["id"], now_iso()),
)
audit("room_member_added", admin["id"], "room", str(room["id"]), target["username"])
return jsonify({"status": "added"}), 201
@app.route("/api/rooms/<string:slug>/members/<int:user_id>", methods=["DELETE"])
def remove_room_member(slug: str, user_id: int):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
with get_db() as db:
member = db.execute(
"""
SELECT membership_role
FROM room_members
WHERE room_id = ?
AND user_id = ?
""",
(room["id"], user_id),
).fetchone()
if not member:
return jsonify({"error": "Room member not found."}), 404
if member["membership_role"] == "owner":
return jsonify({"error": "The room owner cannot be removed."}), 400
db_write(
"DELETE FROM room_members WHERE room_id = ? AND user_id = ?",
(room["id"], user_id),
)
audit("room_member_removed", admin["id"], "room", str(room["id"]), str(user_id))
return jsonify({"status": "removed"})
@app.route("/api/rooms/<string:slug>/invites", methods=["POST"])
def create_room_invite(slug: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
admin, error = require_admin()
if error:
return error
room = get_room_by_slug(slug)
if not room:
return jsonify({"error": "Room not found."}), 404
if room["visibility"] != "private":
return jsonify({"error": "Invite links are only available for private rooms."}), 400
token = app.secrets.token_urlsafe(32)
expires_at = (utc_now() + app.timedelta(hours=INVITE_TTL_HOURS)).isoformat()
db_write(
"""
INSERT INTO room_invites (
room_id,
token_hash,
created_by_user_id,
created_at,
expires_at
) VALUES (?, ?, ?, ?, ?)
""",
(room["id"], hash_identifier(token), admin["id"], now_iso(), expires_at),
)
audit("room_invite_created", admin["id"], "room", str(room["id"]))
return jsonify({"invite": {"url": f"/?invite={token}", "expires_at": expires_at}}), 201
@app.route("/api/invites/<string:token>/accept", methods=["POST"])
def accept_room_invite(token: str):
if not csrf_valid():
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
user, error = require_writable_user()
if error:
return error
token_hash = hash_identifier(token)
with get_db() as db:
invite = db.execute(
"""
SELECT
ri.id,
ri.room_id,
ri.expires_at,
ri.used_at,
r.slug,
r.name,
r.description,
r.visibility,
r.created_by_user_id,
r.is_active
FROM room_invites AS ri
JOIN rooms AS r
ON r.id = ri.room_id
WHERE ri.token_hash = ?
""",
(token_hash,),
).fetchone()
if not invite:
return jsonify({"error": "Invite link is invalid."}), 404
if invite["used_at"] or parse_iso(invite["expires_at"]) <= utc_now():
return jsonify({"error": "Invite link has expired or was already used."}), 400
if invite["visibility"] != "private" or not invite["is_active"]:
return jsonify({"error": "This room is no longer available."}), 404
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
fresh = db.execute(
"SELECT id, used_at, expires_at FROM room_invites WHERE id = ?",
(invite["id"],),
).fetchone()
if not fresh or fresh["used_at"] or parse_iso(fresh["expires_at"]) <= utc_now():
db.rollback()
return jsonify({"error": "Invite link has expired or was already used."}), 400
db.execute(
"""
INSERT OR IGNORE INTO room_members (
room_id,
user_id,
membership_role,
added_at
) VALUES (?, ?, 'member', ?)
""",
(invite["room_id"], user["id"], now_iso()),
)
db.execute(
"""
UPDATE room_invites
SET
used_at = ?,
used_by_user_id = ?
WHERE id = ?
""",
(now_iso(), user["id"], invite["id"]),
)
db.commit()
audit("room_invite_accepted", user["id"], "room", str(invite["room_id"]))
room = get_room_by_slug(invite["slug"])
return jsonify({"status": "joined", "room": room_payload(room, user)})

1203
routes/teams.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,97 +0,0 @@
import html
from html.parser import HTMLParser
_ALLOWED_TAGS = {
"b",
"strong",
"i",
"em",
"u",
"p",
"br",
"ul",
"ol",
"li",
"blockquote",
"code",
"pre",
"a",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
}
_ALLOWED_ATTRS = {
"a": {"href", "title", "target", "rel"},
}
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
def _escape_attr(value: str) -> str:
return html.escape(value, quote=True)
def sanitize_html_fragment(raw: str) -> str:
class SafeHtmlParser(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.parts = []
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag not in _ALLOWED_TAGS:
return
allowed = _ALLOWED_ATTRS.get(tag, set())
clean_attrs = []
for key, value in attrs:
key = (key or "").lower()
value = value or ""
if key not in allowed:
continue
if tag == "a" and key == "href":
lowered = value.strip().lower()
if not lowered.startswith(_ALLOWED_SCHEMES):
continue
clean_attrs.append((key, value))
if tag == "a":
attr_keys = {key for key, _ in clean_attrs}
if "target" not in attr_keys:
clean_attrs.append(("target", "_blank"))
if "rel" not in attr_keys:
clean_attrs.append(("rel", "noopener noreferrer"))
attr_text = "".join(
f' {key}="{_escape_attr(value)}"'
for key, value in clean_attrs
)
self.parts.append(f"<{tag}{attr_text}>")
def handle_endtag(self, tag):
tag = tag.lower()
if tag in _ALLOWED_TAGS and tag not in {"br"}:
self.parts.append(f"</{tag}>")
def handle_data(self, data):
self.parts.append(html.escape(data))
def handle_entityref(self, name):
self.parts.append(f"&{name};")
def handle_charref(self, name):
self.parts.append(f"&#{name};")
parser = SafeHtmlParser()
parser.feed(raw or "")
parser.close()
return "".join(parser.parts)

View File

@ -1,2 +0,0 @@
ADMIN_USERNAME=admin
ADMIN_PASSWORD=StrongPass123!

View File

@ -1,6 +1,8 @@
// static/js/00-config.js
"use strict";
window.Glow = window.Glow || {};
Glow.els = {
chatBox: document.getElementById("chatBox"),
dmBox: document.getElementById("dmBox"),
@ -39,6 +41,7 @@ Glow.els = {
themeButton: document.getElementById("themeButton"),
adminUsers: document.getElementById("adminUsers"),
adminRooms: document.getElementById("adminRooms"),
adminTeamsChannels: document.getElementById("adminTeamsChannels"),
statusToast: document.getElementById("statusToast"),
blockedOverlay: document.getElementById("blockedOverlay"),
blockedMessage: document.getElementById("blockedMessage"),
@ -85,15 +88,48 @@ Glow.els = {
callOverlay: document.getElementById("callOverlay"),
callStatus: document.getElementById("callStatus"),
callPeerLabel: document.getElementById("callPeerLabel"),
callParticipants: document.getElementById("callParticipants"),
callPendingInvites: document.getElementById("callPendingInvites"),
remoteAudioContainer: document.getElementById("remoteAudioContainer"),
callStatusChip: document.getElementById("callStatusChip"),
startCallButton: document.getElementById("startCallButton"),
answerCallButton: document.getElementById("answerCallButton"),
retryCallButton: document.getElementById("retryCallButton"),
addToCallButton: document.getElementById("addToCallButton"),
muteCallButton: document.getElementById("muteCallButton"),
hangupCallButton: document.getElementById("hangupCallButton"),
endCallButton: document.getElementById("endCallButton"),
closeCallPanelButton: document.getElementById("closeCallPanelButton"),
remoteAudio: document.getElementById("remoteAudio"),
emojiAutocomplete: document.getElementById("emojiAutocomplete")
dmLayoutToggle: document.getElementById("dmLayoutToggle"),
emojiAutocomplete: document.getElementById("emojiAutocomplete"),
threadEmojiAutocomplete: document.getElementById("threadEmojiAutocomplete"),
settingsSavePanel: document.getElementById("settingsSavePanel"),
settingsSaveButton: document.getElementById("settingsSaveButton"),
settingsCloseButton: document.getElementById("settingsCloseButton"),
settingsUnsavedIndicator: document.getElementById("settingsUnsavedIndicator"),
avatarUploadInput: document.getElementById("avatarUploadInput"),
bannerUploadInput: document.getElementById("bannerUploadInput"),
imageCropOverlay: document.getElementById("imageCropOverlay"),
imageCropPreview: document.getElementById("imageCropPreview"),
imageCropZoom: document.getElementById("imageCropZoom"),
imageCropAuto: document.getElementById("imageCropAuto"),
imageCropApply: document.getElementById("imageCropApply"),
imageCropCancel: document.getElementById("imageCropCancel"),
adminTabRooms: document.getElementById("adminTabRooms"),
adminTabUsers: document.getElementById("adminTabUsers"),
adminTabTeams: document.getElementById("adminTabTeams"),
adminRoomsPanel: document.getElementById("adminRoomsPanel"),
adminUsersPanel: document.getElementById("adminUsersPanel"),
adminTeamsPanel: document.getElementById("adminTeamsPanel"),
callDragHandle: document.getElementById("callDragHandle"),
callSummaryStats: document.getElementById("callSummaryStats"),
callLatencyStat: document.getElementById("callLatencyStat"),
callPacketLossStat: document.getElementById("callPacketLossStat"),
callUptimeStat: document.getElementById("callUptimeStat"),
callUserCountStat: document.getElementById("callUserCountStat"),
callElapsedStat: document.getElementById("callElapsedStat")
};
Glow.state = {
currentUser: null,
csrfToken: "",
@ -129,6 +165,9 @@ Glow.state = {
pageTypeFilter: "",
activeProfile: null,
settings: null,
settingsSaved: null,
settingsDraft: null,
settingsDirty: false,
pollTimer: null,
initialized: false,
toastTimer: null,
@ -148,8 +187,33 @@ Glow.state = {
originalTitle: document.title,
dialogResolver: null,
lastFocusedElement: null,
emojiSuggestions: [],
emojiActiveInput: null,
dmLayoutMode:
localStorage.getItem("glow_dm_layout_mode") === "compact"
? "compact"
: "cards",
profilePresenceBadgeVisible:
localStorage.getItem("glow_profile_presence_badge_visible") !== "false",
profileMediaDraft: {
avatarFile: null,
bannerFile: null
},
imageCrop: {
open: false,
kind: "",
file: null,
previewUrl: "",
zoom: 1,
autoCrop: false
},
emojiAutocomplete: {
open: false,
owner: "main",
query: "",
suggestions: [],
selectedIndex: 0,
triggerIndex: -1
},
adminTab: "rooms",
presence: {
roomOnlineCount: 0,
channelOnlineCount: 0,
@ -164,40 +228,52 @@ Glow.state = {
selfPeerId: null,
peerConnections: new Map(),
remoteStreams: new Map(),
participantAudioEls: new Map(),
peers: [],
reconnectTimer: null,
invitePollTimer: null,
participantPollTimer: null,
heartbeatTimer: null,
invites: [],
invitePollTimer: null
reconnecting: false,
joining: false,
leaving: false,
permissions: {
can_manage: false,
can_end: false,
can_invite: false
},
backendParticipants: [],
scope: null,
statusText: "Idle",
metrics: {
packetLoss: null,
uptime: null,
latency: null,
elapsedSeconds: 0,
userCount: 0
},
drag: {
active: false,
x: null,
y: null,
offsetX: 0,
offsetY: 0
}
}
};
Glow.MAX_MESSAGE_LENGTH = 1000;
Glow.MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
Glow.DEFAULT_ACCENT_COLOR = "#f7dce8";
Glow.EMOJI_ALIASES = {
smile: "😄",
grin: "😁",
joy: "😂",
heart: "❤️",
thumbs_up: "👍",
fire: "🔥",
tada: "🎉",
clap: "👏",
eyes: "👀",
rocket: "🚀",
wave: "👋",
sparkles: "✨",
thinking: "🤔",
cry: "😢",
check: "✅",
bulb: "💡",
shrug: "¯\\_(ツ)_/¯"
};
Glow.normalizeAccentColor = value => {
if (typeof value !== "string") return null;
const color = value.trim();
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
return color.toLowerCase();
};
Glow.normalizeNameColor = value => {
if (value == null || value === "") return null;
if (typeof value !== "string") return null;
@ -205,6 +281,7 @@ Glow.normalizeNameColor = value => {
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
return color.toLowerCase();
};
Glow.hexToRgb = hex => {
const normalized = Glow.normalizeAccentColor(hex);
if (!normalized) return null;
@ -214,6 +291,7 @@ Glow.hexToRgb = hex => {
b: parseInt(normalized.slice(5, 7), 16)
};
};
Glow.relativeLuminance = hex => {
const rgb = Glow.hexToRgb(hex);
if (!rgb) return 0;
@ -229,9 +307,11 @@ Glow.relativeLuminance = hex => {
0.0722 * channel(rgb.b)
);
};
Glow.accentTextColor = hex => {
return Glow.relativeLuminance(hex) >= 0.18 ? hex : "#f2f2f4";
};
Glow.applyAccentColor = value => {
const accent =
Glow.normalizeAccentColor(value) || Glow.DEFAULT_ACCENT_COLOR;
@ -251,10 +331,44 @@ Glow.applyAccentColor = value => {
localStorage.setItem("glow_accent_color", accent);
Glow.updateThemeButton?.();
};
Glow.resetAccentColor = () => {
localStorage.removeItem("glow_accent_color");
Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR);
};
Glow.loadDmLayoutPreference = () => {
const stored = localStorage.getItem("glow_dm_layout_mode");
return stored === "compact" ? "compact" : "cards";
};
Glow.saveDmLayoutPreference = mode => {
const normalized = mode === "compact" ? "compact" : "cards";
Glow.state.dmLayoutMode = normalized;
localStorage.setItem("glow_dm_layout_mode", normalized);
};
Glow.isSettingsDirty = () => Boolean(Glow.state.settingsDirty);
Glow.markSettingsDirty = () => {
Glow.state.settingsDirty = true;
Glow.syncSettingsSavePanel?.();
};
Glow.clearSettingsDirty = () => {
Glow.state.settingsDirty = false;
Glow.syncSettingsSavePanel?.();
};
Glow.getTimestampVisibilityMode = () => {
return Glow.state.activeDm ? "hover" : "always";
};
Glow.shouldShowMessageTimestamp = (_message = null, context = null) => {
const view = context || Glow.getTimestampVisibilityMode();
return view !== "hover";
};
Glow.roleDefaultNameColor = role => {
switch (String(role || "").toLowerCase()) {
case "admin":
@ -265,6 +379,7 @@ Glow.roleDefaultNameColor = role => {
return "#f5f5f7";
}
};
Glow.resolveUserNameColor = userish => {
const explicit =
Glow.normalizeNameColor(userish?.name_color) ||
@ -272,18 +387,22 @@ Glow.resolveUserNameColor = userish => {
if (explicit) return explicit;
return Glow.roleDefaultNameColor(userish?.role || userish?.user_role);
};
Glow.applyUserColor = (element, userish) => {
if (!(element instanceof HTMLElement)) return;
element.style.color = Glow.resolveUserNameColor(userish);
};
Glow.canModerate = () => {
const user = Glow.state.currentUser;
return Boolean(user && ["moderator", "admin"].includes(user.role));
};
Glow.isAdmin = () => {
const user = Glow.state.currentUser;
return Boolean(user && user.role === "admin");
};
Glow.showToast = (message, type = "") => {
clearTimeout(Glow.state.toastTimer);
Glow.els.statusToast.textContent = String(message);
@ -292,14 +411,17 @@ Glow.showToast = (message, type = "") => {
Glow.els.statusToast.className = "status-toast";
}, 3500);
};
Glow.showAuthNotice = (message, type = "error") => {
Glow.els.authNotice.textContent = String(message);
Glow.els.authNotice.className = `notice show ${type}`;
};
Glow.clearAuthNotice = () => {
Glow.els.authNotice.textContent = "";
Glow.els.authNotice.className = "notice";
};
Glow.showBlocked = message => {
Glow.els.blockedMessage.textContent = message || "Access has been blocked.";
Glow.els.blockedOverlay.classList.remove("hidden");
@ -311,6 +433,7 @@ Glow.showBlocked = message => {
Glow.els.attachmentButton.classList.add("disabled");
clearTimeout(Glow.state.pollTimer);
};
Glow.relativeTime = iso => {
const seconds = Math.max(
0,
@ -324,6 +447,7 @@ Glow.relativeTime = iso => {
if (hours < 24) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
};
Glow.formatDateLabel = iso => {
const date = new Date(iso);
const now = new Date();
@ -339,6 +463,7 @@ Glow.formatDateLabel = iso => {
day: "numeric"
});
};
Glow.dateKey = iso => {
const date = new Date(iso);
return [
@ -347,6 +472,7 @@ Glow.dateKey = iso => {
String(date.getDate()).padStart(2, "0")
].join("-");
};
Glow.isNearBottom = () => {
const activeBox =
Glow.state.activeView === "feed"
@ -359,34 +485,90 @@ Glow.isNearBottom = () => {
if (!activeBox) return true;
return activeBox.scrollHeight - activeBox.scrollTop - activeBox.clientHeight < 100;
};
Glow.autoResizeComposer = () => {
const input = Glow.els.messageInput;
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 150)}px`;
};
Glow.updateMessageCount = () => {
const count = Glow.els.messageInput.value.length;
Glow.els.messageCount.textContent = `${count} / ${Glow.MAX_MESSAGE_LENGTH}`;
};
Glow.updateDocumentTitle = () => {
if (!Glow.state.unreadMessages) {
document.title = Glow.state.originalTitle;
return;
}
document.title = `(${Glow.state.unreadMessages}) ${Glow.state.originalTitle}`;
document.title = `(${Glow.state.unreadMessages})` + Glow.state.originalTitle;
};
Glow.resetUnreadMessages = () => {
Glow.state.unreadMessages = 0;
Glow.updateDocumentTitle();
Glow.els.newMessagesBar.classList.add("hidden");
};
Glow.showNewMessages = () => {
Glow.els.newMessagesBar.classList.remove("hidden");
};
Glow.normalizeSelectedFiles = fileList => {
if (!fileList) return [];
return [...fileList].filter(Boolean);
};
Glow.websiteBrandMeta = value => {
if (typeof value !== "string" || !value.trim()) {
return {
host: "",
brand: "web",
label: "website",
iconText: "â—‹"
};
}
try {
const url = new URL(value);
const host = url.hostname.toLowerCase().replace(/^www\./, "");
const mappings = [
{ match: /github\.com$/, brand: "github", label: "GitHub", iconText: "gh" },
{ match: /youtube\.com$|youtu\.be$/, brand: "youtube", label: "YouTube", iconText: "yt" },
{ match: /spotify\.com$/, brand: "spotify", label: "Spotify", iconText: "sp" },
{ match: /soundcloud\.com$/, brand: "soundcloud", label: "SoundCloud", iconText: "sc" },
{ match: /twitter\.com$|x\.com$/, brand: "x", label: "X", iconText: "x" }
];
for (const item of mappings) {
if (item.match.test(host)) {
return {
host,
brand: item.brand,
label: item.label,
iconText: item.iconText
};
}
}
return {
host,
brand: "web",
label: "website",
iconText: "â—‹"
};
} catch (_) {
return {
host: "",
brand: "web",
label: "website",
iconText: "â—‹"
};
}
};
Glow.openDialog = ({
title,
body,
@ -398,10 +580,12 @@ Glow.openDialog = ({
if (Glow.state.dialogResolver) {
Glow.closeDialog(null);
}
Glow.state.lastFocusedElement = document.activeElement;
Glow.els.dialogTitle.textContent = title;
Glow.els.dialogBody.replaceChildren();
Glow.els.dialogActions.replaceChildren();
if (typeof body === "string") {
const paragraph = document.createElement("p");
paragraph.textContent = body;
@ -409,17 +593,20 @@ Glow.openDialog = ({
} else if (body instanceof Node) {
Glow.els.dialogBody.append(body);
}
const fieldElements = {};
for (const field of fields) {
const label = document.createElement("label");
label.className = "dialog-label";
label.textContent = field.label;
const isMultiline = Boolean(field.multiline);
const input = document.createElement(isMultiline ? "textarea" : "input");
input.className = "field";
input.name = field.name;
input.value = field.value || "";
input.placeholder = field.placeholder || "";
if (isMultiline) {
input.rows = field.rows || 3;
input.maxLength = field.maxLength || 300;
@ -432,20 +619,26 @@ Glow.openDialog = ({
input.step = field.step || "1";
}
}
label.append(input);
Glow.els.dialogBody.append(label);
fieldElements[field.name] = input;
}
const cancel = document.createElement("button");
cancel.className = "button secondary";
cancel.type = "button";
cancel.textContent = cancelText;
const confirmButton = document.createElement("button");
confirmButton.className = danger ? "danger-button" : "button";
confirmButton.type = "button";
confirmButton.textContent = confirmText;
Glow.els.dialogActions.append(cancel, confirmButton);
Glow.els.dialogOverlay.classList.remove("hidden");
return new Promise(resolve => {
Glow.state.dialogResolver = resolve;
cancel.addEventListener("click", () => Glow.closeDialog(null), { once: true });
@ -466,6 +659,7 @@ Glow.openDialog = ({
}, 0);
});
};
Glow.closeDialog = (result = null) => {
Glow.els.dialogOverlay.classList.add("hidden");
const resolve = Glow.state.dialogResolver;

View File

@ -1,6 +1,8 @@
// static/js/01-api.js
"use strict";
window.Glow = window.Glow || {};
Glow.refreshCsrfToken = async () => {
const response = await fetch("/api/csrf", {
credentials: "same-origin",
@ -8,12 +10,15 @@ Glow.refreshCsrfToken = async () => {
Accept: "application/json"
}
});
if (!response.ok) {
throw new Error("Could not refresh security token.");
}
const data = await response.json();
Glow.state.csrfToken = data.csrf_token || "";
};
Glow.handleUnauthorized = () => {
Glow.state.currentUser = null;
Glow.state.csrfToken = "";
@ -32,6 +37,9 @@ Glow.handleUnauthorized = () => {
Glow.state.pageTypeFilter = "";
Glow.state.activeProfile = null;
Glow.state.settings = null;
Glow.state.settingsSaved = null;
Glow.state.settingsDraft = null;
Glow.state.settingsDirty = false;
Glow.state.selectedAttachment = [];
Glow.state.selectedThreadAttachment = [];
Glow.state.typingUsers = [];
@ -46,13 +54,44 @@ Glow.handleUnauthorized = () => {
Glow.state.dmRequestId += 1;
Glow.state.feedRequestId += 1;
Glow.state.threadRequestId += 1;
Glow.state.calls.activeCall = null;
Glow.state.calls.signalLastSeenId = 0;
Glow.state.calls.incomingCall = null;
Glow.state.calls.pendingOffer = null;
clearTimeout(Glow.state.pollTimer);
clearTimeout(Glow.state.typingTimer);
clearTimeout(Glow.state.calls.pollTimer);
clearTimeout(Glow.state.calls.reconnectTimer);
clearTimeout(Glow.state.calls.invitePollTimer);
clearTimeout(Glow.state.calls.participantPollTimer);
clearTimeout(Glow.state.calls.heartbeatTimer);
Glow.state.calls.activeCall = null;
Glow.state.calls.ws = null;
Glow.state.calls.session = null;
Glow.state.calls.localStream = null;
Glow.state.calls.muted = false;
Glow.state.calls.selfPeerId = null;
Glow.state.calls.peerConnections = new Map();
Glow.state.calls.remoteStreams = new Map();
Glow.state.calls.participantAudioEls = new Map();
Glow.state.calls.peers = [];
Glow.state.calls.reconnecting = false;
Glow.state.calls.joining = false;
Glow.state.calls.leaving = false;
Glow.state.calls.invites = [];
Glow.state.calls.backendParticipants = [];
Glow.state.calls.scope = null;
Glow.state.calls.statusText = "Idle";
Glow.state.calls.metrics = {
packetLoss: null,
uptime: null,
latency: null,
elapsedSeconds: 0,
userCount: 0
};
Glow.state.calls.permissions = {
can_manage: false,
can_end: false,
can_invite: false
};
Glow.clearMessages?.();
Glow.clearFeed?.();
Glow.clearDms?.();
@ -60,41 +99,54 @@ Glow.handleUnauthorized = () => {
Glow.closeReactionPopup?.();
Glow.closeMediaModal?.();
Glow.endLocalCallUi?.();
Glow.profileResetCropState?.();
Glow.setSelectedAttachment?.([]);
Glow.setSelectedThreadAttachment?.([]);
Glow.els.threadBox?.replaceChildren();
Glow.els.pagesBox?.replaceChildren();
Glow.els.pageDetailBox?.replaceChildren();
Glow.els.profileBox?.replaceChildren();
if (Glow.els.teamSelect) {
Glow.els.teamSelect.replaceChildren();
Glow.els.teamSelect.disabled = true;
}
if (Glow.els.roomSelect) {
Glow.els.roomSelect.replaceChildren();
Glow.els.roomSelect.disabled = true;
}
if (Glow.els.typingBar) {
Glow.els.typingBar.textContent = "";
Glow.els.typingBar.classList.add("hidden");
}
if (Glow.els.presenceLabel) {
Glow.els.presenceLabel.textContent = "";
Glow.els.presenceLabel.classList.add("hidden");
}
Glow.state.activeView = "feed";
Glow.updateAuthUI?.();
Glow.showAuthNotice?.("Your session has expired. Please sign in again.");
Glow.els.authOverlay.classList.remove("hidden");
};
Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
const method = (options.method || "GET").toUpperCase();
const headers = new Headers(options.headers || {});
headers.set("Accept", "application/json");
if (!["GET", "HEAD"].includes(method)) {
headers.set("X-CSRF-Token", Glow.state.csrfToken || "");
}
let response;
try {
response = await fetch(url, {
credentials: "same-origin",
@ -107,23 +159,29 @@ Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
error.code = "network";
throw error;
}
let data = {};
try {
data = await response.json();
} catch (_) {
data = {};
}
if (response.status === 403 && data.code === "blacklisted") {
Glow.showBlocked(data.error);
throw new Error(data.error || "Access blocked.");
}
if (response.status === 403 && data.code === "csrf" && retryCsrf) {
await Glow.refreshCsrfToken();
return Glow.apiRequest(url, options, false);
}
if (response.status === 401) {
Glow.handleUnauthorized();
}
if (!response.ok) {
const error = new Error(data.error || "Request failed.");
error.status = response.status;
@ -133,9 +191,11 @@ Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
error.data = data;
throw error;
}
if (data.csrf_token) {
Glow.state.csrfToken = data.csrf_token;
}
return data;
};
@ -147,8 +207,10 @@ Glow.apiUpdateMyProfile = payload =>
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetPublicProfile = username =>
Glow.apiRequest(`/api/profiles/${encodeURIComponent(username)}`);
Glow.apiGetMySettings = () => Glow.apiRequest("/api/settings");
Glow.apiUpdateMySettings = payload =>
Glow.apiRequest("/api/settings", {
@ -157,54 +219,6 @@ Glow.apiUpdateMySettings = payload =>
body: JSON.stringify(payload)
});
/* pins / shares / bookmarks / schedule */
Glow.apiPinMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiUnpinMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
method: "DELETE"
});
Glow.apiListPins = (scope, scopeId) =>
Glow.apiRequest(`/api/pins?scope=${encodeURIComponent(scope)}&scope_id=${encodeURIComponent(scopeId)}`);
Glow.apiCreateMessageShare = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiCreateAttachmentShare = attachmentId =>
Glow.apiRequest(`/api/attachments/${encodeURIComponent(attachmentId)}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiBookmarkMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiUnbookmarkMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
method: "DELETE"
});
Glow.apiListBookmarks = () => Glow.apiRequest("/api/bookmarks");
Glow.apiCreateScheduledMessage = payload =>
Glow.apiRequest("/api/scheduled-messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiListScheduledMessages = () => Glow.apiRequest("/api/scheduled-messages");
Glow.apiCancelScheduledMessage = id =>
Glow.apiRequest(`/api/scheduled-messages/${encodeURIComponent(id)}`, {
method: "DELETE"
});
/* teams/channels */
Glow.apiCreateTeam = payload =>
Glow.apiRequest("/api/teams", {
@ -212,18 +226,21 @@ Glow.apiCreateTeam = payload =>
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateTeam = (teamSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiCreateChannel = (teamSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/channels`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
@ -233,19 +250,23 @@ Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
body: JSON.stringify(payload)
}
);
Glow.apiDeleteChannel = (teamSlug, channelSlug) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
{ method: "DELETE" }
);
Glow.apiListTeamMembers = teamSlug =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`);
Glow.apiAddTeamMember = (teamSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
@ -255,17 +276,20 @@ Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
body: JSON.stringify(payload)
}
);
Glow.apiRemoveTeamMember = (teamSlug, userId) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
{ method: "DELETE" }
);
Glow.apiCreateTeamInvite = (teamSlug, payload = {}) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/invites`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiAcceptTeamInvite = token =>
Glow.apiRequest(`/api/team-invites/${encodeURIComponent(token)}/accept`, {
method: "POST",
@ -280,6 +304,7 @@ Glow.apiCreateRoom = payload =>
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiUpdateRoom = (roomSlug, payload) =>
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}`, {
method: "PATCH",
@ -290,12 +315,14 @@ Glow.apiUpdateRoom = (roomSlug, payload) =>
/* reactions */
Glow.apiGetMessageReactions = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`);
Glow.apiAddReaction = (messageId, emoji) =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emoji })
});
Glow.apiRemoveReaction = (messageId, emoji) =>
Glow.apiRequest(
`/api/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`,
@ -309,30 +336,38 @@ Glow.apiEditMessage = (messageId, payload) =>
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiSearch = query =>
Glow.apiRequest(`/api/search?q=${encodeURIComponent(query)}`);
Glow.apiMarkRead = messageIds =>
Glow.apiRequest("/api/read-receipts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message_ids: messageIds })
});
Glow.apiGetMessageReceipts = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/receipts`);
Glow.apiPingPresence = payload =>
Glow.apiRequest("/api/presence", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetRoomPresence = roomSlug =>
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}/presence`);
Glow.apiGetChannelPresence = (teamSlug, channelSlug) =>
Glow.apiRequest(
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}/presence`
);
Glow.apiGetDmPresence = dmId =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/presence`);
Glow.apiLoadHistory = params => {
const qs = new URLSearchParams(params);
return Glow.apiRequest(`/api/messages/history?${qs.toString()}`);
@ -340,26 +375,31 @@ Glow.apiLoadHistory = params => {
/* DMs */
Glow.apiListDms = () => Glow.apiRequest("/api/dms");
Glow.apiCreateDm = payload =>
Glow.apiRequest("/api/dms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetDmMessages = (dmId, afterId = null) => {
const base = `/api/dms/${encodeURIComponent(dmId)}/messages`;
return Glow.apiRequest(
afterId == null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`
);
};
Glow.apiSendDmMessage = (dmId, payload) =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetDmTyping = dmId =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`);
Glow.apiSetDmTyping = (dmId, typing) =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`, {
method: "POST",
@ -367,28 +407,123 @@ Glow.apiSetDmTyping = (dmId, typing) =>
body: JSON.stringify({ typing })
});
/* calls */
Glow.apiCreateCall = dmId =>
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, {
/* group calls */
Glow.apiGetGroupCall = sessionId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}`);
Glow.apiJoinGroupCall = sessionId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiGetCall = callId =>
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`);
Glow.apiPostCallSignal = (callId, payload) =>
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, {
Glow.apiHeartbeatGroupCall = sessionId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/heartbeat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiLeaveGroupCall = sessionId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/leave`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiEndGroupCall = sessionId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/end`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiGetGroupCallParticipants = sessionId =>
Glow.apiRequest(
`/api/group-calls/${encodeURIComponent(sessionId)}/participants`
);
Glow.apiInviteToGroupCall = payload =>
Glow.apiRequest("/api/group-calls/invite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiGetCallSignals = (callId, afterId = 0) =>
Glow.apiListGroupCallInvites = () =>
Glow.apiRequest("/api/group-calls/invites");
Glow.apiAcceptGroupCallInvite = inviteId =>
Glow.apiRequest(
`/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}`
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/accept`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
);
Glow.apiEndCall = callId =>
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, {
Glow.apiDismissGroupCallInvite = inviteId =>
Glow.apiRequest(
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/dismiss`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
);
/* optional / not yet guaranteed on all backends */
Glow.apiBookmarkMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiUnbookmarkMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
method: "DELETE"
});
Glow.apiPinMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiUnpinMessage = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
method: "DELETE"
});
Glow.apiListPins = (scopeKind, scopeId) =>
Glow.apiRequest(
`/api/pins?scope_kind=${encodeURIComponent(scopeKind)}&scope_id=${encodeURIComponent(scopeId)}`
);
Glow.apiListBookmarks = () =>
Glow.apiRequest("/api/bookmarks");
Glow.apiCreateMessageShare = messageId =>
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiCreateAttachmentShare = attachmentId =>
Glow.apiRequest(`/api/attachments/${encodeURIComponent(attachmentId)}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiCreateScheduledMessage = payload =>
Glow.apiRequest("/api/scheduled-messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});

File diff suppressed because it is too large Load Diff

View File

@ -6,58 +6,99 @@ window.Glow = window.Glow || {};
Glow.closeAdminPanel = () => {
Glow.els.adminPanel.classList.remove("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "true");
Glow.els.adminFab.focus();
Glow.els.adminFab?.focus();
};
Glow.setAdminTab = tab => {
Glow.state.adminTab = ["rooms", "users", "teams"].includes(tab) ? tab : "rooms";
const isRooms = Glow.state.adminTab === "rooms";
const isUsers = Glow.state.adminTab === "users";
const isTeams = Glow.state.adminTab === "teams";
Glow.els.adminTabRooms?.classList.toggle("active", isRooms);
Glow.els.adminTabUsers?.classList.toggle("active", isUsers);
Glow.els.adminTabTeams?.classList.toggle("active", isTeams);
Glow.els.adminTabRooms?.setAttribute("aria-selected", String(isRooms));
Glow.els.adminTabUsers?.setAttribute("aria-selected", String(isUsers));
Glow.els.adminTabTeams?.setAttribute("aria-selected", String(isTeams));
Glow.els.adminRoomsPanel?.classList.toggle("hidden", !isRooms);
Glow.els.adminUsersPanel?.classList.toggle("hidden", !isUsers);
Glow.els.adminTeamsPanel?.classList.toggle("hidden", !isTeams);
};
Glow.buildAdminRow = ({ title, subtitle, actions = [], className = "" }) => {
const row = document.createElement("div");
row.className = className || "admin-room";
const details = document.createElement("div");
const name = document.createElement("div");
name.className =
className.includes("user")
? "admin-user-name"
: className.includes("team")
? "admin-team-name"
: "admin-room-name";
name.textContent = title;
const info = document.createElement("div");
info.className =
className.includes("user")
? "admin-user-info"
: className.includes("team")
? "admin-team-info"
: "admin-room-info";
info.textContent = subtitle || "";
details.append(name, info);
const actionsWrap = document.createElement("div");
actionsWrap.className =
className.includes("user")
? "admin-user-actions"
: className.includes("team")
? "admin-team-actions"
: "admin-room-actions";
for (const action of actions) {
actionsWrap.append(action);
}
row.append(details, actionsWrap);
return row;
};
Glow.renderAdminUsers = users => {
Glow.els.adminUsers.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "Users";
heading.textContent = "users";
Glow.els.adminUsers.append(heading);
if (!users.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "No users found.";
empty.textContent = "no users found.";
Glow.els.adminUsers.append(empty);
return;
}
for (const user of users) {
const row = document.createElement("div");
row.className = "admin-user";
const details = document.createElement("div");
const name = document.createElement("div");
name.className = "admin-user-name";
name.textContent = `${user.username} · ${user.role}`;
const info = document.createElement("div");
info.className = "admin-user-info";
info.textContent =
`${user.status} · ${user.message_count} message(s)` +
(user.restricted_until
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
: "");
details.append(name, info);
const actions = document.createElement("div");
actions.className = "admin-user-actions";
const current =
Glow.state.currentUser &&
Number(Glow.state.currentUser.id) === Number(user.id);
const actions = [];
if (!current && Glow.isAdmin()) {
const role = document.createElement("button");
role.className = "small-button";
role.type = "button";
role.textContent =
user.role === "moderator" ? "Make user" : "Make moderator";
user.role === "moderator" ? "make user" : "make moderator";
role.addEventListener("click", () => {
Glow.updateAdminUser(user.id, {
role: user.role === "moderator" ? "user" : "moderator"
@ -68,8 +109,7 @@ Glow.renderAdminUsers = users => {
restrict.className = "small-button";
restrict.type = "button";
restrict.textContent =
user.status === "restricted" ? "Unrestrict" : "Restrict";
user.status === "restricted" ? "unrestrict" : "restrict";
restrict.addEventListener("click", () => {
Glow.toggleRestriction(user);
});
@ -78,17 +118,26 @@ Glow.renderAdminUsers = users => {
blacklist.className = "small-button danger";
blacklist.type = "button";
blacklist.textContent =
user.status === "blacklisted" ? "Unblacklist" : "Blacklist";
user.status === "blacklisted" ? "unblacklist" : "blacklist";
blacklist.addEventListener("click", () => {
Glow.toggleBlacklist(user);
});
actions.append(role, restrict, blacklist);
actions.push(role, restrict, blacklist);
}
row.append(details, actions);
Glow.els.adminUsers.append(row);
Glow.els.adminUsers.append(
Glow.buildAdminRow({
title: `${user.username} · ${user.role}`,
subtitle:
`${user.status} · ${user.message_count} message(s)` +
(user.restricted_until
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
: ""),
actions,
className: "admin-user"
})
);
}
};
@ -96,52 +145,33 @@ Glow.renderAdminRooms = rooms => {
Glow.els.adminRooms.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "Rooms";
heading.textContent = "rooms";
Glow.els.adminRooms.append(heading);
if (!rooms.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "No rooms found.";
empty.textContent = "no rooms found.";
Glow.els.adminRooms.append(empty);
return;
}
for (const room of rooms) {
const row = document.createElement("div");
row.className = "admin-room";
const details = document.createElement("div");
const name = document.createElement("div");
name.className = "admin-room-name";
name.textContent = `${room.name} · ${room.visibility}`;
const info = document.createElement("div");
info.className = "admin-room-info";
info.textContent =
`/${room.slug}` +
(room.description ? ` · ${room.description}` : "");
details.append(name, info);
const actions = document.createElement("div");
actions.className = "admin-room-actions";
const actions = [];
if (Glow.isAdmin()) {
const edit = document.createElement("button");
edit.className = "small-button";
edit.type = "button";
edit.textContent = "Edit";
edit.textContent = "edit";
edit.addEventListener("click", () => Glow.editRoom(room));
actions.append(edit);
actions.push(edit);
if (room.visibility === "private") {
const members = document.createElement("button");
members.className = "small-button";
members.type = "button";
members.textContent = "Members";
members.textContent = "members";
members.addEventListener("click", () => {
Glow.manageRoomMembers(room);
});
@ -149,27 +179,139 @@ Glow.renderAdminRooms = rooms => {
const invite = document.createElement("button");
invite.className = "small-button";
invite.type = "button";
invite.textContent = "Invite link";
invite.textContent = "invite link";
invite.addEventListener("click", () => {
Glow.createInviteLink(room);
});
actions.append(members, invite);
actions.push(members, invite);
}
if (room.slug !== "general") {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Delete";
remove.textContent = "delete";
remove.addEventListener("click", () => Glow.deleteRoom(room));
actions.append(remove);
actions.push(remove);
}
}
row.append(details, actions);
Glow.els.adminRooms.append(row);
Glow.els.adminRooms.append(
Glow.buildAdminRow({
title: `${room.name} · ${room.visibility}`,
subtitle: `/${room.slug}` + (room.description ? ` · ${room.description}` : ""),
actions,
className: "admin-room"
})
);
}
};
Glow.renderAdminTeamsChannels = () => {
const box = Glow.els.adminTeamsChannels;
if (!box) return;
box.replaceChildren();
const heading = document.createElement("h3");
heading.textContent = "teams / channels";
box.append(heading);
const team = Glow.state.activeTeam;
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
if (!team) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "select a team to manage team or channel actions.";
box.append(empty);
return;
}
const teamActions = [];
if (team.can_manage) {
const editTeam = document.createElement("button");
editTeam.className = "small-button";
editTeam.type = "button";
editTeam.textContent = "edit team";
editTeam.addEventListener("click", () => Glow.openEditTeamDialog?.(team));
const members = document.createElement("button");
members.className = "small-button";
members.type = "button";
members.textContent = "members";
members.addEventListener("click", () => Glow.manageTeamMembers?.(team));
const invite = document.createElement("button");
invite.className = "small-button";
invite.type = "button";
invite.textContent = "invite";
invite.addEventListener("click", () => Glow.createTeamInviteLink?.(team));
const addChannel = document.createElement("button");
addChannel.className = "small-button";
addChannel.type = "button";
addChannel.textContent = "add channel";
addChannel.addEventListener("click", () => Glow.openCreateChannelDialog?.(team));
teamActions.push(editTeam, members, invite, addChannel);
}
box.append(
Glow.buildAdminRow({
title: team.name,
subtitle: team.description || `/${team.slug}`,
actions: teamActions,
className: "admin-team-row"
})
);
const channels = Array.isArray(Glow.state.channels) ? Glow.state.channels : [];
if (!channels.length) {
const empty = document.createElement("p");
empty.className = "admin-empty";
empty.textContent = "no channels available for this team.";
box.append(empty);
return;
}
for (const item of channels) {
const actions = [];
const isActive = channel && item.slug === channel.slug;
if (team.can_manage) {
const edit = document.createElement("button");
edit.className = "small-button";
edit.type = "button";
edit.textContent = "edit";
edit.addEventListener("click", () => {
Glow.openEditChannelDialog?.(team, item);
});
actions.push(edit);
if (!item.legacy_room_id) {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "delete";
remove.addEventListener("click", async () => {
const previous = Glow.state.activeChannel;
Glow.state.activeChannel = item;
await Glow.deleteCurrentChannel?.(team);
Glow.state.activeChannel = previous;
});
actions.push(remove);
}
}
box.append(
Glow.buildAdminRow({
title: `${isActive ? "• " : ""}# ${item.name}`,
subtitle: item.description || `/${item.slug}`,
actions,
className: "admin-team-row"
})
);
}
};
@ -179,8 +321,15 @@ Glow.openAdminPanel = async () => {
Glow.els.adminPanel.classList.add("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "false");
Glow.els.adminUsers.textContent = "Loading users…";
Glow.els.adminRooms.textContent = "Loading rooms…";
if (Glow.els.adminUsers) {
Glow.els.adminUsers.textContent = "loading users…";
}
if (Glow.els.adminRooms) {
Glow.els.adminRooms.textContent = "loading rooms…";
}
if (Glow.els.adminTeamsChannels) {
Glow.els.adminTeamsChannels.textContent = "loading teams/channels…";
}
try {
const [usersData, roomsData] = await Promise.all([
@ -190,134 +339,15 @@ Glow.openAdminPanel = async () => {
Glow.renderAdminUsers(usersData.users || []);
Glow.renderAdminRooms(roomsData.rooms || []);
Glow.updateAdminPreferenceButtons();
Glow.renderAdminTeamsChannels();
Glow.setAdminTab(Glow.state.adminTab || "rooms");
setTimeout(() => Glow.els.closeAdminButton.focus(), 0);
setTimeout(() => Glow.els.closeAdminButton?.focus(), 0);
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not load moderation data.", "error");
}
};
Glow.updateThemeButton = () => {
if (!Glow.els.themeButton) return;
Glow.els.themeButton.textContent = "Accent color";
};
Glow.updateAdminPreferenceButtons = () => {
Glow.els.typingToggleButton.textContent =
`Typing indicators: ${Glow.state.typingIndicatorsEnabled ? "on" : "off"}`;
Glow.els.notificationsToggleButton.textContent =
`Notifications: ${Glow.state.notificationsEnabled ? "on" : "off"}`;
Glow.updateThemeButton();
};
Glow.openAccentColorPicker = async () => {
const container = document.createElement("div");
container.className = "accent-picker";
const description = document.createElement("p");
description.className = "accent-picker-description";
description.textContent =
"Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings.";
const label = document.createElement("label");
label.className = "accent-picker-label";
label.textContent = "Accent color";
const row = document.createElement("div");
row.className = "accent-picker-row";
const colorInput = document.createElement("input");
colorInput.className = "accent-color-input";
colorInput.type = "color";
colorInput.value =
Glow.normalizeAccentColor(Glow.state.accentColor) ||
Glow.DEFAULT_ACCENT_COLOR;
colorInput.setAttribute("aria-label", "Choose accent color");
const hexInput = document.createElement("input");
hexInput.className = "field accent-hex-input";
hexInput.type = "text";
hexInput.value = colorInput.value;
hexInput.maxLength = 7;
hexInput.autocomplete = "off";
hexInput.autocapitalize = "off";
hexInput.spellcheck = false;
hexInput.placeholder = "#f7dce8";
hexInput.setAttribute(
"aria-label",
"Accent color hexadecimal value"
);
const preview = document.createElement("span");
preview.className = "accent-preview";
preview.style.background = colorInput.value;
preview.setAttribute("aria-hidden", "true");
const error = document.createElement("div");
error.className = "accent-picker-error";
error.setAttribute("role", "status");
const updatePreview = value => {
const normalized = Glow.normalizeAccentColor(value);
if (!normalized) {
preview.style.background = "transparent";
error.textContent =
"Use a six-digit hex color, such as #7dd3fc.";
return false;
}
preview.style.background = normalized;
error.textContent = "";
if (colorInput.value !== normalized) {
colorInput.value = normalized;
}
return true;
};
colorInput.addEventListener("input", () => {
const color = Glow.normalizeAccentColor(colorInput.value);
if (!color) return;
hexInput.value = color;
updatePreview(color);
});
hexInput.addEventListener("input", () => {
updatePreview(hexInput.value.trim());
});
row.append(colorInput, hexInput, preview);
label.append(row);
container.append(description, label, error);
const result = await Glow.openDialog({
title: "Accent color",
body: container,
confirmText: "Save color",
cancelText: "Cancel"
});
if (!result) return;
const accent = Glow.normalizeAccentColor(hexInput.value);
if (!accent) {
Glow.showToast(
"Choose a valid six-digit hexadecimal color.",
"error"
);
return;
}
Glow.applyAccentColor(accent);
Glow.showToast("Accent color updated.");
};
Glow.toggleRestriction = async user => {
if (user.status === "restricted") {
return Glow.updateAdminUser(user.id, {
@ -327,14 +357,14 @@ Glow.toggleRestriction = async user => {
}
const values = await Glow.openDialog({
title: `Restrict ${user.username}`,
body: "Enter the restriction duration in minutes.",
confirmText: "Restrict",
title: `restrict ${user.username}`,
body: "enter the restriction duration in minutes.",
confirmText: "restrict",
danger: true,
fields: [
{
name: "minutes",
label: "Duration in minutes",
label: "duration in minutes",
type: "number",
value: "60",
maxLength: 5
@ -345,20 +375,14 @@ Glow.toggleRestriction = async user => {
if (!values) return;
const minutes = Number(values.minutes);
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) {
Glow.showToast(
"Enter a duration between 1 minute and 30 days.",
"error"
);
Glow.showToast("enter a duration between 1 minute and 30 days.", "error");
return;
}
await Glow.updateAdminUser(user.id, {
status: "restricted",
restricted_until: new Date(
Date.now() + minutes * 60 * 1000
).toISOString()
restricted_until: new Date(Date.now() + minutes * 60 * 1000).toISOString()
});
};
@ -367,12 +391,12 @@ Glow.toggleBlacklist = async user => {
const result = await Glow.openDialog({
title: blacklisting
? `Blacklist ${user.username}?`
: `Unblacklist ${user.username}?`,
? `blacklist ${user.username}?`
: `unblacklist ${user.username}?`,
body: blacklisting
? "The account will be denied access immediately."
: "The account will be restored to active status.",
confirmText: blacklisting ? "Blacklist" : "Unblacklist",
? "the account will be denied access immediately."
: "the account will be restored to active status.",
confirmText: blacklisting ? "blacklist" : "unblacklist",
danger: blacklisting
});
@ -391,36 +415,36 @@ Glow.updateAdminUser = async (userId, changes) => {
body: JSON.stringify(changes)
});
Glow.showToast("User updated.");
Glow.showToast("user updated.");
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not update user.", "error");
}
};
Glow.createRoom = async () => {
const values = await Glow.openDialog({
title: "Create room",
body: "Choose a room name, description, and visibility.",
confirmText: "Create",
title: "create room",
body: "choose a room name, description, and visibility.",
confirmText: "create",
fields: [
{
name: "name",
label: "Room name",
placeholder: "Project chat",
label: "room name",
placeholder: "project chat",
maxLength: 60
},
{
name: "description",
label: "Description",
placeholder: "Optional room description",
label: "description",
placeholder: "optional room description",
multiline: true,
rows: 3,
maxLength: 300
},
{
name: "visibility",
label: "Visibility: public or private",
label: "visibility: public or private",
value: "public",
maxLength: 7
}
@ -429,46 +453,41 @@ Glow.createRoom = async () => {
if (!values) return;
const visibility = values.visibility.toLowerCase();
const visibility = String(values.visibility || "").toLowerCase();
if (!["public", "private"].includes(visibility)) {
Glow.showToast("Visibility must be public or private.", "error");
Glow.showToast("visibility must be public or private.", "error");
return;
}
try {
await Glow.apiRequest("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
await Glow.apiCreateRoom({
name: values.name,
description: values.description,
visibility
})
});
Glow.showToast("Room created.");
Glow.showToast("room created.");
await Glow.loadRooms();
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not create room.", "error");
}
};
Glow.editRoom = async room => {
const values = await Glow.openDialog({
title: `Edit ${room.name}`,
confirmText: "Save",
title: `edit ${room.name}`,
confirmText: "save",
fields: [
{
name: "name",
label: "Room name",
label: "room name",
value: room.name,
maxLength: 60
},
{
name: "description",
label: "Description",
label: "description",
value: room.description || "",
multiline: true,
rows: 3,
@ -476,7 +495,7 @@ Glow.editRoom = async room => {
},
{
name: "visibility",
label: "Visibility: public or private",
label: "visibility: public or private",
value: room.visibility,
maxLength: 7
}
@ -485,10 +504,9 @@ Glow.editRoom = async room => {
if (!values) return;
const visibility = values.visibility.toLowerCase();
const visibility = String(values.visibility || "").toLowerCase();
if (!["public", "private"].includes(visibility)) {
Glow.showToast("Visibility must be public or private.", "error");
Glow.showToast("visibility must be public or private.", "error");
return;
}
@ -501,13 +519,8 @@ Glow.editRoom = async room => {
Glow.updateRoom = async (slug, changes) => {
try {
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
Glow.showToast("Room updated.");
await Glow.apiUpdateRoom(slug, changes);
Glow.showToast("room updated.");
await Glow.loadRooms();
if (!Glow.state.activeDm) {
@ -516,15 +529,15 @@ Glow.updateRoom = async (slug, changes) => {
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not update room.", "error");
}
};
Glow.deleteRoom = async room => {
const result = await Glow.openDialog({
title: `Delete ${room.name}?`,
body: "This permanently deletes the room and every message in it.",
confirmText: "Delete room",
title: `delete ${room.name}?`,
body: "this permanently deletes the room and every message in it.",
confirmText: "delete room",
danger: true
});
@ -535,7 +548,7 @@ Glow.deleteRoom = async room => {
method: "DELETE"
});
Glow.showToast("Room deleted.");
Glow.showToast("room deleted.");
await Glow.loadRooms();
if (!Glow.state.activeDm) {
@ -544,43 +557,39 @@ Glow.deleteRoom = async room => {
await Glow.openAdminPanel();
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not delete room.", "error");
}
};
Glow.clearActiveRoom = async () => {
const room = Glow.state.activeRoom;
if (!room || Glow.state.activeDm) return;
const result = await Glow.openDialog({
title: `Clear ${room.name}?`,
body: "All messages in this room will be permanently deleted.",
confirmText: "Clear room",
title: `clear ${room.name}?`,
body: "all messages in this room will be permanently deleted.",
confirmText: "clear room",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/messages`,
{ method: "DELETE" }
);
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/messages`, {
method: "DELETE"
});
Glow.clearMessages();
Glow.renderEmptyState();
Glow.showToast("Room messages cleared.");
Glow.showToast("room messages cleared.");
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not clear room.", "error");
}
};
Glow.manageRoomMembers = async room => {
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`
);
const data = await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`);
const container = document.createElement("div");
container.className = "member-list";
@ -595,7 +604,7 @@ Glow.manageRoomMembers = async room => {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Remove";
remove.textContent = "remove";
remove.disabled = member.membership_role === "owner";
remove.addEventListener("click", async () => {
@ -604,12 +613,11 @@ Glow.manageRoomMembers = async room => {
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
{ method: "DELETE" }
);
Glow.showToast("Member removed.");
Glow.showToast("member removed.");
Glow.closeDialog({});
Glow.manageRoomMembers(room);
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not remove member.", "error");
}
});
@ -620,11 +628,11 @@ Glow.manageRoomMembers = async room => {
const values = await Glow.openDialog({
title: `${room.name} members`,
body: container,
confirmText: "Add member",
confirmText: "add member",
fields: [
{
name: "username",
label: "Username to add",
label: "username to add",
placeholder: "username",
maxLength: 30
}
@ -633,46 +641,37 @@ Glow.manageRoomMembers = async room => {
if (!values?.username) return;
await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`,
{
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: values.username })
}
);
});
Glow.showToast("Member added.");
Glow.showToast("member added.");
await Glow.manageRoomMembers(room);
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not manage room members.", "error");
}
};
Glow.createInviteLink = async room => {
try {
const data = await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/invites`,
{
const data = await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/invites`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
);
});
const url = new URL(
data.invite.url,
window.location.origin
).toString();
const url = new URL(data.invite.url, window.location.origin).toString();
const values = await Glow.openDialog({
title: "Private-room invite link",
body: "Copy and share this link. It can be used once before it expires.",
confirmText: "Copy link",
title: "private-room invite link",
body: "copy and share this link. it can be used once before it expires.",
confirmText: "copy link",
fields: [
{
name: "invite",
label: "Invite link",
label: "invite link",
value: url,
maxLength: 500
}
@ -681,9 +680,21 @@ Glow.createInviteLink = async room => {
if (values) {
await navigator.clipboard.writeText(url);
Glow.showToast("Invite link copied.");
Glow.showToast("invite link copied.");
}
} catch (error) {
Glow.showToast(error.message, "error");
Glow.showToast(error.message || "could not create invite link.", "error");
}
};
Glow.els.adminTabRooms?.addEventListener("click", () => {
Glow.setAdminTab("rooms");
});
Glow.els.adminTabUsers?.addEventListener("click", () => {
Glow.setAdminTab("users");
});
Glow.els.adminTabTeams?.addEventListener("click", () => {
Glow.setAdminTab("teams");
});

File diff suppressed because it is too large Load Diff

View File

@ -99,14 +99,36 @@ Glow.openFeedEntry = async entry => {
Glow.showToast("This conversation is no longer available.", "error");
};
Glow.feedAttachmentSummary = entry => {
const attachments = Array.isArray(entry.attachments)
? entry.attachments
: entry.attachment
? [entry.attachment]
: [];
if (!attachments.length) return "";
if (attachments.length > 1) {
return `${attachments.length} attachments`;
}
const item = attachments[0];
const mime = String(item?.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image attachment";
if (mime.startsWith("audio/")) return "audio attachment";
if (mime.startsWith("video/")) return "video attachment";
return "file attachment";
};
Glow.buildFeedReactions = entry => {
const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
if (!reactions.length) return null;
const row = document.createElement("div");
row.className = "feed-entry-reactions";
row.className = "feed-entry-reactions compact";
for (const reaction of reactions) {
for (const reaction of reactions.slice(0, 6)) {
const chip = document.createElement("button");
chip.className = "reaction-chip";
chip.type = "button";
@ -136,97 +158,35 @@ Glow.buildFeedReactions = entry => {
return row;
};
Glow.buildFeedAttachmentBlock = entry => {
const attachments = Array.isArray(entry.attachments)
? entry.attachments
: entry.attachment
? [entry.attachment]
: [];
if (!attachments.length) return null;
const wrap = document.createElement("div");
wrap.className = "message-attachments-list";
for (const attachment of attachments) {
if (String(attachment.mime_type || "").startsWith("image/")) {
const imageButton = document.createElement("button");
imageButton.className = "message-image-link";
imageButton.type = "button";
const image = document.createElement("img");
image.className = "message-image-preview";
image.src = attachment.url;
image.alt = attachment.original_name || "Image attachment";
image.loading = "lazy";
imageButton.append(image);
imageButton.addEventListener("click", () => {
Glow.openMediaModal?.({
url: attachment.url,
name: attachment.original_name,
mimeType: attachment.mime_type
});
});
wrap.append(imageButton);
} else if (String(attachment.mime_type || "").startsWith("audio/")) {
const audio = document.createElement("audio");
audio.controls = true;
audio.preload = "metadata";
audio.src = attachment.url;
wrap.append(audio);
} else if (String(attachment.mime_type || "").startsWith("video/")) {
const video = document.createElement("video");
video.controls = true;
video.preload = "metadata";
video.src = attachment.url;
video.className = "message-video-preview";
wrap.append(video);
}
const link = document.createElement("a");
link.className = "message-attachment";
link.href = attachment.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.download = attachment.original_name;
link.textContent = `Attachment: ${attachment.original_name}`;
wrap.append(link);
}
return wrap;
};
Glow.buildFeedEntry = entry => {
const card = document.createElement("article");
card.className = "feed-entry";
const row = document.createElement("article");
row.className = "feed-entry timeline-row";
if (entry.is_mention) {
card.classList.add("mention");
row.classList.add("mention");
}
card.dataset.messageId = String(entry.id);
row.dataset.messageId = String(entry.id);
if (entry.room_slug) card.dataset.roomSlug = entry.room_slug;
if (entry.team_slug) card.dataset.teamSlug = entry.team_slug;
if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug;
if (entry.room_slug) row.dataset.roomSlug = entry.room_slug;
if (entry.team_slug) row.dataset.teamSlug = entry.team_slug;
if (entry.channel_slug) row.dataset.channelSlug = entry.channel_slug;
const heading = document.createElement("div");
heading.className = "feed-entry-heading";
const top = document.createElement("div");
top.className = "feed-entry-top";
const room = document.createElement("button");
room.className = "feed-room-link";
room.type = "button";
const context = document.createElement("button");
context.className = "feed-room-link";
context.type = "button";
if (entry.team_name && entry.channel_name) {
room.textContent = `${entry.team_name} · # ${entry.channel_name}`;
context.textContent = `${entry.team_name} · # ${entry.channel_name}`;
} else {
room.textContent =
context.textContent =
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
}
room.addEventListener("click", () => {
context.addEventListener("click", () => {
Glow.openFeedEntry(entry);
});
@ -236,7 +196,7 @@ Glow.buildFeedEntry = entry => {
time.textContent = Glow.relativeTime(entry.created_at);
time.title = new Date(entry.created_at).toLocaleString();
heading.append(room, time);
top.append(context, time);
const author = document.createElement("button");
author.className = "feed-entry-author profile-link";
@ -248,37 +208,59 @@ Glow.buildFeedEntry = entry => {
});
const body = document.createElement("div");
body.className = "feed-entry-body";
body.textContent = entry.text;
body.className = "feed-entry-body clamp-2";
body.textContent = entry.text || "";
const footer = document.createElement("div");
footer.className = "feed-entry-footer";
const meta = document.createElement("div");
meta.className = "feed-entry-meta";
const attachmentSummary = Glow.feedAttachmentSummary(entry);
if (attachmentSummary) {
const attachmentButton = document.createElement("button");
attachmentButton.className = "small-button feed-attachment-summary";
attachmentButton.type = "button";
attachmentButton.textContent = attachmentSummary;
attachmentButton.addEventListener("click", () => {
Glow.openFeedEntry(entry);
});
meta.append(attachmentButton);
}
const attachmentBlock = Glow.buildFeedAttachmentBlock(entry);
const reactions = Glow.buildFeedReactions(entry);
if (reactions) {
meta.append(reactions);
}
const openButton = document.createElement("button");
openButton.className = "small-button feed-open-room";
openButton.type = "button";
openButton.textContent = "Open channel";
openButton.textContent = "Open";
openButton.addEventListener("click", () => {
Glow.openFeedEntry(entry);
});
card.addEventListener("dblclick", () => {
footer.append(meta, openButton);
row.addEventListener("dblclick", () => {
Glow.openFeedEntry(entry);
});
card.append(heading, author, body);
if (attachmentBlock) {
card.append(attachmentBlock);
row.addEventListener("click", event => {
if (
event.target instanceof HTMLElement &&
(event.target.closest("button") || event.target.closest("a"))
) {
return;
}
Glow.openFeedEntry(entry);
});
if (reactions) {
card.append(reactions);
}
row.append(top, author, body, footer);
card.append(openButton);
return card;
return row;
};
Glow.appendFeedEntries = (entries, forceScroll = false) => {

View File

@ -1,6 +1,7 @@
// static/js/06-pages.js
"use strict";
window.Glow = window.Glow || {};
// comments, version history, and share links intentionally disabled in production until backend support complete
Glow.renderPagesEmptyState = message => {
Glow.els.pagesBox.replaceChildren();
@ -15,49 +16,21 @@ Glow.renderPagesEmptyState = message => {
Glow.pageTypeLabel = type => {
const labels = {
project: "Project",
document: "Document",
update: "Update",
resource: "Resource"
project: "project",
document: "document",
update: "update",
resource: "resource"
};
return labels[type] || "Page";
return labels[type] || "page";
};
Glow.apiListPageVersions = (teamSlug, pageSlug) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/versions`);
Glow.apiGetPageVersion = versionId =>
Glow.apiRequest(`/api/page-versions/${encodeURIComponent(versionId)}`);
Glow.apiRestorePageVersion = versionId =>
Glow.apiRequest(`/api/page-versions/${encodeURIComponent(versionId)}/restore`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiListPageComments = (teamSlug, pageSlug) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/comments`);
Glow.apiCreatePageComment = (teamSlug, pageSlug, payload) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
Glow.apiDeletePageComment = commentId =>
Glow.apiRequest(`/api/page-comments/${encodeURIComponent(commentId)}`, {
method: "DELETE"
});
Glow.apiCreatePageShare = (teamSlug, pageSlug) =>
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
const team = Glow.state.activeTeam;
if (!team) {
Glow.renderPagesEmptyState("Select a team to view pages.");
Glow.renderPagesEmptyState("select a team to view pages.");
return;
}
Glow.els.pagesBox.setAttribute("aria-busy", "true");
try {
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
@ -69,8 +42,8 @@ Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
Glow.state.pageTypeFilter = pageType || "";
Glow.renderPages();
} catch (error) {
Glow.renderPagesEmptyState(error.message || "Could not load pages.");
Glow.showToast(error.message || "Could not load pages.", "error");
Glow.renderPagesEmptyState(error.message || "could not load pages.");
Glow.showToast(error.message || "could not load pages.", "error");
} finally {
Glow.els.pagesBox.setAttribute("aria-busy", "false");
}
@ -84,11 +57,12 @@ Glow.renderPages = () => {
const header = document.createElement("div");
header.className = "pages-header";
const copy = document.createElement("div");
const title = document.createElement("h2");
title.textContent = `${team?.name || "Team"} pages`;
title.textContent = `${team?.name || "team"} pages`;
const subtitle = document.createElement("p");
subtitle.textContent = "Shared team knowledge and public resources.";
subtitle.textContent = "shared team knowledge and public resources.";
copy.append(title, subtitle);
header.append(copy);
@ -96,21 +70,23 @@ Glow.renderPages = () => {
const create = document.createElement("button");
create.className = "button";
create.type = "button";
create.textContent = "New page";
create.textContent = "new page";
create.addEventListener("click", Glow.createPage);
header.append(create);
}
box.append(header);
const filters = document.createElement("div");
filters.className = "page-filters";
filters.className = "page-filters compact";
const filterOptions = [
["", "All"],
["project", "Projects"],
["document", "Documents"],
["update", "Updates"],
["resource", "Resources"]
["", "all"],
["project", "projects"],
["document", "documents"],
["update", "updates"],
["resource", "resources"]
];
for (const [value, label] of filterOptions) {
const button = document.createElement("button");
button.className = "page-filter";
@ -120,6 +96,7 @@ Glow.renderPages = () => {
button.addEventListener("click", () => Glow.loadPages(value));
filters.append(button);
}
box.append(filters);
if (!pages.length) {
@ -127,7 +104,7 @@ Glow.renderPages = () => {
empty.className = "empty-state page-empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "No pages match this filter.";
card.textContent = "no pages match this filter.";
empty.append(card);
box.append(empty);
return;
@ -135,15 +112,17 @@ Glow.renderPages = () => {
const list = document.createElement("div");
list.className = "pages-list";
for (const page of pages) {
list.append(Glow.buildPageCard(page));
}
box.append(list);
};
Glow.buildPageCard = page => {
const article = document.createElement("article");
article.className = "page-card";
article.className = "page-card compact";
article.tabIndex = 0;
const meta = document.createElement("div");
@ -162,27 +141,19 @@ Glow.buildPageCard = page => {
const title = document.createElement("h3");
title.textContent = page.title;
const excerpt = document.createElement("p");
excerpt.className = "page-card-excerpt";
const compactBody = (page.body || "").replace(/\s+/g, " ").trim();
excerpt.textContent =
compactBody.length > 180
? `${compactBody.slice(0, 180)}…`
: compactBody || "No content yet.";
const footer = document.createElement("div");
footer.className = "page-card-footer";
const context = document.createElement("span");
context.textContent = page.parent_page_title
? `Child of ${page.parent_page_title}`
: page.channel_name
context.textContent = page.channel_name
? `#${page.channel_name}`
: "Team page";
: "team page";
const updated = document.createElement("time");
updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString();
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
updated.textContent = `updated ${Glow.relativeTime(page.updated_at)}`;
footer.append(context, updated);
const open = () => Glow.openPage(page.slug);
@ -194,7 +165,7 @@ Glow.buildPageCard = page => {
}
});
article.append(meta, title, excerpt, footer);
article.append(meta, title, footer);
return article;
};
@ -209,10 +180,12 @@ Glow.openPages = async () => {
Glow.openPage = async pageSlug => {
const team = Glow.state.activeTeam;
if (!team) return;
Glow.state.activeView = "page";
Glow.state.activePage = null;
Glow.updateViewUI();
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
@ -220,244 +193,13 @@ Glow.openPage = async pageSlug => {
Glow.state.activePage = data.page;
Glow.renderPageDetail();
} catch (error) {
Glow.showToast(error.message || "Could not load page.", "error");
Glow.showToast(error.message || "could not load page.", "error");
Glow.openPages();
} finally {
Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
}
};
Glow.renderPageComments = page => {
const section = document.createElement("section");
section.className = "profile-section";
const heading = document.createElement("h3");
heading.textContent = "Comments";
section.append(heading);
const list = document.createElement("div");
list.className = "page-comments-list";
const comments = Array.isArray(page.comments) ? page.comments : [];
if (!comments.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No comments yet.";
list.append(empty);
} else {
for (const comment of comments) {
const item = document.createElement("article");
item.className = "page-comment";
const top = document.createElement("div");
top.className = "page-comment-meta";
const author = document.createElement("strong");
author.textContent = comment.username;
author.style.color = comment.user_name_color || "";
const time = document.createElement("time");
time.dateTime = comment.created_at;
time.textContent = new Date(comment.created_at).toLocaleString();
top.append(author, time);
const body = document.createElement("div");
body.className = "page-comment-body";
body.textContent = comment.body;
item.append(top, body);
const canDelete =
Glow.state.currentUser &&
(Number(Glow.state.currentUser.id) === Number(comment.user_id) ||
Glow.state.currentUser.role === "admin" ||
page.can_edit);
if (canDelete) {
const remove = document.createElement("button");
remove.className = "small-button danger";
remove.type = "button";
remove.textContent = "Delete";
remove.addEventListener("click", async () => {
try {
await Glow.apiDeletePageComment(comment.id);
await Glow.openPage(page.slug);
Glow.showToast("Comment deleted.");
} catch (error) {
Glow.showToast(error.message || "Could not delete comment.", "error");
}
});
item.append(remove);
}
list.append(item);
}
}
section.append(list);
if (Glow.state.currentUser) {
const form = document.createElement("form");
form.className = "page-comment-form";
const area = document.createElement("textarea");
area.className = "field";
area.rows = 4;
area.maxLength = 3000;
area.placeholder = "Add a comment";
const submit = document.createElement("button");
submit.className = "button";
submit.type = "submit";
submit.textContent = "Post comment";
form.append(area, submit);
form.addEventListener("submit", async event => {
event.preventDefault();
const body = area.value.trim();
if (!body) return;
try {
await Glow.apiCreatePageComment(page.team_slug, page.slug, { body });
await Glow.openPage(page.slug);
Glow.showToast("Comment added.");
} catch (error) {
Glow.showToast(error.message || "Could not add comment.", "error");
}
});
section.append(form);
}
return section;
};
Glow.renderPageBacklinks = page => {
const section = document.createElement("section");
section.className = "profile-section";
const heading = document.createElement("h3");
heading.textContent = "Backlinks";
section.append(heading);
const backlinks = Array.isArray(page.backlinks) ? page.backlinks : [];
if (!backlinks.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No backlinks yet.";
section.append(empty);
return section;
}
const list = document.createElement("div");
list.className = "page-related-list";
for (const link of backlinks) {
const button = document.createElement("button");
button.className = "small-button";
button.type = "button";
button.textContent = link.title;
button.addEventListener("click", () => Glow.openPage(link.slug));
list.append(button);
}
section.append(list);
return section;
};
Glow.renderPageChildren = page => {
const section = document.createElement("section");
section.className = "profile-section";
const heading = document.createElement("h3");
heading.textContent = "Child pages";
section.append(heading);
const children = Array.isArray(page.children) ? page.children : [];
if (!children.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No child pages.";
section.append(empty);
return section;
}
const list = document.createElement("div");
list.className = "page-related-list";
for (const child of children) {
const button = document.createElement("button");
button.className = "small-button";
button.type = "button";
button.textContent = child.title;
button.addEventListener("click", () => Glow.openPage(child.slug));
list.append(button);
}
section.append(list);
return section;
};
Glow.openPageVersionHistory = async page => {
try {
const data = await Glow.apiListPageVersions(page.team_slug, page.slug);
const container = document.createElement("div");
container.className = "member-list";
const versions = data.versions || [];
if (!versions.length) {
const empty = document.createElement("div");
empty.className = "member-row";
empty.textContent = "No version history yet.";
container.append(empty);
} else {
for (const version of versions) {
const row = document.createElement("div");
row.className = "member-row";
const left = document.createElement("button");
left.className = "small-button";
left.type = "button";
left.textContent = `v${version.version_number} · ${version.title}`;
left.addEventListener("click", async () => {
try {
const detail = await Glow.apiGetPageVersion(version.id);
const body = document.createElement("div");
body.className = "page-body markdown-body";
body.innerHTML = detail.version.body_html || "<p>No content.</p>";
const result = await Glow.openDialog({
title: `Version ${detail.version.version_number}`,
body,
confirmText: page.can_edit ? "Restore version" : "Close",
cancelText: "Close",
danger: page.can_edit
});
if (result && page.can_edit) {
await Glow.apiRestorePageVersion(version.id);
Glow.showToast("Version restored.");
await Glow.openPage(page.slug);
}
} catch (error) {
Glow.showToast(error.message || "Could not load version.", "error");
}
});
const right = document.createElement("span");
right.textContent = new Date(version.created_at).toLocaleString();
row.append(left, right);
container.append(row);
}
}
await Glow.openDialog({
title: "Version history",
body: container,
confirmText: "Close",
cancelText: "Close"
});
} catch (error) {
Glow.showToast(error.message || "Could not load version history.", "error");
}
};
Glow.shareCurrentPage = async page => {
try {
const data = await Glow.apiCreatePageShare(page.team_slug, page.slug);
const url = new URL(data.share.url, window.location.origin).toString();
await navigator.clipboard.writeText(url);
Glow.showToast("Page share link copied.");
} catch (error) {
Glow.showToast(error.message || "Could not create page share.", "error");
}
};
Glow.renderPageDetail = () => {
const box = Glow.els.pageDetailBox;
const page = Glow.state.activePage;
@ -468,7 +210,7 @@ Glow.renderPageDetail = () => {
state.className = "empty-state";
const card = document.createElement("div");
card.className = "empty-card";
card.textContent = "Page not found.";
card.textContent = "page not found.";
state.append(card);
box.append(state);
return;
@ -492,7 +234,7 @@ Glow.renderPageDetail = () => {
updated.className = "page-updated";
updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString();
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
updated.textContent = `updated ${Glow.relativeTime(page.updated_at)}`;
meta.append(type, visibility, updated);
@ -501,9 +243,7 @@ Glow.renderPageDetail = () => {
const context = document.createElement("p");
context.className = "page-detail-context";
context.textContent = page.parent_page_title
? `${page.team_name} · Child of ${page.parent_page_title}`
: page.channel_name
context.textContent = page.channel_name
? `${page.team_name} · #${page.channel_name}`
: page.team_name;
@ -512,46 +252,23 @@ Glow.renderPageDetail = () => {
const body = document.createElement("article");
body.className = "page-body markdown-body";
body.innerHTML = page.body_html || "<p>No content yet.</p>";
body.innerHTML = page.body_html || "<p>no content yet.</p>";
box.append(body);
const related = document.createElement("div");
related.className = "profile-grid";
related.append(
Glow.renderPageChildren(page),
Glow.renderPageBacklinks(page),
Glow.renderPageComments(page)
);
box.append(related);
const actions = document.createElement("div");
actions.className = "page-detail-actions";
const history = document.createElement("button");
history.className = "button secondary";
history.type = "button";
history.textContent = "Version history";
history.addEventListener("click", () => Glow.openPageVersionHistory(page));
actions.append(history);
const share = document.createElement("button");
share.className = "button secondary";
share.type = "button";
share.textContent = "Share";
share.addEventListener("click", () => Glow.shareCurrentPage(page));
actions.append(share);
if (page.can_edit) {
const edit = document.createElement("button");
edit.className = "button";
edit.type = "button";
edit.textContent = "Edit";
edit.textContent = "edit";
edit.addEventListener("click", () => Glow.editPage(page));
const remove = document.createElement("button");
remove.className = "danger-button";
remove.type = "button";
remove.textContent = "Delete";
remove.textContent = "delete";
remove.addEventListener("click", () => Glow.deletePage(page));
actions.append(edit, remove);
@ -563,40 +280,33 @@ Glow.renderPageDetail = () => {
Glow.pageDialogFields = (page = null) => [
{
name: "title",
label: "Title",
label: "title",
value: page?.title || "",
placeholder: "Page title",
placeholder: "page title",
maxLength: 160
},
{
name: "page_type",
label: "Type: project, document, update, or resource",
label: "type: project, document, update, or resource",
value: page?.page_type || "document",
maxLength: 12
},
{
name: "visibility",
label: "Visibility: team or public",
label: "visibility: team or public",
value: page?.visibility || "team",
maxLength: 6
},
{
name: "channel_slug",
label: "Channel slug (optional)",
label: "channel slug (optional)",
value: page?.channel_slug || "",
placeholder: "general",
maxLength: 48
},
{
name: "parent_page_slug",
label: "Parent page slug (optional)",
value: page?.parent_page_slug || "",
placeholder: "project-overview",
maxLength: 48
},
{
name: "body",
label: "Markdown content",
label: "markdown content",
value: page?.body || "",
multiline: true,
rows: 14,
@ -606,41 +316,46 @@ Glow.pageDialogFields = (page = null) => [
Glow.validatePageValues = values => {
if (!values.title) {
Glow.showToast("Page title is required.", "error");
Glow.showToast("page title is required.", "error");
return null;
}
const pageType = values.page_type.toLowerCase();
if (!["project", "document", "update", "resource"].includes(pageType)) {
Glow.showToast("Choose a valid page type.", "error");
Glow.showToast("choose a valid page type.", "error");
return null;
}
const visibility = values.visibility.toLowerCase();
if (!["team", "public"].includes(visibility)) {
Glow.showToast("Visibility must be team or public.", "error");
Glow.showToast("visibility must be team or public.", "error");
return null;
}
return {
title: values.title,
body: values.body,
page_type: pageType,
visibility,
channel_slug: values.channel_slug.toLowerCase(),
parent_page_slug: values.parent_page_slug.toLowerCase()
channel_slug: values.channel_slug.toLowerCase()
};
};
Glow.createPage = async () => {
const team = Glow.state.activeTeam;
if (!team?.can_manage) return;
const values = await Glow.openDialog({
title: "Create page",
body: "Create a shared team page or a public resource. Markdown is supported.",
confirmText: "Create page",
title: "create page",
body: "create a shared team page or a public resource. markdown is supported.",
confirmText: "create page",
fields: Glow.pageDialogFields()
});
if (!values) return;
const payload = Glow.validatePageValues(values);
if (!payload) return;
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages`,
@ -650,23 +365,26 @@ Glow.createPage = async () => {
body: JSON.stringify(payload)
}
);
Glow.showToast("Page created.");
Glow.showToast("page created.");
await Glow.openPage(data.page.slug);
} catch (error) {
Glow.showToast(error.message || "Could not create page.", "error");
Glow.showToast(error.message || "could not create page.", "error");
}
};
Glow.editPage = async page => {
if (!page.can_edit) return;
const values = await Glow.openDialog({
title: `Edit ${page.title}`,
confirmText: "Save",
title: `edit ${page.title}`,
confirmText: "save",
fields: Glow.pageDialogFields(page)
});
if (!values) return;
const payload = Glow.validatePageValues(values);
if (!payload) return;
try {
const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
@ -678,29 +396,31 @@ Glow.editPage = async page => {
);
Glow.state.activePage = data.page;
Glow.renderPageDetail();
Glow.showToast("Page updated.");
Glow.showToast("page updated.");
} catch (error) {
Glow.showToast(error.message || "Could not update page.", "error");
Glow.showToast(error.message || "could not update page.", "error");
}
};
Glow.deletePage = async page => {
if (!page.can_edit) return;
const result = await Glow.openDialog({
title: `Delete ${page.title}?`,
body: "This page will no longer be available.",
confirmText: "Delete page",
title: `delete ${page.title}?`,
body: "this page will no longer be available.",
confirmText: "delete page",
danger: true
});
if (!result) return;
try {
await Glow.apiRequest(
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
{ method: "DELETE" }
);
Glow.showToast("Page deleted.");
Glow.showToast("page deleted.");
await Glow.openPages();
} catch (error) {
Glow.showToast(error.message || "Could not delete page.", "error");
Glow.showToast(error.message || "could not delete page.", "error");
}
};

View File

@ -3,11 +3,14 @@
window.Glow = window.Glow || {};
// note:
// this file keeps team/channel helper methods available,
// but the old production monkey-patching adapter is disabled.
// rooms remain the stable fallback runtime path.
Glow.teamAdapter = Glow.teamAdapter || {
initialized: false,
originalLoadRooms: null,
originalUpdateViewUI: null,
chatBarVisibilityInstalled: false
enabled: false
};
Glow.loadTeamsForAdapter = async () => {
@ -43,7 +46,7 @@ Glow.loadTeamsForAdapter = async () => {
Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
}
} catch (error) {
console.warn("Team adapter could not load teams:", error);
console.warn("could not load teams:", error);
Glow.state.teams = [];
Glow.state.activeTeam = null;
Glow.els.teamSelect.disabled = true;
@ -69,7 +72,6 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
Glow.state.channels = channels;
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
if (matchingTeam || data.team) {
Glow.state.activeTeam = {
...(matchingTeam || {}),
@ -89,24 +91,21 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
const selected =
channels.find(channel => channel.slug === preferredChannelSlug) ||
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) ||
channels.find(channel => channel.slug === Glow.state.activeRoom?.slug) ||
channels.find(channel => channel.slug === "general") ||
channels[0] ||
null;
Glow.state.activeChannel = selected;
Glow.state.activeRoom = selected;
Glow.els.roomSelect.disabled = !selected;
if (selected) {
Glow.state.activeRoom = selected;
Glow.els.roomSelect.value = selected.slug;
}
Glow.els.roomSelect.disabled = !selected;
};
Glow.decorateChannelSelector = () => {
if (!Glow.els.roomSelect) return;
Glow.els.roomSelect.classList.add("channel-select");
Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
};
@ -114,21 +113,23 @@ Glow.decorateChannelSelector = () => {
Glow.updateTeamChannelHeader = () => {
const team = Glow.state.activeTeam;
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
const isChannelView =
Glow.state.activeView === "channel" ||
Glow.state.activeView === "room";
const view = Glow.state.activeView;
if (
Glow.state.activeView === "dm" ||
Glow.state.activeView === "dms"
view === "dm" ||
view === "dms" ||
view === "feed" ||
view === "pages" ||
view === "page" ||
view === "profile" ||
view === "settings" ||
view === "thread"
) {
return;
}
if (!isChannelView || !team || !channel || !Glow.els.headerTitle) {
return;
}
if (!(view === "channel" || view === "room")) return;
if (!team || !channel || !Glow.els.headerTitle) return;
Glow.els.headerTitle.textContent = `# ${channel.name}`;
Glow.els.headerSubtitle.textContent =
@ -530,152 +531,10 @@ Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) =>
Glow.installTeamAdapter = () => {
if (Glow.teamAdapter.initialized) return;
if (!Glow.els.teamSelect || !Glow.els.roomSelect) {
console.warn("Team adapter skipped: expected team/channel elements are missing.");
return;
}
Glow.teamAdapter.initialized = true;
Glow.state.teams ||= [];
Glow.state.channels ||= [];
Glow.state.activeTeam ||= null;
Glow.state.activeChannel ||= null;
Glow.decorateChannelSelector();
Glow.teamAdapter.originalLoadRooms = Glow.loadRooms;
Glow.loadRooms = async () => {
await Glow.teamAdapter.originalLoadRooms();
await Glow.loadTeamsForAdapter();
Glow.decorateChannelSelector();
if (Glow.state.activeTeam) {
try {
await Glow.loadChannelsForTeam(
Glow.state.activeTeam.slug,
Glow.state.activeRoom?.slug
);
} catch (error) {
console.warn("Team adapter could not load channels after rooms:", error);
}
}
Glow.updateTeamChannelHeader();
};
Glow.teamAdapter.originalUpdateViewUI = Glow.updateViewUI;
Glow.updateViewUI = () => {
Glow.teamAdapter.originalUpdateViewUI?.();
Glow.updateTeamChannelHeader();
Glow.applyComposerVisibility();
};
const initializeAdapter = async () => {
await Glow.loadTeamsForAdapter();
Glow.decorateChannelSelector();
if (!Glow.state.activeTeam) {
Glow.applyComposerVisibility();
return;
}
try {
await Glow.loadChannelsForTeam(
Glow.state.activeTeam.slug,
Glow.state.activeRoom?.slug
);
Glow.updateTeamChannelHeader();
} catch (error) {
console.warn("Team adapter could not load initial channels:", error);
}
Glow.applyComposerVisibility();
};
initializeAdapter();
Glow.els.teamSelect.addEventListener("change", async () => {
const selected = Glow.state.teams.find(
team => team.slug === Glow.els.teamSelect.value
);
if (!selected) return;
Glow.state.activeDm = null;
Glow.state.activeTeam = selected;
Glow.state.activeChannel = null;
Glow.state.activeRoom = null;
Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = [];
Glow.renderTyping?.();
Glow.clearMessages?.();
try {
await Glow.loadChannelsForTeam(selected.slug);
Glow.state.activeView = "channel";
Glow.updateViewUI();
if (Glow.state.activeChannel) {
await Glow.loadChannelMessages?.();
Glow.pollTyping?.();
Glow.pingPresence?.();
Glow.refreshPresence?.();
} else {
Glow.renderEmptyState?.("No channels are available in this team.");
}
} catch (error) {
Glow.showToast(error.message || "Could not load channels for this team.", "error");
}
Glow.schedulePoll?.();
});
Glow.els.roomSelect.addEventListener("change", () => {
window.setTimeout(() => {
const selected = Glow.state.channels.find(
channel => channel.slug === Glow.els.roomSelect.value
);
if (!selected) return;
Glow.state.activeDm = null;
Glow.state.activeChannel = selected;
Glow.state.activeRoom = selected;
if (
Glow.state.activeView === "room" ||
Glow.state.activeView === "channel"
) {
Glow.state.activeView = "channel";
}
Glow.updateViewUI();
Glow.pingPresence?.();
Glow.refreshPresence?.();
}, 0);
});
console.info("Team/channel adapter enabled.");
console.info("team/channel helper mode enabled; runtime monkey-patching disabled.");
};
Glow.installTeamAdapter();
if (!Glow.teamAdapter.chatBarVisibilityInstalled) {
Glow.teamAdapter.chatBarVisibilityInstalled = true;
const previousUpdateViewUI = Glow.updateViewUI;
Glow.updateViewUI = () => {
previousUpdateViewUI?.();
Glow.applyComposerVisibility();
};
}
Glow.applyComposerVisibility();

View File

@ -20,21 +20,161 @@ Glow.applyCustomCss = cssText => {
style.textContent = typeof cssText === "string" ? cssText : "";
};
Glow.cloneSettingsState = source => {
const value = source && typeof source === "object" ? source : {};
return {
accent_color:
Glow.normalizeAccentColor(value.accent_color || Glow.state.accentColor) ||
Glow.DEFAULT_ACCENT_COLOR,
name_color:
Glow.normalizeNameColor(value.name_color) || null,
custom_css:
typeof value.custom_css === "string" ? value.custom_css : "",
theme_mode:
value.theme_mode === "light" ? "light" : "dark",
profile_background_color:
Glow.normalizeAccentColor(value.profile_background_color) || "#121214",
profile_banner_color:
Glow.normalizeAccentColor(value.profile_banner_color) || "#3a3140",
profile_visibility:
["public", "members", "private"].includes(
String(value.profile_visibility || "").toLowerCase()
)
? String(value.profile_visibility).toLowerCase()
: "public",
last_active_visibility:
["everyone", "members", "nobody"].includes(
String(value.last_active_visibility || "").toLowerCase()
)
? String(value.last_active_visibility).toLowerCase()
: "everyone",
presence_mode:
["online", "silent", "focus", "dnd"].includes(
String(value.presence_mode || "").toLowerCase()
)
? String(value.presence_mode).toLowerCase()
: "online"
};
};
Glow.initializeSettingsDraft = settings => {
const saved = Glow.cloneSettingsState(settings);
Glow.state.settings = saved;
Glow.state.settingsSaved = saved;
Glow.state.settingsDraft = Glow.cloneSettingsState(saved);
Glow.clearSettingsDirty?.();
};
Glow.previewSettingsDraft = () => {
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
Glow.applyAccentColor(draft.accent_color);
Glow.applyThemeMode(draft.theme_mode);
Glow.applyCustomCss(draft.custom_css || "");
};
Glow.updateSettingsDraft = patch => {
const current = Glow.state.settingsDraft || Glow.cloneSettingsState({});
Glow.state.settingsDraft = {
...current,
...patch
};
Glow.previewSettingsDraft();
Glow.markSettingsDirty?.();
};
Glow.syncSettingsSavePanel = () => {
const panel = Glow.els.settingsSavePanel;
if (!panel) return;
const shouldShow =
Glow.state.activeView === "settings" &&
Glow.isSettingsDirty?.();
panel.classList.toggle("hidden", !shouldShow);
if (Glow.els.settingsUnsavedIndicator) {
Glow.els.settingsUnsavedIndicator.textContent = shouldShow
? "Unsaved changes"
: "";
}
};
Glow.saveSettingsDraft = async () => {
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
const payload = {
accent_color:
Glow.normalizeAccentColor(draft.accent_color) ||
Glow.DEFAULT_ACCENT_COLOR,
name_color:
Glow.normalizeNameColor(draft.name_color) || null,
custom_css: draft.custom_css || "",
theme_mode: draft.theme_mode === "light" ? "light" : "dark",
profile_background_color:
Glow.normalizeAccentColor(draft.profile_background_color) || null,
profile_banner_color:
Glow.normalizeAccentColor(draft.profile_banner_color) || null,
profile_visibility:
["public", "members", "private"].includes(draft.profile_visibility)
? draft.profile_visibility
: "public",
last_active_visibility:
["everyone", "members", "nobody"].includes(draft.last_active_visibility)
? draft.last_active_visibility
: "everyone",
presence_mode:
["online", "silent", "focus", "dnd"].includes(draft.presence_mode)
? draft.presence_mode
: "online"
};
const data = await Glow.apiUpdateMySettings(payload);
const merged = {
...Glow.cloneSettingsState(draft),
...(data.settings || {})
};
Glow.state.settings = merged;
Glow.state.settingsSaved = Glow.cloneSettingsState(merged);
Glow.state.settingsDraft = Glow.cloneSettingsState(merged);
Glow.previewSettingsDraft();
Glow.clearSettingsDirty?.();
if (Glow.state.currentUser) {
Glow.state.currentUser.presence_mode =
merged.presence_mode || Glow.state.currentUser.presence_mode || "online";
}
Glow.showToast("Settings saved.");
Glow.renderSettingsView();
};
Glow.discardSettingsDraft = () => {
const saved = Glow.state.settingsSaved || Glow.cloneSettingsState({});
Glow.state.settingsDraft = Glow.cloneSettingsState(saved);
Glow.previewSettingsDraft();
Glow.clearSettingsDirty?.();
if (Glow.state.activeView === "settings") {
Glow.renderSettingsView();
}
};
Glow.loadMySettings = async () => {
try {
const data = await Glow.apiGetMySettings();
Glow.state.settings = data.settings || {};
const accent = Glow.state.settings.accent_color || Glow.state.accentColor;
Glow.applyAccentColor(accent);
const mode =
Glow.state.settings.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark";
Glow.applyThemeMode(mode);
Glow.applyCustomCss(Glow.state.settings.custom_css || "");
const base = Glow.cloneSettingsState(data.settings || {});
Glow.initializeSettingsDraft(base);
Glow.previewSettingsDraft();
} catch (_) {
const mode = localStorage.getItem("glow_theme_mode") || "dark";
Glow.applyThemeMode(mode);
const fallback = Glow.cloneSettingsState({
theme_mode: localStorage.getItem("glow_theme_mode") || "dark",
accent_color: Glow.state.accentColor,
profile_visibility: "public",
last_active_visibility: "everyone",
presence_mode: "online"
});
Glow.initializeSettingsDraft(fallback);
Glow.previewSettingsDraft();
}
};
@ -42,15 +182,19 @@ Glow.renderHeaderMenu = () => {
const list = Glow.els.headerMenuList;
if (!list) return;
list.replaceChildren();
const addItem = ({ label, hint = "", onClick, hidden = false }) => {
if (hidden) return;
const button = document.createElement("button");
button.className = "header-menu-item";
button.type = "button";
const left = document.createElement("span");
left.textContent = label;
const right = document.createElement("small");
right.textContent = hint;
button.append(left, right);
button.addEventListener("click", () => {
Glow.closeHeaderMenu?.();
@ -58,17 +202,20 @@ Glow.renderHeaderMenu = () => {
});
list.append(button);
};
addItem({
label: "My profile",
hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "",
onClick: () => Glow.openMyProfile?.(),
hidden: !Glow.state.currentUser
});
addItem({
label: "Settings",
hint: "Theme, CSS, profile",
onClick: () => Glow.openSettings?.()
});
addItem({
label: "Direct messages",
hint: Glow.state.currentUser ? "Private chat" : "",
@ -80,24 +227,28 @@ Glow.renderHeaderMenu = () => {
},
hidden: !Glow.state.currentUser
});
addItem({
label: "Create team",
hint: "Up to 3 max",
onClick: () => Glow.openCreateTeamDialog?.(),
hidden: !Glow.state.currentUser
});
addItem({
label: "Edit current team",
hint: Glow.state.activeTeam?.can_manage ? "Manage" : "",
onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Add channel",
hint: Glow.state.activeTeam?.name || "",
onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Edit current channel",
hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "",
@ -111,18 +262,21 @@ Glow.renderHeaderMenu = () => {
!(Glow.state.activeChannel || Glow.state.activeRoom) ||
Glow.state.activeView === "dm"
});
addItem({
label: "Manage team members",
hint: Glow.state.activeTeam?.can_manage ? "Members" : "",
onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Create team invite",
hint: Glow.state.activeTeam?.can_manage ? "Share" : "",
onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam),
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
});
addItem({
label: "Delete current channel",
hint: Glow.state.activeChannel?.name || "",
@ -155,18 +309,28 @@ Glow.toggleHeaderMenu = () => {
Glow.openSettings = async () => {
Glow.state.activeView = "settings";
Glow.updateViewUI();
if (!Glow.state.settings && Glow.state.currentUser) {
if (!Glow.state.settingsSaved && Glow.state.currentUser) {
await Glow.loadMySettings();
} else if (!Glow.state.settingsDraft) {
Glow.initializeSettingsDraft(
Glow.state.settingsSaved || Glow.cloneSettingsState({})
);
Glow.previewSettingsDraft();
}
Glow.renderSettingsView();
Glow.syncSettingsSavePanel();
};
Glow.renderNameColorPreview = value => {
const wrap = document.createElement("div");
wrap.className = "settings-row";
const label = document.createElement("div");
label.className = "header-subtitle";
label.textContent = "Preview";
const preview = document.createElement("div");
preview.className = "name-color-preview";
preview.textContent =
@ -174,118 +338,69 @@ Glow.renderNameColorPreview = value => {
preview.style.color =
Glow.normalizeNameColor(value) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
wrap.append(label, preview);
return wrap;
};
Glow.renderChoicePills = ({
label,
value,
options,
onChange
}) => {
const wrap = document.createElement("div");
wrap.className = "settings-row";
const title = document.createElement("div");
title.className = "dialog-label";
title.textContent = label;
const row = document.createElement("div");
row.className = "settings-theme-row";
for (const option of options) {
const button = document.createElement("button");
button.className = `theme-pill${value === option.value ? " active" : ""}`;
button.type = "button";
button.textContent = option.label;
button.addEventListener("click", () => onChange(option.value));
row.append(button);
}
wrap.append(title, row);
return wrap;
};
Glow.renderSettingsView = () => {
const box = Glow.els.profileBox;
box.replaceChildren();
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
const card = document.createElement("div");
card.className = "settings-card";
const appearance = document.createElement("section");
appearance.className = "settings-section";
const aTitle = document.createElement("h2");
aTitle.textContent = "Appearance";
const aText = document.createElement("p");
aText.textContent = "Set your theme mode, accent color, username color, profile background, and banner color.";
const identity = document.createElement("section");
identity.className = "settings-section";
const themeRow = document.createElement("div");
themeRow.className = "settings-theme-row";
const currentTheme =
Glow.state.settings?.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark";
const iTitle = document.createElement("h2");
iTitle.textContent = "Identity";
const darkButton = document.createElement("button");
darkButton.className = `theme-pill${currentTheme !== "light" ? " active" : ""}`;
darkButton.type = "button";
darkButton.textContent = "Dark";
const lightButton = document.createElement("button");
lightButton.className = `theme-pill${currentTheme === "light" ? " active" : ""}`;
lightButton.type = "button";
lightButton.textContent = "Light";
darkButton.addEventListener("click", () => {
Glow.applyThemeMode("dark");
Glow.state.settings ||= {};
Glow.state.settings.theme_mode = "dark";
Glow.renderSettingsView();
});
lightButton.addEventListener("click", () => {
Glow.applyThemeMode("light");
Glow.state.settings ||= {};
Glow.state.settings.theme_mode = "light";
Glow.renderSettingsView();
});
themeRow.append(darkButton, lightButton);
const accentWrap = document.createElement("div");
accentWrap.className = "settings-row";
const accentLabel = document.createElement("label");
accentLabel.className = "dialog-label";
accentLabel.textContent = "Accent color";
const accentInput = document.createElement("input");
accentInput.type = "color";
accentInput.className = "accent-color-input";
accentInput.value =
Glow.normalizeAccentColor(
Glow.state.settings?.accent_color || Glow.state.accentColor
) || Glow.DEFAULT_ACCENT_COLOR;
accentInput.addEventListener("input", () => {
Glow.applyAccentColor(accentInput.value);
Glow.state.settings ||= {};
Glow.state.settings.accent_color = accentInput.value;
});
accentLabel.append(accentInput);
accentWrap.append(accentLabel);
const backgroundWrap = document.createElement("div");
backgroundWrap.className = "settings-row";
const backgroundLabel = document.createElement("label");
backgroundLabel.className = "dialog-label";
backgroundLabel.textContent = "Profile background color";
const backgroundInput = document.createElement("input");
backgroundInput.type = "color";
backgroundInput.className = "accent-color-input";
backgroundInput.value =
Glow.normalizeAccentColor(Glow.state.settings?.profile_background_color) || "#121214";
backgroundInput.addEventListener("input", () => {
Glow.state.settings ||= {};
Glow.state.settings.profile_background_color = backgroundInput.value;
});
backgroundLabel.append(backgroundInput);
backgroundWrap.append(backgroundLabel);
const bannerWrap = document.createElement("div");
bannerWrap.className = "settings-row";
const bannerLabel = document.createElement("label");
bannerLabel.className = "dialog-label";
bannerLabel.textContent = "Profile banner color";
const bannerInput = document.createElement("input");
bannerInput.type = "color";
bannerInput.className = "accent-color-input";
bannerInput.value =
Glow.normalizeAccentColor(Glow.state.settings?.profile_banner_color) || "#3a3140";
bannerInput.addEventListener("input", () => {
Glow.state.settings ||= {};
Glow.state.settings.profile_banner_color = bannerInput.value;
});
bannerLabel.append(bannerInput);
bannerWrap.append(bannerLabel);
const iText = document.createElement("p");
iText.textContent = "Adjust how your name and profile presence appear across the app.";
const nameColorWrap = document.createElement("div");
nameColorWrap.className = "settings-row";
const nameColorLabel = document.createElement("label");
nameColorLabel.className = "dialog-label";
nameColorLabel.textContent = "Username color";
const nameColorInput = document.createElement("input");
nameColorInput.type = "color";
nameColorInput.className = "accent-color-input";
nameColorInput.value =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
Glow.normalizeNameColor(draft.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const nameColorText = document.createElement("input");
@ -293,43 +408,30 @@ Glow.renderSettingsView = () => {
nameColorText.type = "text";
nameColorText.maxLength = 7;
nameColorText.value =
Glow.normalizeNameColor(Glow.state.settings?.name_color) || "";
Glow.normalizeNameColor(draft.name_color) || "";
nameColorText.placeholder = "#7dd3fc";
const nameRow = document.createElement("div");
nameRow.className = "accent-picker-row";
const previewSwatch = document.createElement("span");
previewSwatch.className = "accent-preview";
previewSwatch.style.background =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
Glow.normalizeNameColor(draft.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const help = document.createElement("div");
help.className = "header-subtitle";
help.textContent =
`Leave empty to use your role default (${Glow.roleDefaultNameColor(Glow.state.currentUser?.role)}).`;
const updatePreview = value => {
const normalized = Glow.normalizeNameColor(value);
const finalColor =
normalized || Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
previewSwatch.style.background = finalColor;
return finalColor;
};
nameColorInput.addEventListener("input", () => {
nameColorText.value = nameColorInput.value;
Glow.state.settings ||= {};
Glow.state.settings.name_color = nameColorInput.value;
updatePreview(nameColorInput.value);
Glow.renderSettingsView();
Glow.updateSettingsDraft({
name_color: nameColorInput.value
});
});
nameColorText.addEventListener("input", () => {
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
Glow.state.settings ||= {};
Glow.state.settings.name_color = normalized || null;
updatePreview(nameColorText.value.trim());
Glow.updateSettingsDraft({
name_color: normalized || null
});
});
const resetNameColor = document.createElement("button");
@ -337,8 +439,9 @@ Glow.renderSettingsView = () => {
resetNameColor.type = "button";
resetNameColor.textContent = "Use role default";
resetNameColor.addEventListener("click", () => {
Glow.state.settings ||= {};
Glow.state.settings.name_color = null;
Glow.updateSettingsDraft({
name_color: null
});
Glow.renderSettingsView();
});
@ -346,106 +449,213 @@ Glow.renderSettingsView = () => {
nameColorLabel.append(nameRow);
nameColorWrap.append(
nameColorLabel,
help,
Glow.renderNameColorPreview(Glow.state.settings?.name_color),
Glow.renderNameColorPreview(draft.name_color),
resetNameColor
);
appearance.append(
aTitle,
aText,
themeRow,
accentWrap,
backgroundWrap,
bannerWrap,
nameColorWrap
const profileVisibility = Glow.renderChoicePills({
label: "Profile visibility",
value: draft.profile_visibility || "public",
options: [
{ value: "public", label: "Public" },
{ value: "members", label: "Members" },
{ value: "private", label: "Private" }
],
onChange: value => {
Glow.updateSettingsDraft({
profile_visibility: value
});
Glow.renderSettingsView();
}
});
const lastActiveVisibility = Glow.renderChoicePills({
label: "Last active visibility",
value: draft.last_active_visibility || "everyone",
options: [
{ value: "everyone", label: "Everyone" },
{ value: "members", label: "Members" },
{ value: "nobody", label: "Nobody" }
],
onChange: value => {
Glow.updateSettingsDraft({
last_active_visibility: value
});
Glow.renderSettingsView();
}
});
const presenceMode = Glow.renderChoicePills({
label: "Presence mode",
value: draft.presence_mode || "online",
options: [
{ value: "online", label: "Online" },
{ value: "silent", label: "Silent" },
{ value: "focus", label: "Focus" },
{ value: "dnd", label: "Dnd" }
],
onChange: value => {
Glow.updateSettingsDraft({
presence_mode: value
});
Glow.renderSettingsView();
}
});
identity.append(
iTitle,
iText,
nameColorWrap,
profileVisibility,
lastActiveVisibility,
presenceMode
);
const presenceSection = document.createElement("section");
presenceSection.className = "settings-section";
const pTitle = document.createElement("h2");
pTitle.textContent = "Presence & privacy";
const pText = document.createElement("p");
pText.textContent = "Control who can view your profile and last active time, and set your presence mode.";
const appearance = document.createElement("section");
appearance.className = "settings-section";
const profileVisibility = document.createElement("input");
profileVisibility.className = "field";
profileVisibility.value = Glow.state.settings?.profile_visibility || "public";
profileVisibility.placeholder = "public | members | private";
const aTitle = document.createElement("h2");
aTitle.textContent = "Appearance";
const lastActiveVisibility = document.createElement("input");
lastActiveVisibility.className = "field";
lastActiveVisibility.value = Glow.state.settings?.last_active_visibility || "everyone";
lastActiveVisibility.placeholder = "everyone | members | nobody";
const aText = document.createElement("p");
aText.textContent = "Set theme and accent color.";
const presenceMode = document.createElement("input");
presenceMode.className = "field";
presenceMode.value = Glow.state.settings?.presence_mode || Glow.state.currentUser?.presence_mode || "online";
presenceMode.placeholder = "online | silent | focus | dnd";
const themeRow = document.createElement("div");
themeRow.className = "settings-theme-row";
profileVisibility.addEventListener("input", () => {
Glow.state.settings ||= {};
Glow.state.settings.profile_visibility = profileVisibility.value.trim().toLowerCase();
});
lastActiveVisibility.addEventListener("input", () => {
Glow.state.settings ||= {};
Glow.state.settings.last_active_visibility = lastActiveVisibility.value.trim().toLowerCase();
});
presenceMode.addEventListener("input", () => {
Glow.state.settings ||= {};
Glow.state.settings.presence_mode = presenceMode.value.trim().toLowerCase();
const darkButton = document.createElement("button");
darkButton.className = `theme-pill${draft.theme_mode !== "light" ? " active" : ""}`;
darkButton.type = "button";
darkButton.textContent = "Dark";
const lightButton = document.createElement("button");
lightButton.className = `theme-pill${draft.theme_mode === "light" ? " active" : ""}`;
lightButton.type = "button";
lightButton.textContent = "Light";
darkButton.addEventListener("click", () => {
Glow.updateSettingsDraft({ theme_mode: "dark" });
Glow.renderSettingsView();
});
presenceSection.append(
pTitle,
pText,
(() => {
const label = document.createElement("label");
label.className = "dialog-label";
label.textContent = "Profile visibility";
label.append(profileVisibility);
return label;
})(),
(() => {
const label = document.createElement("label");
label.className = "dialog-label";
label.textContent = "Last active visibility";
label.append(lastActiveVisibility);
return label;
})(),
(() => {
const label = document.createElement("label");
label.className = "dialog-label";
label.textContent = "Presence mode";
label.append(presenceMode);
return label;
})()
);
lightButton.addEventListener("click", () => {
Glow.updateSettingsDraft({ theme_mode: "light" });
Glow.renderSettingsView();
});
themeRow.append(darkButton, lightButton);
const accentWrap = document.createElement("div");
accentWrap.className = "settings-row";
const accentLabel = document.createElement("label");
accentLabel.className = "dialog-label";
accentLabel.textContent = "Accent color";
const accentInput = document.createElement("input");
accentInput.type = "color";
accentInput.className = "accent-color-input";
accentInput.value =
Glow.normalizeAccentColor(draft.accent_color) ||
Glow.DEFAULT_ACCENT_COLOR;
accentInput.addEventListener("input", () => {
Glow.updateSettingsDraft({
accent_color: accentInput.value
});
});
accentLabel.append(accentInput);
accentWrap.append(accentLabel);
appearance.append(aTitle, aText, themeRow, accentWrap);
const profileVisuals = document.createElement("section");
profileVisuals.className = "settings-section";
const pvTitle = document.createElement("h2");
pvTitle.textContent = "Profile visuals";
const pvText = document.createElement("p");
pvText.textContent = "Adjust background and banner colors used in your profile view.";
const backgroundWrap = document.createElement("div");
backgroundWrap.className = "settings-row";
const backgroundLabel = document.createElement("label");
backgroundLabel.className = "dialog-label";
backgroundLabel.textContent = "Profile background color";
const backgroundInput = document.createElement("input");
backgroundInput.type = "color";
backgroundInput.className = "accent-color-input";
backgroundInput.value =
Glow.normalizeAccentColor(draft.profile_background_color) || "#121214";
backgroundInput.addEventListener("input", () => {
Glow.updateSettingsDraft({
profile_background_color: backgroundInput.value
});
});
backgroundLabel.append(backgroundInput);
backgroundWrap.append(backgroundLabel);
const bannerWrap = document.createElement("div");
bannerWrap.className = "settings-row";
const bannerLabel = document.createElement("label");
bannerLabel.className = "dialog-label";
bannerLabel.textContent = "Profile banner color";
const bannerInput = document.createElement("input");
bannerInput.type = "color";
bannerInput.className = "accent-color-input";
bannerInput.value =
Glow.normalizeAccentColor(draft.profile_banner_color) || "#3a3140";
bannerInput.addEventListener("input", () => {
Glow.updateSettingsDraft({
profile_banner_color: bannerInput.value
});
});
bannerLabel.append(bannerInput);
bannerWrap.append(bannerLabel);
profileVisuals.append(pvTitle, pvText, backgroundWrap, bannerWrap);
const cssSection = document.createElement("section");
cssSection.className = "settings-section";
const cTitle = document.createElement("h2");
cTitle.textContent = "Custom CSS";
const cText = document.createElement("p");
cText.textContent =
"Optional CSS applied only for your account in this browser session and saved to your settings. \n ex: *,html,body{font-size:95%;text-transform:lowercase;}";
"Optional CSS applied locally for preview and saved to your account when supported.";
const cssInput = document.createElement("textarea");
cssInput.className = "field settings-custom-css";
cssInput.placeholder = "/* Custom CSS */";
cssInput.value = Glow.state.settings?.custom_css || "";
cssInput.value = draft.custom_css || "";
cssInput.addEventListener("input", () => {
Glow.applyCustomCss(cssInput.value);
Glow.state.settings ||= {};
Glow.state.settings.custom_css = cssInput.value;
Glow.updateSettingsDraft({
custom_css: cssInput.value
});
});
cssSection.append(cTitle, cText, cssInput);
const accountSection = document.createElement("section");
accountSection.className = "settings-section";
const accountTitle = document.createElement("h2");
accountTitle.textContent = "Account";
const accountText = document.createElement("p");
accountText.textContent = "Update your public profile and application preferences.";
accountText.textContent = "Open your profile editor or reset visual customizations.";
const actions = document.createElement("div");
actions.className = "settings-actions";
@ -457,61 +667,57 @@ Glow.renderSettingsView = () => {
Glow.openMyProfile?.(true);
});
const saveSettings = document.createElement("button");
saveSettings.className = "button";
saveSettings.type = "button";
saveSettings.textContent = "Save settings";
saveSettings.addEventListener("click", async () => {
try {
const payload = {
accent_color:
Glow.normalizeAccentColor(
Glow.state.settings?.accent_color || Glow.state.accentColor
) || Glow.DEFAULT_ACCENT_COLOR,
name_color:
Glow.normalizeNameColor(Glow.state.settings?.name_color) || null,
custom_css: Glow.state.settings?.custom_css || "",
theme_mode:
Glow.state.settings?.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark",
profile_background_color:
Glow.normalizeAccentColor(Glow.state.settings?.profile_background_color) || null,
profile_banner_color:
Glow.normalizeAccentColor(Glow.state.settings?.profile_banner_color) || null,
profile_visibility:
Glow.state.settings?.profile_visibility || "public",
last_active_visibility:
Glow.state.settings?.last_active_visibility || "everyone",
presence_mode:
Glow.state.settings?.presence_mode || "online"
};
const data = await Glow.apiUpdateMySettings(payload);
Glow.state.settings = data.settings || payload;
if (Glow.state.currentUser) {
Glow.state.currentUser.presence_mode = Glow.state.settings.presence_mode || "online";
}
Glow.showToast("Settings saved.");
Glow.renderSettingsView();
} catch (error) {
Glow.showToast(error.message || "Could not save settings.", "error");
}
});
const resetAccent = document.createElement("button");
resetAccent.className = "button secondary";
resetAccent.type = "button";
resetAccent.textContent = "Reset accent";
resetAccent.addEventListener("click", () => {
Glow.resetAccentColor();
Glow.state.settings ||= {};
Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR;
Glow.updateSettingsDraft({
accent_color: Glow.DEFAULT_ACCENT_COLOR
});
Glow.renderSettingsView();
});
actions.append(editProfile, saveSettings, resetAccent);
const discard = document.createElement("button");
discard.className = "button secondary";
discard.type = "button";
discard.textContent = "Discard changes";
discard.addEventListener("click", () => {
Glow.discardSettingsDraft();
});
actions.append(editProfile, resetAccent, discard);
accountSection.append(accountTitle, accountText, actions);
card.append(appearance, presenceSection, cssSection, accountSection);
card.append(identity, appearance, profileVisuals, cssSection, accountSection);
box.append(card);
Glow.syncSettingsSavePanel();
};
Glow.els.settingsSaveButton?.addEventListener("click", async () => {
try {
await Glow.saveSettingsDraft();
} catch (error) {
Glow.showToast(error.message || "Could not save settings.", "error");
}
});
Glow.els.settingsCloseButton?.addEventListener("click", async () => {
if (!Glow.isSettingsDirty?.()) {
await Glow.openFeed?.();
return;
}
const values = await Glow.openDialog({
title: "Leave settings?",
body: "You have unsaved changes.",
confirmText: "Discard changes",
cancelText: "Stay",
danger: true
});
if (!values) return;
Glow.discardSettingsDraft();
await Glow.openFeed?.();
});

View File

@ -23,6 +23,135 @@ Glow.renderProfileBody = profile => {
return wrap;
};
Glow.profileCanShowPresenceBadge = profile => {
if (!profile) return false;
if (profile.is_self) return true;
if (typeof profile.show_presence_badge === "boolean") {
return profile.show_presence_badge;
}
return Glow.state.profilePresenceBadgeVisible !== false;
};
Glow.profileWebsiteMeta = website => {
return Glow.websiteBrandMeta?.(website) || {
host: "",
brand: "web",
label: "website",
iconText: "â—‹"
};
};
Glow.profileOpenMediaPicker = kind => {
if (kind === "avatar") {
Glow.els.avatarUploadInput?.click();
return;
}
if (kind === "banner") {
Glow.els.bannerUploadInput?.click();
}
};
Glow.profileResetCropState = () => {
const crop = Glow.state.imageCrop;
if (crop.previewUrl) {
try {
URL.revokeObjectURL(crop.previewUrl);
} catch (_) {}
}
Glow.state.imageCrop = {
open: false,
kind: "",
file: null,
previewUrl: "",
zoom: 1,
autoCrop: false
};
if (Glow.els.imageCropOverlay) {
Glow.els.imageCropOverlay.classList.add("hidden");
}
if (Glow.els.imageCropPreview) {
Glow.els.imageCropPreview.replaceChildren();
}
if (Glow.els.imageCropZoom) {
Glow.els.imageCropZoom.value = "1";
}
if (Glow.els.imageCropAuto) {
Glow.els.imageCropAuto.checked = false;
}
};
Glow.profileOpenCropDialog = (kind, file) => {
if (!file || !kind) return;
Glow.profileResetCropState();
const previewUrl = URL.createObjectURL(file);
Glow.state.imageCrop = {
open: true,
kind,
file,
previewUrl,
zoom: 1,
autoCrop: false
};
if (Glow.els.imageCropPreview) {
const image = document.createElement("img");
image.src = previewUrl;
image.alt = `${kind} preview`;
image.className = "crop-preview-image";
Glow.els.imageCropPreview.replaceChildren(image);
}
Glow.els.imageCropOverlay?.classList.remove("hidden");
};
Glow.profileApplyCropSelection = () => {
const crop = Glow.state.imageCrop;
if (!crop?.file || !crop?.kind) {
Glow.profileResetCropState();
return;
}
if (crop.kind === "avatar") {
Glow.state.profileMediaDraft.avatarFile = crop.file;
} else if (crop.kind === "banner") {
Glow.state.profileMediaDraft.bannerFile = crop.file;
}
Glow.showToast(
crop.kind === "avatar"
? "Avatar image selected. Save profile changes to keep it."
: "Banner image selected. Save profile changes to keep it."
);
Glow.profileResetCropState();
};
Glow.profileBuildWebsiteBadge = website => {
if (!website) return null;
const meta = Glow.profileWebsiteMeta(website);
const link = document.createElement("a");
link.className = `profile-website-badge brand-${meta.brand}`;
link.href = website;
link.target = "_blank";
link.rel = "noopener noreferrer";
const icon = document.createElement("span");
icon.className = "profile-website-badge-icon";
icon.textContent = meta.iconText;
const text = document.createElement("span");
text.className = "profile-website-badge-text";
text.textContent = meta.label;
link.append(icon, text);
return link;
};
Glow.openProfile = async username => {
Glow.state.activeView = "profile";
Glow.state.activeProfile = null;
@ -98,8 +227,17 @@ Glow.renderProfileView = (editing = false) => {
const card = document.createElement("div");
card.className = "profile-card";
const banner = document.createElement("div");
const banner = document.createElement(profile.is_self ? "button" : "div");
banner.className = "profile-banner";
if (profile.is_self) {
banner.type = "button";
banner.classList.add("profile-banner-button");
banner.title = "Choose banner image";
banner.addEventListener("click", () => {
Glow.profileOpenMediaPicker("banner");
});
}
if (profile.profile_banner_color) {
banner.style.background =
`linear-gradient(135deg, ${profile.profile_banner_color}, transparent 72%), ${profile.profile_banner_color}`;
@ -111,18 +249,34 @@ Glow.renderProfileView = (editing = false) => {
banner.style.backgroundPosition = "center";
}
if (profile.is_self) {
const bannerHint = document.createElement("span");
bannerHint.className = "profile-banner-hint";
bannerHint.textContent = "Change banner";
banner.append(bannerHint);
}
const content = document.createElement("div");
content.className = "profile-content";
const header = document.createElement("div");
header.className = "profile-header";
const avatar = document.createElement("div");
const avatar = document.createElement(profile.is_self ? "button" : "div");
avatar.className = "profile-avatar";
avatar.textContent = (profile.display_name || profile.username || "?")
.slice(0, 1)
.toUpperCase();
if (profile.is_self) {
avatar.type = "button";
avatar.classList.add("profile-avatar-button");
avatar.title = "Choose avatar image";
avatar.addEventListener("click", () => {
Glow.profileOpenMediaPicker("avatar");
});
}
const actions = document.createElement("div");
actions.className = "profile-actions";
if (profile.is_self) {
@ -165,18 +319,22 @@ Glow.renderProfileView = (editing = false) => {
badges.className = "profile-badges";
const roleBadge = document.createElement("span");
roleBadge.className = "profile-badge";
roleBadge.className = `profile-badge role role-${String(profile.role || "user").toLowerCase()}`;
roleBadge.textContent = profile.role;
const statusBadge = document.createElement("span");
statusBadge.className = "profile-badge";
statusBadge.className = `profile-badge status status-${String(profile.status || "active").toLowerCase()}`;
statusBadge.textContent = profile.status;
const presenceBadge = document.createElement("span");
presenceBadge.className = "profile-badge";
presenceBadge.textContent = profile.presence_mode || "online";
badges.append(roleBadge, statusBadge);
badges.append(roleBadge, statusBadge, presenceBadge);
if (Glow.profileCanShowPresenceBadge(profile)) {
const presenceBadge = document.createElement("span");
presenceBadge.className =
`profile-badge presence presence-${String(profile.presence_mode || "online").toLowerCase()}`;
presenceBadge.textContent = profile.presence_mode || "online";
badges.append(presenceBadge);
}
const grid = document.createElement("div");
grid.className = "profile-grid";
@ -198,30 +356,42 @@ Glow.renderProfileView = (editing = false) => {
metaList.className = "profile-meta-list";
const addMeta = (label, value) => {
if (!value) return;
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = value || "—";
dd.textContent = value;
metaList.append(dt, dd);
};
addMeta("Display name", profile.display_name || profile.username);
addMeta("Status message", profile.status_message || "");
addMeta("Presence mode", profile.presence_mode || "online");
addMeta("Website", profile.website || "");
addMeta("Location", profile.location || "");
addMeta(
"Joined",
profile.created_at ? new Date(profile.created_at).toLocaleDateString() : ""
);
addMeta(
"Last active",
profile.last_active_at ? new Date(profile.last_active_at).toLocaleString() : ""
);
addMeta("Bio mode", profile.bio_mode || "txt");
addMeta("Name color", profile.name_color || "role default");
addMeta("Joined", profile.created_at
? new Date(profile.created_at).toLocaleDateString()
: "");
addMeta("Location", profile.location || "");
if (profile.last_active_at) {
addMeta("Last active", new Date(profile.last_active_at).toLocaleString());
}
const websiteBadge = Glow.profileBuildWebsiteBadge(profile.website || "");
if (websiteBadge) {
const dt = document.createElement("dt");
dt.textContent = "Website";
const dd = document.createElement("dd");
dd.append(websiteBadge);
metaList.append(dt, dd);
}
if (!metaList.children.length) {
const empty = document.createElement("div");
empty.className = "profile-empty";
empty.textContent = "No public profile details yet.";
metaSection.append(metaTitle, empty);
} else {
metaSection.append(metaTitle, metaList);
}
grid.append(bioSection, metaSection);
}
@ -248,6 +418,40 @@ Glow.buildProfileEditor = profile => {
const editor = document.createElement("div");
editor.className = "profile-editor";
const uploadInfo = document.createElement("div");
uploadInfo.className = "profile-upload-section";
const uploadTitle = document.createElement("div");
uploadTitle.className = "profile-upload-title";
uploadTitle.textContent = "Profile images";
const uploadText = document.createElement("p");
uploadText.className = "header-subtitle";
uploadText.textContent =
"Choose new avatar or banner images. A preview/crop step opens before the file is staged.";
const uploadActions = document.createElement("div");
uploadActions.className = "settings-actions";
const avatarButton = document.createElement("button");
avatarButton.className = "button secondary";
avatarButton.type = "button";
avatarButton.textContent = "Choose avatar";
avatarButton.addEventListener("click", () => {
Glow.profileOpenMediaPicker("avatar");
});
const bannerButton = document.createElement("button");
bannerButton.className = "button secondary";
bannerButton.type = "button";
bannerButton.textContent = "Choose banner";
bannerButton.addEventListener("click", () => {
Glow.profileOpenMediaPicker("banner");
});
uploadActions.append(avatarButton, bannerButton);
uploadInfo.append(uploadTitle, uploadText, uploadActions);
const displayName = document.createElement("input");
displayName.className = "field";
displayName.type = "text";
@ -276,11 +480,6 @@ Glow.buildProfileEditor = profile => {
location.placeholder = "Location";
location.value = profile.location || "";
const bannerInput = document.createElement("input");
bannerInput.className = "field";
bannerInput.type = "file";
bannerInput.accept = ".png,.jpg,.jpeg,.gif,.webp";
const modeRow = document.createElement("div");
modeRow.className = "profile-mode-row";
let selectedMode = profile.bio_mode || "txt";
@ -316,13 +515,13 @@ Glow.buildProfileEditor = profile => {
const save = document.createElement("button");
save.className = "button";
save.type = "button";
save.textContent = "Save profile";
save.textContent = "Save profile changes";
save.addEventListener("click", async () => {
try {
let bannerAttachmentId = profile.banner_attachment_id || null;
const file = bannerInput.files?.[0];
if (file) {
const uploaded = await Glow.uploadBannerForProfile(file);
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
if (bannerFile) {
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
}
@ -336,6 +535,9 @@ Glow.buildProfileEditor = profile => {
banner_attachment_id: bannerAttachmentId
});
Glow.state.profileMediaDraft.avatarFile = null;
Glow.state.profileMediaDraft.bannerFile = null;
Glow.state.activeProfile = {
...data.profile,
name_color: Glow.state.settings?.name_color || profile.name_color || null,
@ -349,16 +551,16 @@ Glow.buildProfileEditor = profile => {
}
});
const preview = document.createElement("button");
preview.className = "button secondary";
preview.type = "button";
preview.textContent = "Preview";
preview.addEventListener("click", async () => {
const apply = document.createElement("button");
apply.className = "button secondary";
apply.type = "button";
apply.textContent = "Apply changes";
apply.addEventListener("click", async () => {
try {
let bannerAttachmentId = profile.banner_attachment_id || null;
const file = bannerInput.files?.[0];
if (file) {
const uploaded = await Glow.uploadBannerForProfile(file);
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
if (bannerFile) {
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
}
@ -380,17 +582,17 @@ Glow.buildProfileEditor = profile => {
};
Glow.renderProfileView(false);
} catch (error) {
Glow.showToast(error.message || "Could not preview profile.", "error");
Glow.showToast(error.message || "Could not apply profile changes.", "error");
}
});
actions.append(save, preview);
actions.append(save, apply);
editor.append(
uploadInfo,
displayName,
statusMessage,
website,
location,
bannerInput,
modeRow,
help,
body,
@ -399,3 +601,33 @@ Glow.buildProfileEditor = profile => {
section.append(title, editor);
return section;
};
Glow.els.avatarUploadInput?.addEventListener("change", () => {
const file = Glow.els.avatarUploadInput.files?.[0];
if (!file) return;
Glow.profileOpenCropDialog("avatar", file);
Glow.els.avatarUploadInput.value = "";
});
Glow.els.bannerUploadInput?.addEventListener("change", () => {
const file = Glow.els.bannerUploadInput.files?.[0];
if (!file) return;
Glow.profileOpenCropDialog("banner", file);
Glow.els.bannerUploadInput.value = "";
});
Glow.els.imageCropCancel?.addEventListener("click", () => {
Glow.profileResetCropState();
});
Glow.els.imageCropApply?.addEventListener("click", () => {
Glow.profileApplyCropSelection();
});
Glow.els.imageCropZoom?.addEventListener("input", () => {
Glow.state.imageCrop.zoom = Number(Glow.els.imageCropZoom.value || "1") || 1;
});
Glow.els.imageCropAuto?.addEventListener("change", () => {
Glow.state.imageCrop.autoCrop = Boolean(Glow.els.imageCropAuto.checked);
});

View File

@ -5,8 +5,13 @@ window.Glow = window.Glow || {};
Glow.reactions = Glow.reactions || {
enabled: true,
emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"],
activeMessage: null
emojis: [
"👍", "👎", "❤️", "🔥", "😂", "😄", "🙂", "😭",
"👏", "🙌", "🎉", "✨", "💯", "✅", "👀", "🤔",
"🙏", "💡", "⭐", "🚀", "😮", "😢", "😡", "👋"
],
activeMessage: null,
selectedIndex: 0
};
Glow.submitCustomReaction = async () => {
@ -41,25 +46,21 @@ Glow.submitCustomReaction = async () => {
}
};
Glow.openReactionPopup = async message => {
Glow.reactions.activeMessage = message;
Glow.highlightReactionSelection = () => {
const grid = Glow.els.reactionPopupGrid;
const list = Glow.els.reactionPopupList;
const customInput = Glow.els.reactionCustomInput;
if (!grid) return;
if (!grid || !list) return;
const buttons = [...grid.querySelectorAll(".reaction-popup-button")];
buttons.forEach((button, index) => {
button.classList.toggle("active", index === Glow.reactions.selectedIndex);
});
};
grid.replaceChildren();
list.replaceChildren();
Glow.activateReactionAtIndex = async index => {
const message = Glow.reactions.activeMessage;
const emoji = Glow.reactions.emojis[index];
if (!message || !emoji) return;
for (const emoji of Glow.reactions.emojis) {
const button = document.createElement("button");
button.className = "reaction-popup-button";
button.type = "button";
button.textContent = emoji;
button.addEventListener("click", async () => {
try {
const existing = (message.reactions || []).find(item => item.emoji === emoji);
@ -72,8 +73,8 @@ Glow.openReactionPopup = async message => {
const data = await Glow.apiGetMessageReactions(message.id);
message.reactions = data.reactions || [];
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
Glow.renderReactionPopupUsers(message.reactions);
Glow.highlightReactionSelection();
if (Glow.state.activeView === "feed") {
Glow.loadFeed?.();
@ -81,10 +82,36 @@ Glow.openReactionPopup = async message => {
} catch (error) {
Glow.showToast(error.message || "Could not update reaction.", "error");
}
};
Glow.openReactionPopup = async message => {
Glow.reactions.activeMessage = message;
Glow.reactions.selectedIndex = 0;
const grid = Glow.els.reactionPopupGrid;
const list = Glow.els.reactionPopupList;
const customInput = Glow.els.reactionCustomInput;
if (!grid || !list) return;
grid.replaceChildren();
list.replaceChildren();
Glow.reactions.emojis.forEach((emoji, index) => {
const button = document.createElement("button");
button.className = "reaction-popup-button";
button.type = "button";
button.textContent = emoji;
button.setAttribute("aria-label", `React with ${emoji}`);
button.addEventListener("click", async () => {
Glow.reactions.selectedIndex = index;
Glow.highlightReactionSelection();
await Glow.activateReactionAtIndex(index);
});
grid.append(button);
}
});
try {
const data = await Glow.apiGetMessageReactions(message.id);
@ -99,12 +126,26 @@ Glow.openReactionPopup = async message => {
Glow.renderReactionPopupUsers(message.reactions || []);
Glow.els.reactionPopupOverlay?.classList.remove("hidden");
Glow.highlightReactionSelection();
setTimeout(() => {
Glow.els.reactionCustomInput?.focus();
const first = grid.querySelector(".reaction-popup-button");
first?.focus();
}, 0);
};
Glow.moveReactionSelection = delta => {
const total = Glow.reactions.emojis.length;
if (!total) return;
Glow.reactions.selectedIndex =
(Glow.reactions.selectedIndex + delta + total) % total;
Glow.highlightReactionSelection();
const grid = Glow.els.reactionPopupGrid;
const selected = grid?.querySelectorAll(".reaction-popup-button")[Glow.reactions.selectedIndex];
selected?.scrollIntoView({ block: "nearest", inline: "nearest" });
};
Glow.renderReactionPopupUsers = reactions => {
const list = Glow.els.reactionPopupList;
if (!list) return;
@ -128,19 +169,12 @@ Glow.renderReactionPopupUsers = reactions => {
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
section.append(title);
for (const user of reaction.users || []) {
const row = document.createElement("div");
row.className = "reaction-popup-user";
const left = document.createElement("span");
left.textContent = user.username;
const right = document.createElement("span");
right.textContent = new Date(user.created_at).toLocaleString();
row.append(left, right);
section.append(row);
}
const names = document.createElement("div");
names.className = "reaction-popup-user";
names.textContent = (reaction.users || [])
.map(user => user.username)
.join(", ");
section.append(names);
list.append(section);
}
@ -148,6 +182,7 @@ Glow.renderReactionPopupUsers = reactions => {
Glow.closeReactionPopup = () => {
Glow.reactions.activeMessage = null;
Glow.reactions.selectedIndex = 0;
if (Glow.els.reactionCustomInput) {
Glow.els.reactionCustomInput.value = "";
}
@ -163,4 +198,49 @@ Glow.els.reactionCustomInput?.addEventListener("keydown", event => {
event.preventDefault();
Glow.submitCustomReaction?.();
}
if (event.key === "Escape") {
event.preventDefault();
Glow.closeReactionPopup?.();
}
});
document.addEventListener("keydown", async event => {
if (Glow.els.reactionPopupOverlay?.classList.contains("hidden")) return;
if (event.key === "ArrowRight") {
event.preventDefault();
Glow.moveReactionSelection(1);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
Glow.moveReactionSelection(-1);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
Glow.moveReactionSelection(4);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
Glow.moveReactionSelection(-4);
return;
}
if (event.key === "Enter") {
const active = document.activeElement;
if (active === Glow.els.reactionCustomInput) return;
event.preventDefault();
await Glow.activateReactionAtIndex(Glow.reactions.selectedIndex);
return;
}
if (event.key === "Escape") {
event.preventDefault();
Glow.closeReactionPopup?.();
}
});

View File

@ -34,15 +34,17 @@ Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => {
Glow.resetMediaModal();
const mime = String(mimeType || "").toLowerCase();
const compactName = String(name || "Media").trim() || "Media";
Glow.els.mediaTitle.textContent = name;
Glow.els.mediaTitle.textContent = compactName;
Glow.els.mediaTitle.title = compactName;
Glow.els.mediaDownloadLink.href = url;
Glow.els.mediaDownloadLink.download = name;
Glow.els.mediaDownloadLink.download = compactName;
Glow.els.mediaFallbackLink.href = url;
if (mime.startsWith("image/")) {
Glow.els.mediaImage.src = url;
Glow.els.mediaImage.alt = name;
Glow.els.mediaImage.alt = compactName;
Glow.els.mediaImage.classList.remove("hidden");
} else if (mime.startsWith("audio/")) {
Glow.els.mediaAudio.src = url;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

17
templates/blocked.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
>
<meta name="theme-color" content="#0f1012">
<meta name="color-scheme" content="dark">
<meta name="referrer" content="same-origin">
<title></title>
</head>
<body>
403
</body>
</html>

View File

@ -25,12 +25,15 @@
class="header-dot"
aria-hidden="true"
></span>
<div class="header-room-copy">
<div id="headerTitle" class="header-title">Feed</div>
<div id="headerSubtitle" class="header-subtitle">Recent activity</div>
</div>
</div>
<div class="header-navigation">
<div class="header-nav-group">
<button
id="feedButton"
class="header-action feed-button active"
@ -42,6 +45,7 @@
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
</svg>
</button>
<button
id="pagesButton"
class="header-action pages-button"
@ -56,6 +60,7 @@
</g>
</svg>
</button>
<button
id="dmsButton"
class="header-action"
@ -68,6 +73,7 @@
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</button>
<button
id="backButton"
class="header-action hidden"
@ -76,23 +82,31 @@
>
Back
</button>
</div>
<div class="header-context-group">
<select
id="teamSelect"
class="room-select team-select"
aria-label="Select team"
disabled
>
<option value="">Loading teams…</option>
<option value="">Loading teams...</option>
</select>
<select
id="roomSelect"
class="room-select channel-select"
aria-label="Select channel"
disabled
>
<option value="">Loading channels…</option>
<option value="">Loading channels...</option>
</select>
</div>
<div class="header-utility-group">
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
<button
id="settingsButton"
class="header-action"
@ -104,9 +118,22 @@
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
</svg>
</button>
<button
id="callStatusChip"
class="header-action call-status-chip"
type="button"
aria-label="Open call panel"
title="Voice call"
>
Call
</button>
</div>
</div>
<div class="header-account-wrap">
<div id="headerAccount" class="header-account">not signed in</div>
<button
id="logoutButton"
class="header-action hidden"
@ -121,6 +148,7 @@
</div>
</div>
</header>
<main
id="feedBox"
class="feed-box"
@ -129,9 +157,10 @@
aria-label="Activity feed"
>
<div class="empty-state">
<div class="empty-card">Loading activity…</div>
<div class="empty-card">Loading activity...</div>
</div>
</main>
<main
id="dmBox"
class="dm-box hidden"
@ -143,6 +172,7 @@
<div class="empty-card">Sign in to view direct messages.</div>
</div>
</main>
<main
id="chatBox"
class="chat-box hidden"
@ -150,6 +180,7 @@
aria-busy="true"
aria-label="Channel messages"
></main>
<main
id="threadBox"
class="thread-box hidden"
@ -157,6 +188,7 @@
aria-busy="false"
aria-label="Message thread"
></main>
<main
id="pagesBox"
class="pages-box hidden"
@ -164,6 +196,7 @@
aria-busy="false"
aria-label="Team pages"
></main>
<main
id="pageDetailBox"
class="page-detail-box hidden"
@ -171,6 +204,7 @@
aria-busy="false"
aria-label="Page details"
></main>
<main
id="profileBox"
class="profile-box hidden"
@ -178,16 +212,21 @@
aria-busy="false"
aria-label="Profile"
></main>
<div id="newMessagesBar" class="new-messages-bar hidden">
<button id="jumpToLatestButton" class="small-button" type="button">
New messages · Jump to latest
</button>
</div>
<div id="typingBar" class="typing-bar hidden" aria-live="polite"></div>
<div id="composerWrap" class="composer-wrap hidden">
<form id="chatForm" class="composer">
<div class="composer-stack">
<div class="composer-shell">
<label class="sr-only" for="messageInput">Message</label>
<div class="composer-field-wrap">
<textarea
id="messageInput"
class="composer-input"
@ -198,12 +237,8 @@
aria-label="Message"
disabled
></textarea>
<div id="emojiAutocomplete" class="emoji-autocomplete hidden"></div>
<div class="composer-meta">
<span id="attachmentName" class="attachment-name"></span>
<span id="messageCount" class="message-count">0 / 1000</span>
</div>
</div>
<div class="composer-shell-actions">
<label
id="attachmentButton"
class="attachment-button disabled"
@ -213,23 +248,38 @@
>
+
</label>
<button id="sendButton" class="send-button" type="submit" disabled>
Send
</button>
</div>
</div>
<div id="emojiAutocomplete" class="emoji-autocomplete hidden" aria-live="polite"></div>
<div class="composer-meta">
<span id="attachmentName" class="attachment-name"></span>
<span id="messageCount" class="message-count">0 / 1000</span>
</div>
</div>
<input
id="attachmentInput"
class="sr-only"
type="file"
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp,.mp3,.wav,.ogg,.m4a,.aac,.flac,.mp4,.webm,.mov,.m4v,.ogv"
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
multiple
disabled
>
<button id="sendButton" class="send-button" type="submit" disabled>
Send
</button>
</form>
</div>
<div id="threadComposerWrap" class="composer-wrap hidden">
<form id="threadForm" class="composer">
<div class="composer-stack">
<div class="composer-shell">
<label class="sr-only" for="threadInput">Reply in thread</label>
<div class="composer-field-wrap">
<textarea
id="threadInput"
class="composer-input"
@ -240,11 +290,8 @@
aria-label="Reply in thread"
disabled
></textarea>
<div class="composer-meta">
<span id="threadAttachmentName" class="attachment-name"></span>
<span id="threadMessageCount" class="message-count">0 / 1000</span>
</div>
</div>
<div class="composer-shell-actions">
<label
id="threadAttachmentButton"
class="attachment-button disabled"
@ -254,20 +301,47 @@
>
+
</label>
<button id="threadSendButton" class="send-button" type="submit" disabled>
Reply
</button>
</div>
</div>
<div id="threadEmojiAutocomplete" class="emoji-autocomplete hidden" aria-live="polite"></div>
<div class="composer-meta">
<span id="threadAttachmentName" class="attachment-name"></span>
<span id="threadMessageCount" class="message-count">0 / 1000</span>
</div>
</div>
<input
id="threadAttachmentInput"
class="sr-only"
type="file"
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp,.mp3,.wav,.ogg,.m4a,.aac,.flac,.mp4,.webm,.mov,.m4v,.ogv"
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
multiple
disabled
>
<button id="threadSendButton" class="send-button" type="submit" disabled>
Reply
</button>
</form>
</div>
</div>
<input
id="avatarUploadInput"
class="sr-only"
type="file"
accept=".png,.jpg,.jpeg,.gif,.webp"
>
<input
id="bannerUploadInput"
class="sr-only"
type="file"
accept=".png,.jpg,.jpeg,.gif,.webp"
>
<section
id="authOverlay"
class="auth-overlay"
@ -277,7 +351,9 @@
>
<div class="auth-card">
<p></p>
<div id="authNotice" class="notice" role="status"></div>
<label class="sr-only" for="accessKeyInput">access key</label>
<input
id="accessKeyInput"
@ -287,8 +363,9 @@
autocapitalize="none"
spellcheck="false"
maxlength="256"
placeholder="private access key"
placeholder="access key"
>
<label class="sr-only" for="usernameInput">Username</label>
<input
id="usernameInput"
@ -300,6 +377,7 @@
maxlength="30"
placeholder="username"
>
<label class="sr-only" for="passwordInput">Password</label>
<input
id="passwordInput"
@ -309,13 +387,16 @@
maxlength="256"
placeholder="password"
>
<div class="auth-actions">
<button id="loginButton" class="button" type="button">sign in</button>
<button id="registerButton" class="button secondary" type="button">create account</button>
</div>
<p class="auth-help"></p>
</div>
</section>
<section
id="blockedOverlay"
class="blocked-overlay hidden"
@ -325,9 +406,10 @@
>
<div class="auth-card">
<h1>Access blocked</h1>
<p id="blockedMessage">Access to this chat service is unavailable.</p>
<p id="blockedMessage">Access is unavailable.</p>
</div>
</section>
<section
id="headerMenuOverlay"
class="header-menu-overlay hidden"
@ -340,6 +422,7 @@
<div id="headerMenuList" class="header-menu-list"></div>
</div>
</section>
<section
id="reactionPopupOverlay"
class="reaction-popup-overlay hidden"
@ -348,8 +431,12 @@
aria-label="Reactions"
>
<div class="reaction-popup-card">
<div class="reaction-popup-header">
<h2 class="reaction-popup-title">Reactions</h2>
</div>
<div id="reactionPopupGrid" class="reaction-popup-grid"></div>
<div class="reaction-popup-custom">
<input
id="reactionCustomInput"
@ -367,12 +454,15 @@
Add
</button>
</div>
<div id="reactionPopupList" class="reaction-popup-list"></div>
<div class="dialog-actions">
<button id="closeReactionPopupButton" class="button secondary" type="button">Close</button>
</div>
</div>
</section>
<section
id="mediaOverlay"
class="media-overlay hidden"
@ -381,14 +471,15 @@
aria-label="Media preview"
>
<div class="media-card">
<div class="media-header">
<div class="media-header minimal">
<div id="mediaTitle" class="media-title">Media</div>
<div class="settings-actions">
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
<button id="closeMediaButton" class="button secondary" type="button">Close</button>
</div>
</div>
<div class="media-body">
<div class="media-body spacious">
<img id="mediaImage" class="hidden" alt="">
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
<video id="mediaVideo" class="hidden" controls preload="metadata"></video>
@ -399,31 +490,104 @@
</div>
</div>
</section>
<section
id="imageCropOverlay"
class="dialog-overlay hidden"
role="dialog"
aria-modal="true"
aria-label="Image crop and preview"
>
<div class="dialog-card crop-dialog-card">
<h2>Adjust image</h2>
<div class="crop-dialog-body">
<div id="imageCropPreview" class="crop-preview-area" aria-live="polite"></div>
<label class="dialog-label" for="imageCropZoom">
Zoom
<input
id="imageCropZoom"
class="field"
type="range"
min="1"
max="3"
step="0.05"
value="1"
>
</label>
<label class="dialog-label crop-toggle">
<input id="imageCropAuto" type="checkbox">
Auto-crop placeholder
</label>
</div>
<div class="dialog-actions">
<button id="imageCropCancel" class="button secondary" type="button">Cancel</button>
<button id="imageCropApply" class="button" type="button">Apply</button>
</div>
</div>
</section>
<section
id="callOverlay"
class="call-overlay hidden"
role="dialog"
aria-modal="true"
aria-modal="false"
aria-label="Voice call"
>
<div class="call-card">
<div class="call-card utility-panel">
<div id="callDragHandle" class="call-drag-handle" aria-hidden="true"></div>
<div class="call-title-row">
<div>
<div class="call-title">Voice call</div>
<div class="call-title">call</div>
<div id="callStatus" class="call-status">Idle</div>
</div>
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
</div>
<div id="callPeerLabel" class="call-peer-label">No active call</div>
<audio id="remoteAudio" autoplay playsinline></audio>
<div id="callSummaryStats" class="call-summary-stats">
<div class="call-stat-row">
<span class="call-stat-label">Users</span>
<span id="callUserCountStat" class="call-stat-value">0</span>
</div>
<div class="call-stat-row">
<span class="call-stat-label">Elapsed</span>
<span id="callElapsedStat" class="call-stat-value">0m</span>
</div>
<div class="call-stat-row">
<span class="call-stat-label">Latency</span>
<span id="callLatencyStat" class="call-stat-value"> </span>
</div>
<div class="call-stat-row">
<span class="call-stat-label">Loss</span>
<span id="callPacketLossStat" class="call-stat-value"> </span>
</div>
<div class="call-stat-row">
<span class="call-stat-label">Uptime</span>
<span id="callUptimeStat" class="call-stat-value"> </span>
</div>
</div>
<div id="callParticipants" class="call-participants"></div>
<div id="callPendingInvites" class="call-pending-invites hidden"></div>
<div id="remoteAudioContainer" class="remote-audio-container"></div>
<div class="call-actions">
<button id="startCallButton" class="button" type="button">Start call</button>
<button id="answerCallButton" class="button hidden" type="button">Answer</button>
<button id="retryCallButton" class="button secondary hidden" type="button">Retry connection</button>
<button id="addToCallButton" class="button secondary hidden" type="button">Add user</button>
<button id="muteCallButton" class="button secondary hidden" type="button">Mute</button>
<button id="hangupCallButton" class="danger-button hidden" type="button">Hang up</button>
<button id="hangupCallButton" class="danger-button hidden" type="button">Leave call</button>
<button id="endCallButton" class="danger-button hidden" type="button">End call</button>
</div>
</div>
</section>
<button
id="adminFab"
class="admin-fab"
@ -433,6 +597,7 @@
>
#
</button>
<section
id="adminPanel"
class="admin-panel"
@ -447,21 +612,63 @@
<strong>Moderation</strong>
<div class="header-subtitle"></div>
</div>
<button id="closeAdminButton" class="button secondary" type="button">
Close
</button>
</div>
<div class="admin-actions">
<button id="createRoomButton" class="button" type="button">Add chat</button>
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</button>
<button id="typingToggleButton" class="button secondary" type="button">Typing indicator: on</button>
<button id="notificationsToggleButton" class="button secondary" type="button">Notifications: off</button>
<button id="themeButton" class="button secondary" type="button" aria-label="Choose accent color">Accent color</button>
</div>
<div class="admin-tabs" role="tablist" aria-label="Moderation sections">
<button
id="adminTabRooms"
class="admin-tab active"
type="button"
role="tab"
aria-selected="true"
>
Rooms
</button>
<button
id="adminTabUsers"
class="admin-tab"
type="button"
role="tab"
aria-selected="false"
>
Users
</button>
<button
id="adminTabTeams"
class="admin-tab"
type="button"
role="tab"
aria-selected="false"
>
Teams/channels
</button>
</div>
<div class="admin-tab-panels">
<div id="adminRoomsPanel" class="admin-tab-panel">
<div id="adminRooms" class="admin-rooms"></div>
</div>
<div id="adminUsersPanel" class="admin-tab-panel hidden">
<div id="adminUsers" class="admin-users"></div>
</div>
<div id="adminTeamsPanel" class="admin-tab-panel hidden">
<div id="adminTeamsChannels" class="admin-rooms"></div>
</div>
</div>
</div>
</section>
<section
id="dialogOverlay"
class="dialog-overlay hidden"
@ -475,7 +682,26 @@
<div id="dialogActions" class="dialog-actions"></div>
</div>
</section>
<div
id="settingsSavePanel"
class="settings-save-panel hidden"
aria-live="polite"
>
<div class="settings-save-panel-inner">
<div class="settings-save-copy">
<strong id="settingsUnsavedIndicator">Unsaved changes</strong>
<small>Your previews are local until you save.</small>
</div>
<div class="settings-save-actions">
<button id="settingsCloseButton" class="button secondary" type="button">Close</button>
<button id="settingsSaveButton" class="button" type="button">Save</button>
</div>
</div>
</div>
<div id="statusToast" class="status-toast" role="status" aria-live="polite"></div>
<script src="{{ url_for('static', filename='js/00-config.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/01-api.js', v=asset_version) }}" defer></script>
<script src="{{ url_for('static', filename='js/02-chat.js', v=asset_version) }}" defer></script>