Compare commits

..

No commits in common. "main" and "v1" have entirely different histories.
main ... v1

52 changed files with 9718 additions and 16536 deletions

View File

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

2040
app.py

File diff suppressed because it is too large Load Diff

View File

@ -1,335 +0,0 @@
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
View File

@ -1,498 +0,0 @@
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
View File

@ -1,464 +0,0 @@
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
View File

@ -1,231 +0,0 @@
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
View File

@ -1,948 +0,0 @@
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

@ -1,54 +0,0 @@
desktop app
- secure qtwebengine browser instead of web browser
- allows only approved https
- blocks external navigation
- blocks popups
- blocks downloads
- denies browser permission requests
- rejects bad tls/certificate errors
- keeps a single app instance
- stores login/session data beside the app, keeps between restarts
- logs startup/runtime errors
##### requirements
install python packages:
```bash
pip install PySide6
```
##### run with python
```bash
python3 main.py
```
or:
```bash
python main.py
```
##### data
local app data is stored beside the script or bundled binary:
```text
angelhome_data/
logs/
runtime/
cache/
storage/
```
this is used for:
- session persistence
- cache
- logs
- runtime lock file
```

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,154 +0,0 @@
copy + paste themes in settings > custom css
```
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;
letter-spacing: -0.02em;
font-size: 96.7%;
}
div {
font-size: 96%;
font-weight: 500 !important;
text-shadow: 0px 3px 33px #fff;
}
```
```
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;
}
div {
font-size: 96%;
font-weight: 500 !important;
line-height: 1.2rem;
text-shadow: 0px 0px 75px #fff;
}
```

View File

@ -1,79 +1,40 @@
[themes](CSS.md)
themes
<pre> <pre>
ver. 1.6 lowercase classic
monospace
- sign in, register, logout, session check * {
- private access key for sign up text-transform: lowercase;
- csrf protection and session handling font-family: monospace;
- user roles (user, moderator, admin) font-size: 94%;
- account states (active, restricted, disabled, blacklisted) }
div {
- direct messages view font-size: 95%;
- start new dm font-weight: 600 !important;
text-shadow: 0px 3px 33px #fff;
- activity feed }
- highlighted mentions in feed * {
- attachment previews font-weight: 400 !important;
- feed reactions }
</pre>
- public, private chats
- send, edit, remove messages
- message threads and replies
- message reactions, editing, and delete
- add multiple attachments per message
- image, audio, video, and file previews
- attachment downloads
- 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
- markdown support
- user profiles
- edit user bio in txt, markdown, html
- display name, badge, website, location fields
- quick actions for profile settings
- join date
- user settings
- dark and light themes
- presence controls
- custom css
- accent color
- username color
- admin, moderation panel
- manage users
- change roles
- restrict or blacklist users
- create, edit, delete, and manage channels
- mobile-friendly ui
- online counter
- docker setup
- .env configurable
- html, markdown rendering
- asset versioning for css/js
- persistent database and uploads
- rate limiting
- calls
- mute, leave, end call controls
- group calls for rooms and channels
*needs testing*
- dm calls
- call invites
- reconnect handling
- desktop client
<pre> <pre>
lowercase modern
helvetica
* {
font-weight: 500 !important;
text-transform: lowercase;
font-family: monospace;
font-size: 100%;
font-family: "SF SANS", HELVETICA, SYSTEM-UI, MONOSPACE;
}
div {
font-size: 96%;
font-weight: 500 !important;
line-height: 1.2rem;
text-shadow: 0px 0px 75px #fff;
}
</pre>

1073
features.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +0,0 @@
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=

View File

@ -1,14 +0,0 @@
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"]

View File

@ -1,38 +0,0 @@
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

View File

@ -1,14 +0,0 @@
{
"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

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

View File

@ -1,761 +0,0 @@
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
View File

@ -1,642 +0,0 @@
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

View File

@ -1,749 +0,0 @@
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

4931
routes.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
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)

View File

@ -1,268 +0,0 @@
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"})

View File

@ -1,125 +0,0 @@
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,
)

View File

@ -1,876 +0,0 @@
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)})

View File

@ -1,413 +0,0 @@
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,
}

View File

@ -1,236 +0,0 @@
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

View File

@ -1,189 +0,0 @@
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})

File diff suppressed because it is too large Load Diff

View File

@ -1,336 +0,0 @@
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]})

View File

@ -1,518 +0,0 @@
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

View File

@ -1,526 +0,0 @@
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)})

File diff suppressed because it is too large Load Diff

97
sanitizer.py Normal file
View File

@ -0,0 +1,97 @@
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)

2
static/js/.env Normal file
View File

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

View File

@ -41,7 +41,6 @@ Glow.els = {
themeButton: document.getElementById("themeButton"), themeButton: document.getElementById("themeButton"),
adminUsers: document.getElementById("adminUsers"), adminUsers: document.getElementById("adminUsers"),
adminRooms: document.getElementById("adminRooms"), adminRooms: document.getElementById("adminRooms"),
adminTeamsChannels: document.getElementById("adminTeamsChannels"),
statusToast: document.getElementById("statusToast"), statusToast: document.getElementById("statusToast"),
blockedOverlay: document.getElementById("blockedOverlay"), blockedOverlay: document.getElementById("blockedOverlay"),
blockedMessage: document.getElementById("blockedMessage"), blockedMessage: document.getElementById("blockedMessage"),
@ -88,46 +87,13 @@ Glow.els = {
callOverlay: document.getElementById("callOverlay"), callOverlay: document.getElementById("callOverlay"),
callStatus: document.getElementById("callStatus"), callStatus: document.getElementById("callStatus"),
callPeerLabel: document.getElementById("callPeerLabel"), callPeerLabel: document.getElementById("callPeerLabel"),
callParticipants: document.getElementById("callParticipants"),
callPendingInvites: document.getElementById("callPendingInvites"),
remoteAudioContainer: document.getElementById("remoteAudioContainer"),
callStatusChip: document.getElementById("callStatusChip"),
startCallButton: document.getElementById("startCallButton"), startCallButton: document.getElementById("startCallButton"),
retryCallButton: document.getElementById("retryCallButton"), answerCallButton: document.getElementById("answerCallButton"),
addToCallButton: document.getElementById("addToCallButton"), addToCallButton: document.getElementById("addToCallButton"),
muteCallButton: document.getElementById("muteCallButton"), muteCallButton: document.getElementById("muteCallButton"),
hangupCallButton: document.getElementById("hangupCallButton"), hangupCallButton: document.getElementById("hangupCallButton"),
endCallButton: document.getElementById("endCallButton"),
closeCallPanelButton: document.getElementById("closeCallPanelButton"), closeCallPanelButton: document.getElementById("closeCallPanelButton"),
remoteAudio: document.getElementById("remoteAudio")
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 = { Glow.state = {
@ -165,9 +131,6 @@ Glow.state = {
pageTypeFilter: "", pageTypeFilter: "",
activeProfile: null, activeProfile: null,
settings: null, settings: null,
settingsSaved: null,
settingsDraft: null,
settingsDirty: false,
pollTimer: null, pollTimer: null,
initialized: false, initialized: false,
toastTimer: null, toastTimer: null,
@ -187,33 +150,6 @@ Glow.state = {
originalTitle: document.title, originalTitle: document.title,
dialogResolver: null, dialogResolver: null,
lastFocusedElement: null, lastFocusedElement: 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: { presence: {
roomOnlineCount: 0, roomOnlineCount: 0,
channelOnlineCount: 0, channelOnlineCount: 0,
@ -228,38 +164,10 @@ Glow.state = {
selfPeerId: null, selfPeerId: null,
peerConnections: new Map(), peerConnections: new Map(),
remoteStreams: new Map(), remoteStreams: new Map(),
participantAudioEls: new Map(),
peers: [], peers: [],
reconnectTimer: null, reconnectTimer: null,
invitePollTimer: null,
participantPollTimer: null,
heartbeatTimer: null,
invites: [], invites: [],
reconnecting: false, invitePollTimer: null
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
}
} }
}; };
@ -337,38 +245,6 @@ Glow.resetAccentColor = () => {
Glow.applyAccentColor(Glow.DEFAULT_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 => { Glow.roleDefaultNameColor = role => {
switch (String(role || "").toLowerCase()) { switch (String(role || "").toLowerCase()) {
case "admin": case "admin":
@ -520,55 +396,6 @@ Glow.normalizeSelectedFiles = fileList => {
return [...fileList].filter(Boolean); 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 = ({ Glow.openDialog = ({
title, title,
body, body,

View File

@ -37,9 +37,6 @@ Glow.handleUnauthorized = () => {
Glow.state.pageTypeFilter = ""; Glow.state.pageTypeFilter = "";
Glow.state.activeProfile = null; Glow.state.activeProfile = null;
Glow.state.settings = null; Glow.state.settings = null;
Glow.state.settingsSaved = null;
Glow.state.settingsDraft = null;
Glow.state.settingsDirty = false;
Glow.state.selectedAttachment = []; Glow.state.selectedAttachment = [];
Glow.state.selectedThreadAttachment = []; Glow.state.selectedThreadAttachment = [];
Glow.state.typingUsers = []; Glow.state.typingUsers = [];
@ -54,43 +51,14 @@ Glow.handleUnauthorized = () => {
Glow.state.dmRequestId += 1; Glow.state.dmRequestId += 1;
Glow.state.feedRequestId += 1; Glow.state.feedRequestId += 1;
Glow.state.threadRequestId += 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.pollTimer);
clearTimeout(Glow.state.typingTimer); clearTimeout(Glow.state.typingTimer);
clearTimeout(Glow.state.calls.reconnectTimer); clearTimeout(Glow.state.calls.pollTimer);
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.clearMessages?.();
Glow.clearFeed?.(); Glow.clearFeed?.();
@ -99,7 +67,6 @@ Glow.handleUnauthorized = () => {
Glow.closeReactionPopup?.(); Glow.closeReactionPopup?.();
Glow.closeMediaModal?.(); Glow.closeMediaModal?.();
Glow.endLocalCallUi?.(); Glow.endLocalCallUi?.();
Glow.profileResetCropState?.();
Glow.setSelectedAttachment?.([]); Glow.setSelectedAttachment?.([]);
Glow.setSelectedThreadAttachment?.([]); Glow.setSelectedThreadAttachment?.([]);
@ -407,123 +374,32 @@ Glow.apiSetDmTyping = (dmId, typing) =>
body: JSON.stringify({ typing }) body: JSON.stringify({ typing })
}); });
/* group calls */ /* calls */
Glow.apiGetGroupCall = sessionId => Glow.apiCreateCall = dmId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}`); Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, {
Glow.apiJoinGroupCall = sessionId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/join`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) body: JSON.stringify({})
}); });
Glow.apiHeartbeatGroupCall = sessionId => Glow.apiGetCall = callId =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/heartbeat`, { Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`);
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
Glow.apiLeaveGroupCall = sessionId => Glow.apiPostCallSignal = (callId, payload) =>
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/leave`, { Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, {
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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
Glow.apiListGroupCallInvites = () => Glow.apiGetCallSignals = (callId, afterId = 0) =>
Glow.apiRequest("/api/group-calls/invites");
Glow.apiAcceptGroupCallInvite = inviteId =>
Glow.apiRequest( Glow.apiRequest(
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/accept`, `/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}`
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}
); );
Glow.apiDismissGroupCallInvite = inviteId => Glow.apiEndCall = callId =>
Glow.apiRequest( Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, {
`/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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) 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,99 +6,58 @@ window.Glow = window.Glow || {};
Glow.closeAdminPanel = () => { Glow.closeAdminPanel = () => {
Glow.els.adminPanel.classList.remove("open"); Glow.els.adminPanel.classList.remove("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "true"); 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.renderAdminUsers = users => {
Glow.els.adminUsers.replaceChildren(); Glow.els.adminUsers.replaceChildren();
const heading = document.createElement("h3"); const heading = document.createElement("h3");
heading.textContent = "users"; heading.textContent = "Users";
Glow.els.adminUsers.append(heading); Glow.els.adminUsers.append(heading);
if (!users.length) { if (!users.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "admin-empty"; empty.className = "admin-empty";
empty.textContent = "no users found."; empty.textContent = "No users found.";
Glow.els.adminUsers.append(empty); Glow.els.adminUsers.append(empty);
return; return;
} }
for (const user of users) { 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 = const current =
Glow.state.currentUser && Glow.state.currentUser &&
Number(Glow.state.currentUser.id) === Number(user.id); Number(Glow.state.currentUser.id) === Number(user.id);
const actions = [];
if (!current && Glow.isAdmin()) { if (!current && Glow.isAdmin()) {
const role = document.createElement("button"); const role = document.createElement("button");
role.className = "small-button"; role.className = "small-button";
role.type = "button"; role.type = "button";
role.textContent = role.textContent =
user.role === "moderator" ? "make user" : "make moderator"; user.role === "moderator" ? "Make user" : "Make moderator";
role.addEventListener("click", () => { role.addEventListener("click", () => {
Glow.updateAdminUser(user.id, { Glow.updateAdminUser(user.id, {
role: user.role === "moderator" ? "user" : "moderator" role: user.role === "moderator" ? "user" : "moderator"
@ -109,7 +68,8 @@ Glow.renderAdminUsers = users => {
restrict.className = "small-button"; restrict.className = "small-button";
restrict.type = "button"; restrict.type = "button";
restrict.textContent = restrict.textContent =
user.status === "restricted" ? "unrestrict" : "restrict"; user.status === "restricted" ? "Unrestrict" : "Restrict";
restrict.addEventListener("click", () => { restrict.addEventListener("click", () => {
Glow.toggleRestriction(user); Glow.toggleRestriction(user);
}); });
@ -118,26 +78,17 @@ Glow.renderAdminUsers = users => {
blacklist.className = "small-button danger"; blacklist.className = "small-button danger";
blacklist.type = "button"; blacklist.type = "button";
blacklist.textContent = blacklist.textContent =
user.status === "blacklisted" ? "unblacklist" : "blacklist"; user.status === "blacklisted" ? "Unblacklist" : "Blacklist";
blacklist.addEventListener("click", () => { blacklist.addEventListener("click", () => {
Glow.toggleBlacklist(user); Glow.toggleBlacklist(user);
}); });
actions.push(role, restrict, blacklist); actions.append(role, restrict, blacklist);
} }
Glow.els.adminUsers.append( row.append(details, actions);
Glow.buildAdminRow({ Glow.els.adminUsers.append(row);
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"
})
);
} }
}; };
@ -145,33 +96,52 @@ Glow.renderAdminRooms = rooms => {
Glow.els.adminRooms.replaceChildren(); Glow.els.adminRooms.replaceChildren();
const heading = document.createElement("h3"); const heading = document.createElement("h3");
heading.textContent = "rooms"; heading.textContent = "Rooms";
Glow.els.adminRooms.append(heading); Glow.els.adminRooms.append(heading);
if (!rooms.length) { if (!rooms.length) {
const empty = document.createElement("p"); const empty = document.createElement("p");
empty.className = "admin-empty"; empty.className = "admin-empty";
empty.textContent = "no rooms found."; empty.textContent = "No rooms found.";
Glow.els.adminRooms.append(empty); Glow.els.adminRooms.append(empty);
return; return;
} }
for (const room of rooms) { for (const room of rooms) {
const actions = []; 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";
if (Glow.isAdmin()) { if (Glow.isAdmin()) {
const edit = document.createElement("button"); const edit = document.createElement("button");
edit.className = "small-button"; edit.className = "small-button";
edit.type = "button"; edit.type = "button";
edit.textContent = "edit"; edit.textContent = "Edit";
edit.addEventListener("click", () => Glow.editRoom(room)); edit.addEventListener("click", () => Glow.editRoom(room));
actions.push(edit);
actions.append(edit);
if (room.visibility === "private") { if (room.visibility === "private") {
const members = document.createElement("button"); const members = document.createElement("button");
members.className = "small-button"; members.className = "small-button";
members.type = "button"; members.type = "button";
members.textContent = "members"; members.textContent = "Members";
members.addEventListener("click", () => { members.addEventListener("click", () => {
Glow.manageRoomMembers(room); Glow.manageRoomMembers(room);
}); });
@ -179,139 +149,27 @@ Glow.renderAdminRooms = rooms => {
const invite = document.createElement("button"); const invite = document.createElement("button");
invite.className = "small-button"; invite.className = "small-button";
invite.type = "button"; invite.type = "button";
invite.textContent = "invite link"; invite.textContent = "Invite link";
invite.addEventListener("click", () => { invite.addEventListener("click", () => {
Glow.createInviteLink(room); Glow.createInviteLink(room);
}); });
actions.push(members, invite); actions.append(members, invite);
} }
if (room.slug !== "general") { if (room.slug !== "general") {
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "small-button danger"; remove.className = "small-button danger";
remove.type = "button"; remove.type = "button";
remove.textContent = "delete"; remove.textContent = "Delete";
remove.addEventListener("click", () => Glow.deleteRoom(room)); remove.addEventListener("click", () => Glow.deleteRoom(room));
actions.push(remove);
actions.append(remove);
} }
} }
Glow.els.adminRooms.append( row.append(details, actions);
Glow.buildAdminRow({ Glow.els.adminRooms.append(row);
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"
})
);
} }
}; };
@ -321,15 +179,8 @@ Glow.openAdminPanel = async () => {
Glow.els.adminPanel.classList.add("open"); Glow.els.adminPanel.classList.add("open");
Glow.els.adminPanel.setAttribute("aria-hidden", "false"); Glow.els.adminPanel.setAttribute("aria-hidden", "false");
if (Glow.els.adminUsers) { Glow.els.adminUsers.textContent = "Loading users…";
Glow.els.adminUsers.textContent = "loading users…"; Glow.els.adminRooms.textContent = "Loading rooms…";
}
if (Glow.els.adminRooms) {
Glow.els.adminRooms.textContent = "loading rooms…";
}
if (Glow.els.adminTeamsChannels) {
Glow.els.adminTeamsChannels.textContent = "loading teams/channels…";
}
try { try {
const [usersData, roomsData] = await Promise.all([ const [usersData, roomsData] = await Promise.all([
@ -339,15 +190,134 @@ Glow.openAdminPanel = async () => {
Glow.renderAdminUsers(usersData.users || []); Glow.renderAdminUsers(usersData.users || []);
Glow.renderAdminRooms(roomsData.rooms || []); Glow.renderAdminRooms(roomsData.rooms || []);
Glow.renderAdminTeamsChannels(); Glow.updateAdminPreferenceButtons();
Glow.setAdminTab(Glow.state.adminTab || "rooms");
setTimeout(() => Glow.els.closeAdminButton?.focus(), 0); setTimeout(() => Glow.els.closeAdminButton.focus(), 0);
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not load moderation data.", "error"); Glow.showToast(error.message, "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 => { Glow.toggleRestriction = async user => {
if (user.status === "restricted") { if (user.status === "restricted") {
return Glow.updateAdminUser(user.id, { return Glow.updateAdminUser(user.id, {
@ -357,14 +327,14 @@ Glow.toggleRestriction = async user => {
} }
const values = await Glow.openDialog({ const values = await Glow.openDialog({
title: `restrict ${user.username}`, title: `Restrict ${user.username}`,
body: "enter the restriction duration in minutes.", body: "Enter the restriction duration in minutes.",
confirmText: "restrict", confirmText: "Restrict",
danger: true, danger: true,
fields: [ fields: [
{ {
name: "minutes", name: "minutes",
label: "duration in minutes", label: "Duration in minutes",
type: "number", type: "number",
value: "60", value: "60",
maxLength: 5 maxLength: 5
@ -375,14 +345,20 @@ Glow.toggleRestriction = async user => {
if (!values) return; if (!values) return;
const minutes = Number(values.minutes); const minutes = Number(values.minutes);
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) { 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; return;
} }
await Glow.updateAdminUser(user.id, { await Glow.updateAdminUser(user.id, {
status: "restricted", status: "restricted",
restricted_until: new Date(Date.now() + minutes * 60 * 1000).toISOString() restricted_until: new Date(
Date.now() + minutes * 60 * 1000
).toISOString()
}); });
}; };
@ -391,12 +367,12 @@ Glow.toggleBlacklist = async user => {
const result = await Glow.openDialog({ const result = await Glow.openDialog({
title: blacklisting title: blacklisting
? `blacklist ${user.username}?` ? `Blacklist ${user.username}?`
: `unblacklist ${user.username}?`, : `Unblacklist ${user.username}?`,
body: blacklisting body: blacklisting
? "the account will be denied access immediately." ? "The account will be denied access immediately."
: "the account will be restored to active status.", : "The account will be restored to active status.",
confirmText: blacklisting ? "blacklist" : "unblacklist", confirmText: blacklisting ? "Blacklist" : "Unblacklist",
danger: blacklisting danger: blacklisting
}); });
@ -415,36 +391,36 @@ Glow.updateAdminUser = async (userId, changes) => {
body: JSON.stringify(changes) body: JSON.stringify(changes)
}); });
Glow.showToast("user updated."); Glow.showToast("User updated.");
await Glow.openAdminPanel(); await Glow.openAdminPanel();
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not update user.", "error"); Glow.showToast(error.message, "error");
} }
}; };
Glow.createRoom = async () => { Glow.createRoom = async () => {
const values = await Glow.openDialog({ const values = await Glow.openDialog({
title: "create room", title: "Create room",
body: "choose a room name, description, and visibility.", body: "Choose a room name, description, and visibility.",
confirmText: "create", confirmText: "Create",
fields: [ fields: [
{ {
name: "name", name: "name",
label: "room name", label: "Room name",
placeholder: "project chat", placeholder: "Project chat",
maxLength: 60 maxLength: 60
}, },
{ {
name: "description", name: "description",
label: "description", label: "Description",
placeholder: "optional room description", placeholder: "Optional room description",
multiline: true, multiline: true,
rows: 3, rows: 3,
maxLength: 300 maxLength: 300
}, },
{ {
name: "visibility", name: "visibility",
label: "visibility: public or private", label: "Visibility: public or private",
value: "public", value: "public",
maxLength: 7 maxLength: 7
} }
@ -453,41 +429,46 @@ Glow.createRoom = async () => {
if (!values) return; if (!values) return;
const visibility = String(values.visibility || "").toLowerCase(); const visibility = values.visibility.toLowerCase();
if (!["public", "private"].includes(visibility)) { if (!["public", "private"].includes(visibility)) {
Glow.showToast("visibility must be public or private.", "error"); Glow.showToast("Visibility must be public or private.", "error");
return; return;
} }
try { try {
await Glow.apiCreateRoom({ await Glow.apiRequest("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: values.name, name: values.name,
description: values.description, description: values.description,
visibility visibility
})
}); });
Glow.showToast("room created."); Glow.showToast("Room created.");
await Glow.loadRooms(); await Glow.loadRooms();
await Glow.openAdminPanel(); await Glow.openAdminPanel();
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not create room.", "error"); Glow.showToast(error.message, "error");
} }
}; };
Glow.editRoom = async room => { Glow.editRoom = async room => {
const values = await Glow.openDialog({ const values = await Glow.openDialog({
title: `edit ${room.name}`, title: `Edit ${room.name}`,
confirmText: "save", confirmText: "Save",
fields: [ fields: [
{ {
name: "name", name: "name",
label: "room name", label: "Room name",
value: room.name, value: room.name,
maxLength: 60 maxLength: 60
}, },
{ {
name: "description", name: "description",
label: "description", label: "Description",
value: room.description || "", value: room.description || "",
multiline: true, multiline: true,
rows: 3, rows: 3,
@ -495,7 +476,7 @@ Glow.editRoom = async room => {
}, },
{ {
name: "visibility", name: "visibility",
label: "visibility: public or private", label: "Visibility: public or private",
value: room.visibility, value: room.visibility,
maxLength: 7 maxLength: 7
} }
@ -504,9 +485,10 @@ Glow.editRoom = async room => {
if (!values) return; if (!values) return;
const visibility = String(values.visibility || "").toLowerCase(); const visibility = values.visibility.toLowerCase();
if (!["public", "private"].includes(visibility)) { if (!["public", "private"].includes(visibility)) {
Glow.showToast("visibility must be public or private.", "error"); Glow.showToast("Visibility must be public or private.", "error");
return; return;
} }
@ -519,8 +501,13 @@ Glow.editRoom = async room => {
Glow.updateRoom = async (slug, changes) => { Glow.updateRoom = async (slug, changes) => {
try { try {
await Glow.apiUpdateRoom(slug, changes); await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, {
Glow.showToast("room updated."); method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(changes)
});
Glow.showToast("Room updated.");
await Glow.loadRooms(); await Glow.loadRooms();
if (!Glow.state.activeDm) { if (!Glow.state.activeDm) {
@ -529,15 +516,15 @@ Glow.updateRoom = async (slug, changes) => {
await Glow.openAdminPanel(); await Glow.openAdminPanel();
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not update room.", "error"); Glow.showToast(error.message, "error");
} }
}; };
Glow.deleteRoom = async room => { Glow.deleteRoom = async room => {
const result = await Glow.openDialog({ const result = await Glow.openDialog({
title: `delete ${room.name}?`, title: `Delete ${room.name}?`,
body: "this permanently deletes the room and every message in it.", body: "This permanently deletes the room and every message in it.",
confirmText: "delete room", confirmText: "Delete room",
danger: true danger: true
}); });
@ -548,7 +535,7 @@ Glow.deleteRoom = async room => {
method: "DELETE" method: "DELETE"
}); });
Glow.showToast("room deleted."); Glow.showToast("Room deleted.");
await Glow.loadRooms(); await Glow.loadRooms();
if (!Glow.state.activeDm) { if (!Glow.state.activeDm) {
@ -557,39 +544,43 @@ Glow.deleteRoom = async room => {
await Glow.openAdminPanel(); await Glow.openAdminPanel();
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not delete room.", "error"); Glow.showToast(error.message, "error");
} }
}; };
Glow.clearActiveRoom = async () => { Glow.clearActiveRoom = async () => {
const room = Glow.state.activeRoom; const room = Glow.state.activeRoom;
if (!room || Glow.state.activeDm) return; if (!room || Glow.state.activeDm) return;
const result = await Glow.openDialog({ const result = await Glow.openDialog({
title: `clear ${room.name}?`, title: `Clear ${room.name}?`,
body: "all messages in this room will be permanently deleted.", body: "All messages in this room will be permanently deleted.",
confirmText: "clear room", confirmText: "Clear room",
danger: true danger: true
}); });
if (!result) return; if (!result) return;
try { try {
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/messages`, { await Glow.apiRequest(
method: "DELETE" `/api/rooms/${encodeURIComponent(room.slug)}/messages`,
}); { method: "DELETE" }
);
Glow.clearMessages(); Glow.clearMessages();
Glow.renderEmptyState(); Glow.renderEmptyState();
Glow.showToast("room messages cleared."); Glow.showToast("Room messages cleared.");
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not clear room.", "error"); Glow.showToast(error.message, "error");
} }
}; };
Glow.manageRoomMembers = async room => { Glow.manageRoomMembers = async room => {
try { 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"); const container = document.createElement("div");
container.className = "member-list"; container.className = "member-list";
@ -604,7 +595,7 @@ Glow.manageRoomMembers = async room => {
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "small-button danger"; remove.className = "small-button danger";
remove.type = "button"; remove.type = "button";
remove.textContent = "remove"; remove.textContent = "Remove";
remove.disabled = member.membership_role === "owner"; remove.disabled = member.membership_role === "owner";
remove.addEventListener("click", async () => { remove.addEventListener("click", async () => {
@ -613,11 +604,12 @@ Glow.manageRoomMembers = async room => {
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`, `/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
{ method: "DELETE" } { method: "DELETE" }
); );
Glow.showToast("member removed.");
Glow.showToast("Member removed.");
Glow.closeDialog({}); Glow.closeDialog({});
Glow.manageRoomMembers(room); Glow.manageRoomMembers(room);
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not remove member.", "error"); Glow.showToast(error.message, "error");
} }
}); });
@ -628,11 +620,11 @@ Glow.manageRoomMembers = async room => {
const values = await Glow.openDialog({ const values = await Glow.openDialog({
title: `${room.name} members`, title: `${room.name} members`,
body: container, body: container,
confirmText: "add member", confirmText: "Add member",
fields: [ fields: [
{ {
name: "username", name: "username",
label: "username to add", label: "Username to add",
placeholder: "username", placeholder: "username",
maxLength: 30 maxLength: 30
} }
@ -641,37 +633,46 @@ Glow.manageRoomMembers = async room => {
if (!values?.username) return; if (!values?.username) return;
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`, { await Glow.apiRequest(
`/api/rooms/${encodeURIComponent(room.slug)}/members`,
{
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: values.username }) body: JSON.stringify({ username: values.username })
}); }
);
Glow.showToast("member added."); Glow.showToast("Member added.");
await Glow.manageRoomMembers(room); await Glow.manageRoomMembers(room);
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not manage room members.", "error"); Glow.showToast(error.message, "error");
} }
}; };
Glow.createInviteLink = async room => { Glow.createInviteLink = async room => {
try { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({}) 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({ const values = await Glow.openDialog({
title: "private-room invite link", title: "Private-room invite link",
body: "copy and share this link. it can be used once before it expires.", body: "Copy and share this link. It can be used once before it expires.",
confirmText: "copy link", confirmText: "Copy link",
fields: [ fields: [
{ {
name: "invite", name: "invite",
label: "invite link", label: "Invite link",
value: url, value: url,
maxLength: 500 maxLength: 500
} }
@ -680,21 +681,9 @@ Glow.createInviteLink = async room => {
if (values) { if (values) {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
Glow.showToast("invite link copied."); Glow.showToast("Invite link copied.");
} }
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not create invite link.", "error"); Glow.showToast(error.message, "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");
});

View File

@ -2,44 +2,6 @@
"use strict"; "use strict";
window.Glow = window.Glow || {}; window.Glow = window.Glow || {};
Glow.openCallPanel = async () => {
Glow.calls.ensureState();
Glow.state.calls.panelOpen = true;
const overlay = document.getElementById("callOverlay");
const participants = document.getElementById("callParticipants");
overlay?.classList.remove("hidden");
participants?.classList.remove("hidden");
Glow.restoreCallPanelPosition?.();
if (Glow.state.calls.activeCall?.groupSession?.id) {
await Glow.calls.refreshBackendParticipants();
}
Glow.renderCallUi();
};
Glow.calls.renderHeaderCallChip = () => {
if (!Glow.els.callStatusChip) return;
Glow.calls.ensureState();
const state = Glow.calls.state();
Glow.els.callStatusChip.classList.remove("hidden");
if (state.reconnecting) {
Glow.els.callStatusChip.textContent = "Call reconnecting";
} else if (state.joining) {
Glow.els.callStatusChip.textContent = "Call joining";
} else if (state.activeCall) {
Glow.els.callStatusChip.textContent = "Call active";
} else {
Glow.els.callStatusChip.textContent = "Call";
}
};
Glow.loadRooms = async () => { Glow.loadRooms = async () => {
const previousSlug = Glow.state.activeRoom?.slug; const previousSlug = Glow.state.activeRoom?.slug;
const data = await Glow.apiRequest("/api/rooms"); const data = await Glow.apiRequest("/api/rooms");
@ -68,100 +30,6 @@ Glow.loadRooms = async () => {
Glow.updateChatControls(); Glow.updateChatControls();
}; };
Glow.restoreCallPanelPosition = () => {
const overlay = Glow.els.callOverlay;
if (!overlay) return;
const savedX = localStorage.getItem("glow_call_panel_x");
const savedY = localStorage.getItem("glow_call_panel_y");
if (savedX == null || savedY == null) return;
const x = Number(savedX);
const y = Number(savedY);
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
Glow.state.calls.drag.x = x;
Glow.state.calls.drag.y = y;
overlay.style.left = `${x}px`;
overlay.style.top = `${y}px`;
overlay.style.right = "auto";
overlay.style.bottom = "auto";
overlay.style.alignItems = "flex-start";
overlay.style.justifyContent = "flex-start";
};
Glow.persistCallPanelPosition = () => {
const drag = Glow.state.calls?.drag;
if (!drag) return;
if (!Number.isFinite(drag.x) || !Number.isFinite(drag.y)) return;
localStorage.setItem("glow_call_panel_x", String(drag.x));
localStorage.setItem("glow_call_panel_y", String(drag.y));
};
Glow.confirmDirtySettingsBeforeNavigate = async action => {
if (Glow.state.activeView !== "settings" || !Glow.isSettingsDirty()) {
await action();
return true;
}
Glow.syncSettingsSavePanel?.();
const values = await Glow.openDialog({
title: "Unsaved settings changes",
body: "You have unsaved changes. Would you like to save before leaving this page?",
confirmText: "Save and leave",
cancelText: "Stay"
});
if (values === null) {
const discard = await Glow.openDialog({
title: "Discard changes?",
body: "If you leave now, your unsaved settings changes will be lost.",
confirmText: "Discard changes",
cancelText: "Stay",
danger: true
});
if (!discard) {
return false;
}
Glow.discardSettingsDraft?.();
await action();
return true;
}
try {
await Glow.saveSettingsDraft?.();
await action();
return true;
} catch (error) {
Glow.showToast(error.message || "Could not save settings.", "error");
return false;
}
};
Glow.navigateToView = async (view, options = {}) => {
return Glow.confirmDirtySettingsBeforeNavigate(async () => {
if (typeof options.before === "function") {
await options.before();
}
Glow.state.activeView = view;
Glow.updateViewUI();
if (typeof options.after === "function") {
await options.after();
}
if (options.schedulePoll !== false) {
Glow.schedulePoll?.();
}
});
};
Glow.updateViewUI = () => { Glow.updateViewUI = () => {
const view = Glow.state.activeView || "feed"; const view = Glow.state.activeView || "feed";
const room = Glow.state.activeRoom; const room = Glow.state.activeRoom;
@ -185,8 +53,6 @@ Glow.updateViewUI = () => {
Glow.els.composerWrap.classList.toggle("hidden", !chatActive); Glow.els.composerWrap.classList.toggle("hidden", !chatActive);
Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive); Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive);
Glow.syncSettingsSavePanel?.();
if (!chatActive) { if (!chatActive) {
Glow.state.typingUsers = []; Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = []; Glow.state.dmTypingUsers = [];
@ -221,7 +87,6 @@ Glow.updateViewUI = () => {
: "Sign in or create an account to personalize your feed"; : "Sign in or create an account to personalize your feed";
Glow.els.roomSelect.disabled = !Glow.state.rooms.length; Glow.els.roomSelect.disabled = !Glow.state.rooms.length;
Glow.els.presenceLabel?.classList.add("hidden"); Glow.els.presenceLabel?.classList.add("hidden");
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -231,7 +96,6 @@ Glow.updateViewUI = () => {
? "Private conversations" ? "Private conversations"
: "Sign in to open private conversations"; : "Sign in to open private conversations";
Glow.els.presenceLabel?.classList.add("hidden"); Glow.els.presenceLabel?.classList.add("hidden");
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -240,7 +104,6 @@ Glow.updateViewUI = () => {
Glow.els.headerSubtitle.textContent = Glow.els.headerSubtitle.textContent =
dm ? "Direct message replies" : room?.name || "Conversation replies"; dm ? "Direct message replies" : room?.name || "Conversation replies";
Glow.updateThreadControls?.(); Glow.updateThreadControls?.();
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -248,7 +111,6 @@ Glow.updateViewUI = () => {
Glow.els.headerTitle.textContent = "Pages"; Glow.els.headerTitle.textContent = "Pages";
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources"; Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
Glow.els.presenceLabel?.classList.add("hidden"); Glow.els.presenceLabel?.classList.add("hidden");
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -256,7 +118,6 @@ Glow.updateViewUI = () => {
Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page"; Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page";
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources"; Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
Glow.els.presenceLabel?.classList.add("hidden"); Glow.els.presenceLabel?.classList.add("hidden");
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -267,7 +128,6 @@ Glow.updateViewUI = () => {
"Profile"; "Profile";
Glow.els.headerSubtitle.textContent = "User profile"; Glow.els.headerSubtitle.textContent = "User profile";
Glow.els.presenceLabel?.classList.add("hidden"); Glow.els.presenceLabel?.classList.add("hidden");
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -275,7 +135,6 @@ Glow.updateViewUI = () => {
Glow.els.headerTitle.textContent = "Settings"; Glow.els.headerTitle.textContent = "Settings";
Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences"; Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences";
Glow.els.presenceLabel?.classList.add("hidden"); Glow.els.presenceLabel?.classList.add("hidden");
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -284,14 +143,12 @@ Glow.updateViewUI = () => {
(dm.members || []).find( (dm.members || []).find(
member => Number(member.id) !== Number(Glow.state.currentUser?.id) member => Number(member.id) !== Number(Glow.state.currentUser?.id)
) || dm.members?.[0]; ) || dm.members?.[0];
Glow.els.headerTitle.textContent = Glow.els.headerTitle.textContent =
peer?.display_name || peer?.username || "Direct message"; peer?.display_name || peer?.username || "Direct message";
Glow.els.headerSubtitle.textContent = Glow.els.headerSubtitle.textContent =
peer?.username ? `@${peer.username}` : "Private conversation"; peer?.username ? `@${peer.username}` : "Private conversation";
Glow.refreshPresence?.(); Glow.refreshPresence?.();
Glow.updateChatControls(); Glow.updateChatControls();
Glow.calls?.renderHeaderCallChip?.();
return; return;
} }
@ -307,7 +164,6 @@ Glow.updateViewUI = () => {
} }
Glow.updateChatControls(); Glow.updateChatControls();
Glow.calls?.renderHeaderCallChip?.();
}; };
Glow.updateAuthUI = () => { Glow.updateAuthUI = () => {
@ -404,7 +260,6 @@ Glow.checkAuth = async () => {
Glow.authenticate = async endpoint => { Glow.authenticate = async endpoint => {
Glow.clearAuthNotice(); Glow.clearAuthNotice();
const accessKey = Glow.els.accessKeyInput.value; const accessKey = Glow.els.accessKeyInput.value;
const username = Glow.els.usernameInput.value.trim().toLowerCase(); const username = Glow.els.usernameInput.value.trim().toLowerCase();
const password = Glow.els.passwordInput.value; const password = Glow.els.passwordInput.value;
@ -457,28 +312,10 @@ Glow.authenticate = async endpoint => {
}; };
Glow.logout = async () => { Glow.logout = async () => {
if (Glow.state.calls.activeCall?.groupSession?.id) {
const values = await Glow.openDialog({
title: "Leave active call and sign out?",
body: "You are currently in a voice call. Signing out will disconnect you from the call.",
confirmText: "Leave and sign out",
cancelText: "Stay signed in",
danger: true
});
if (!values) return;
}
try { try {
await Glow.apiRequest("/api/logout", { method: "POST" }); await Glow.apiRequest("/api/logout", { method: "POST" });
} catch (_) {} } catch (_) {}
if (Glow.state.calls.activeCall?.groupSession?.id) {
try {
await Glow.apiLeaveGroupCall(Glow.state.calls.activeCall.groupSession.id);
} catch (_) {}
}
Glow.state.currentUser = null; Glow.state.currentUser = null;
Glow.state.csrfToken = ""; Glow.state.csrfToken = "";
Glow.state.rooms = []; Glow.state.rooms = [];
@ -495,9 +332,6 @@ Glow.logout = async () => {
Glow.state.activePage = null; Glow.state.activePage = null;
Glow.state.activeProfile = null; Glow.state.activeProfile = null;
Glow.state.settings = null; Glow.state.settings = null;
Glow.state.settingsSaved = null;
Glow.state.settingsDraft = null;
Glow.state.settingsDirty = false;
Glow.clearMessages(); Glow.clearMessages();
Glow.clearFeed(); Glow.clearFeed();
@ -508,8 +342,6 @@ Glow.logout = async () => {
Glow.endLocalCallUi?.(); Glow.endLocalCallUi?.();
clearTimeout(Glow.state.calls?.invitePollTimer); clearTimeout(Glow.state.calls?.invitePollTimer);
clearTimeout(Glow.state.calls?.participantPollTimer);
clearTimeout(Glow.state.calls?.heartbeatTimer);
Glow.updateAuthUI(); Glow.updateAuthUI();
@ -555,13 +387,11 @@ Glow.toggleTypingIndicators = () => {
"glow_typing_enabled", "glow_typing_enabled",
String(Glow.state.typingIndicatorsEnabled) String(Glow.state.typingIndicatorsEnabled)
); );
if (!Glow.state.typingIndicatorsEnabled) { if (!Glow.state.typingIndicatorsEnabled) {
Glow.state.typingUsers = []; Glow.state.typingUsers = [];
Glow.state.dmTypingUsers = []; Glow.state.dmTypingUsers = [];
Glow.renderTyping(); Glow.renderTyping();
} }
Glow.updateAdminPreferenceButtons?.(); Glow.updateAdminPreferenceButtons?.();
Glow.showToast( Glow.showToast(
Glow.state.typingIndicatorsEnabled Glow.state.typingIndicatorsEnabled
@ -578,7 +408,7 @@ Glow.pingPresence = async () => {
}; };
if (Glow.state.activeDm?.id) { if (Glow.state.activeDm?.id) {
payload.dm_id = Glow.state.activeDm.id; payload.direct_conversation_id = Glow.state.activeDm.id;
} else if (Glow.state.activeChannel?.id) { } else if (Glow.state.activeChannel?.id) {
payload.channel_id = Glow.state.activeChannel.id; payload.channel_id = Glow.state.activeChannel.id;
} else if (Glow.state.activeRoom?.id) { } else if (Glow.state.activeRoom?.id) {
@ -627,7 +457,6 @@ Glow.refreshPresence = async () => {
Glow.schedulePoll = () => { Glow.schedulePoll = () => {
clearTimeout(Glow.state.pollTimer); clearTimeout(Glow.state.pollTimer);
if (Glow.els.blockedOverlay.classList.contains("hidden") === false) return; if (Glow.els.blockedOverlay.classList.contains("hidden") === false) return;
Glow.state.pollTimer = setTimeout(async () => { Glow.state.pollTimer = setTimeout(async () => {
@ -639,8 +468,10 @@ Glow.schedulePoll = () => {
Glow.state.activeView === "dm" Glow.state.activeView === "dm"
) { ) {
await Glow.pollMessages(); await Glow.pollMessages();
await Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "thread") { } else if (Glow.state.activeView === "thread") {
await Glow.pollThread?.(); await Glow.pollThread?.();
await Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "dms") { } else if (Glow.state.activeView === "dms") {
await Glow.loadDms?.().catch(() => {}); await Glow.loadDms?.().catch(() => {});
} }
@ -658,7 +489,6 @@ Glow.els.messageInput.addEventListener("input", () => {
Glow.autoResizeComposer(); Glow.autoResizeComposer();
Glow.updateMessageCount(); Glow.updateMessageCount();
clearTimeout(Glow.state.typingTimer); clearTimeout(Glow.state.typingTimer);
if (Glow.els.messageInput.value.trim()) { if (Glow.els.messageInput.value.trim()) {
Glow.sendTyping(true); Glow.sendTyping(true);
Glow.state.typingTimer = setTimeout(() => { Glow.state.typingTimer = setTimeout(() => {
@ -675,23 +505,6 @@ Glow.els.threadInput?.addEventListener("input", () => {
}); });
Glow.els.messageInput.addEventListener("keydown", event => { Glow.els.messageInput.addEventListener("keydown", event => {
if (Glow.state.emojiAutocomplete?.open) {
if (event.key === "Enter" || event.key === "Tab") {
if (typeof Glow.applyEmojiSuggestion === "function") {
event.preventDefault();
Glow.applyEmojiSuggestion();
return;
}
}
if (event.key === "Escape") {
if (typeof Glow.closeEmojiAutocomplete === "function") {
event.preventDefault();
Glow.closeEmojiAutocomplete();
return;
}
}
}
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
Glow.sendMessage(event); Glow.sendMessage(event);
@ -699,23 +512,6 @@ Glow.els.messageInput.addEventListener("keydown", event => {
}); });
Glow.els.threadInput?.addEventListener("keydown", event => { Glow.els.threadInput?.addEventListener("keydown", event => {
if (Glow.state.emojiAutocomplete?.open && Glow.state.emojiAutocomplete.owner === "thread") {
if (event.key === "Enter" || event.key === "Tab") {
if (typeof Glow.applyEmojiSuggestion === "function") {
event.preventDefault();
Glow.applyEmojiSuggestion();
return;
}
}
if (event.key === "Escape") {
if (typeof Glow.closeEmojiAutocomplete === "function") {
event.preventDefault();
Glow.closeEmojiAutocomplete();
return;
}
}
}
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
Glow.sendThreadReply?.(event); Glow.sendThreadReply?.(event);
@ -724,12 +520,10 @@ Glow.els.threadInput?.addEventListener("keydown", event => {
Glow.els.attachmentInput.addEventListener("change", () => { Glow.els.attachmentInput.addEventListener("change", () => {
const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files); const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files);
if (!files.length) { if (!files.length) {
Glow.setSelectedAttachment([]); Glow.setSelectedAttachment([]);
return; return;
} }
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES); const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
if (invalid) { if (invalid) {
Glow.showToast("Attachments must be 5 MB or smaller.", "error"); Glow.showToast("Attachments must be 5 MB or smaller.", "error");
@ -737,18 +531,15 @@ Glow.els.attachmentInput.addEventListener("change", () => {
Glow.setSelectedAttachment([]); Glow.setSelectedAttachment([]);
return; return;
} }
Glow.setSelectedAttachment(files); Glow.setSelectedAttachment(files);
}); });
Glow.els.threadAttachmentInput?.addEventListener("change", () => { Glow.els.threadAttachmentInput?.addEventListener("change", () => {
const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files); const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files);
if (!files.length) { if (!files.length) {
Glow.setSelectedThreadAttachment?.([]); Glow.setSelectedThreadAttachment?.([]);
return; return;
} }
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES); const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
if (invalid) { if (invalid) {
Glow.showToast("Attachments must be 5 MB or smaller.", "error"); Glow.showToast("Attachments must be 5 MB or smaller.", "error");
@ -756,37 +547,23 @@ Glow.els.threadAttachmentInput?.addEventListener("change", () => {
Glow.setSelectedThreadAttachment?.([]); Glow.setSelectedThreadAttachment?.([]);
return; return;
} }
Glow.setSelectedThreadAttachment?.(files); Glow.setSelectedThreadAttachment?.(files);
}); });
Glow.els.feedButton.addEventListener("click", async () => { Glow.els.feedButton.addEventListener("click", Glow.openFeed);
await Glow.navigateToView("feed", {
before: async () => {
Glow.state.activeDm = null;
},
after: async () => {
await Glow.loadFeed();
}
});
});
Glow.els.dmsButton?.addEventListener("click", async () => { Glow.els.dmsButton?.addEventListener("click", async () => {
await Glow.navigateToView("dms", { Glow.state.activeView = "dms";
after: async () => { Glow.updateViewUI();
await Glow.loadDms?.(); await Glow.loadDms?.();
} Glow.schedulePoll();
});
}); });
Glow.els.pagesButton?.addEventListener("click", async () => { Glow.els.pagesButton?.addEventListener("click", () => {
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
Glow.openPages?.(); Glow.openPages?.();
});
}); });
Glow.els.backButton?.addEventListener("click", async () => { Glow.els.backButton?.addEventListener("click", async () => {
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
if (Glow.state.activeView === "thread") { if (Glow.state.activeView === "thread") {
await Glow.returnToChannel?.(); await Glow.returnToChannel?.();
} else if (Glow.state.activeView === "page") { } else if (Glow.state.activeView === "page") {
@ -798,7 +575,6 @@ Glow.els.backButton?.addEventListener("click", async () => {
Glow.updateViewUI(); Glow.updateViewUI();
await Glow.loadDms?.(); await Glow.loadDms?.();
} }
});
}); });
Glow.els.loginButton.addEventListener("click", () => { Glow.els.loginButton.addEventListener("click", () => {
@ -824,7 +600,6 @@ Glow.els.roomSelect.addEventListener("change", async () => {
); );
if (!selected) return; if (!selected) return;
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
Glow.state.activeDm = null; Glow.state.activeDm = null;
Glow.state.activeRoom = selected; Glow.state.activeRoom = selected;
Glow.state.activeView = "room"; Glow.state.activeView = "room";
@ -838,7 +613,6 @@ Glow.els.roomSelect.addEventListener("change", async () => {
Glow.pingPresence?.(); Glow.pingPresence?.();
Glow.refreshPresence?.(); Glow.refreshPresence?.();
Glow.schedulePoll(); Glow.schedulePoll();
});
}); });
Glow.els.teamSelect?.addEventListener("change", () => { Glow.els.teamSelect?.addEventListener("change", () => {
@ -851,18 +625,14 @@ Glow.els.adminFab.addEventListener("click", Glow.openAdminPanel);
Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel); Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel);
Glow.els.createRoomButton.addEventListener("click", Glow.createRoom); Glow.els.createRoomButton.addEventListener("click", Glow.createRoom);
Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom); Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom);
Glow.els.typingToggleButton?.addEventListener("click", Glow.toggleTypingIndicators); Glow.els.typingToggleButton.addEventListener("click", Glow.toggleTypingIndicators);
Glow.els.notificationsToggleButton?.addEventListener("click", Glow.toggleNotifications); Glow.els.notificationsToggleButton.addEventListener("click", Glow.toggleNotifications);
Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker); Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker);
Glow.els.settingsButton?.addEventListener("click", () => { Glow.els.settingsButton?.addEventListener("click", () => {
Glow.toggleHeaderMenu?.(); Glow.toggleHeaderMenu?.();
}); });
document.getElementById("callStatusChip")?.addEventListener("click", async () => {
await Glow.openCallPanel?.();
});
Glow.els.jumpToLatestButton.addEventListener("click", () => { Glow.els.jumpToLatestButton.addEventListener("click", () => {
Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight; Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight;
Glow.resetUnreadMessages(); Glow.resetUnreadMessages();
@ -912,14 +682,11 @@ Glow.els.mediaOverlay?.addEventListener("click", event => {
} }
}); });
document.getElementById("closeCallPanelButton")?.addEventListener("click", () => { Glow.els.closeCallPanelButton?.addEventListener("click", () => {
Glow.calls.ensureState(); Glow.els.callOverlay?.classList.add("hidden");
Glow.state.calls.panelOpen = false;
document.getElementById("callOverlay")?.classList.add("hidden");
Glow.persistCallPanelPosition?.();
}); });
document.addEventListener("keydown", async event => { document.addEventListener("keydown", event => {
if (event.key !== "Escape") return; if (event.key !== "Escape") return;
if (!Glow.els.dialogOverlay.classList.contains("hidden")) { if (!Glow.els.dialogOverlay.classList.contains("hidden")) {
@ -927,11 +694,6 @@ document.addEventListener("keydown", async event => {
return; return;
} }
if (!Glow.els.imageCropOverlay?.classList.contains("hidden")) {
Glow.profileResetCropState?.();
return;
}
if (!Glow.els.mediaOverlay?.classList.contains("hidden")) { if (!Glow.els.mediaOverlay?.classList.contains("hidden")) {
Glow.closeMediaModal?.(); Glow.closeMediaModal?.();
return; return;
@ -948,33 +710,24 @@ document.addEventListener("keydown", async event => {
} }
if (!Glow.els.callOverlay?.classList.contains("hidden")) { if (!Glow.els.callOverlay?.classList.contains("hidden")) {
Glow.calls.ensureState();
Glow.state.calls.panelOpen = false;
Glow.els.callOverlay.classList.add("hidden"); Glow.els.callOverlay.classList.add("hidden");
Glow.persistCallPanelPosition?.();
return; return;
} }
if (Glow.state.activeView === "thread") { if (Glow.state.activeView === "thread") {
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
Glow.returnToChannel?.(); Glow.returnToChannel?.();
});
return; return;
} }
if (Glow.state.activeView === "page") { if (Glow.state.activeView === "page") {
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
Glow.openPages?.(); Glow.openPages?.();
});
return; return;
} }
if (Glow.state.activeView === "dm") { if (Glow.state.activeView === "dm") {
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
Glow.state.activeView = "dms"; Glow.state.activeView = "dms";
Glow.updateViewUI(); Glow.updateViewUI();
Glow.loadDms?.(); Glow.loadDms?.();
});
return; return;
} }
@ -999,8 +752,10 @@ document.addEventListener("visibilitychange", () => {
) { ) {
Glow.pollMessages(); Glow.pollMessages();
Glow.pollTyping(); Glow.pollTyping();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "thread") { } else if (Glow.state.activeView === "thread") {
Glow.pollThread?.(); Glow.pollThread?.();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "dms") { } else if (Glow.state.activeView === "dms") {
Glow.loadDms?.(); Glow.loadDms?.();
} }
@ -1022,8 +777,10 @@ window.addEventListener("online", () => {
) { ) {
Glow.pollMessages(); Glow.pollMessages();
Glow.pollTyping(); Glow.pollTyping();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "thread") { } else if (Glow.state.activeView === "thread") {
Glow.pollThread?.(); Glow.pollThread?.();
Glow.pollCallSignals?.();
} else if (Glow.state.activeView === "dms") { } else if (Glow.state.activeView === "dms") {
Glow.loadDms?.(); Glow.loadDms?.();
} }
@ -1058,43 +815,9 @@ setInterval(() => {
} }
}, 10000); }, 10000);
window.addEventListener("beforeunload", event => {
if (Glow.state.activeView === "settings" && Glow.isSettingsDirty()) {
event.preventDefault();
event.returnValue = "";
return "";
}
const sessionId = Glow.state.calls.activeCall?.groupSession?.id;
if (!sessionId || !navigator.sendBeacon) return;
const csrf = Glow.state.csrfToken || "";
const blob = new Blob([JSON.stringify({})], {
type: "application/json"
});
navigator.sendBeacon(
`/api/group-calls/${encodeURIComponent(sessionId)}/leave`,
blob
);
fetch(`/api/group-calls/${encodeURIComponent(sessionId)}/leave`, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
Accept: "application/json"
},
body: JSON.stringify({}),
keepalive: true
}).catch(() => {});
});
Glow.applyAccentColor(Glow.state.accentColor); Glow.applyAccentColor(Glow.state.accentColor);
Glow.autoResizeComposer(); Glow.autoResizeComposer();
Glow.updateMessageCount(); Glow.updateMessageCount();
Glow.autoResizeThreadComposer?.(); Glow.autoResizeThreadComposer?.();
Glow.updateThreadMessageCount?.(); Glow.updateThreadMessageCount?.();
Glow.state.dmLayoutMode = Glow.loadDmLayoutPreference();
Glow.checkAuth(); Glow.checkAuth();

View File

@ -99,36 +99,14 @@ Glow.openFeedEntry = async entry => {
Glow.showToast("This conversation is no longer available.", "error"); 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 => { Glow.buildFeedReactions = entry => {
const reactions = Array.isArray(entry.reactions) ? entry.reactions : []; const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
if (!reactions.length) return null; if (!reactions.length) return null;
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "feed-entry-reactions compact"; row.className = "feed-entry-reactions";
for (const reaction of reactions.slice(0, 6)) { for (const reaction of reactions) {
const chip = document.createElement("button"); const chip = document.createElement("button");
chip.className = "reaction-chip"; chip.className = "reaction-chip";
chip.type = "button"; chip.type = "button";
@ -158,35 +136,97 @@ Glow.buildFeedReactions = entry => {
return row; return row;
}; };
Glow.buildFeedEntry = entry => { Glow.buildFeedAttachmentBlock = entry => {
const row = document.createElement("article"); const attachments = Array.isArray(entry.attachments)
row.className = "feed-entry timeline-row"; ? entry.attachments
: entry.attachment
? [entry.attachment]
: [];
if (entry.is_mention) { if (!attachments.length) return null;
row.classList.add("mention");
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);
} }
row.dataset.messageId = String(entry.id); 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);
}
if (entry.room_slug) row.dataset.roomSlug = entry.room_slug; return wrap;
if (entry.team_slug) row.dataset.teamSlug = entry.team_slug; };
if (entry.channel_slug) row.dataset.channelSlug = entry.channel_slug;
const top = document.createElement("div"); Glow.buildFeedEntry = entry => {
top.className = "feed-entry-top"; const card = document.createElement("article");
card.className = "feed-entry";
const context = document.createElement("button"); if (entry.is_mention) {
context.className = "feed-room-link"; card.classList.add("mention");
context.type = "button"; }
card.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;
const heading = document.createElement("div");
heading.className = "feed-entry-heading";
const room = document.createElement("button");
room.className = "feed-room-link";
room.type = "button";
if (entry.team_name && entry.channel_name) { if (entry.team_name && entry.channel_name) {
context.textContent = `${entry.team_name} · # ${entry.channel_name}`; room.textContent = `${entry.team_name} · # ${entry.channel_name}`;
} else { } else {
context.textContent = room.textContent =
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`; `${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
} }
context.addEventListener("click", () => { room.addEventListener("click", () => {
Glow.openFeedEntry(entry); Glow.openFeedEntry(entry);
}); });
@ -196,7 +236,7 @@ Glow.buildFeedEntry = entry => {
time.textContent = Glow.relativeTime(entry.created_at); time.textContent = Glow.relativeTime(entry.created_at);
time.title = new Date(entry.created_at).toLocaleString(); time.title = new Date(entry.created_at).toLocaleString();
top.append(context, time); heading.append(room, time);
const author = document.createElement("button"); const author = document.createElement("button");
author.className = "feed-entry-author profile-link"; author.className = "feed-entry-author profile-link";
@ -208,59 +248,37 @@ Glow.buildFeedEntry = entry => {
}); });
const body = document.createElement("div"); const body = document.createElement("div");
body.className = "feed-entry-body clamp-2"; body.className = "feed-entry-body";
body.textContent = entry.text || ""; 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); const reactions = Glow.buildFeedReactions(entry);
if (reactions) {
meta.append(reactions);
}
const openButton = document.createElement("button"); const openButton = document.createElement("button");
openButton.className = "small-button feed-open-room"; openButton.className = "small-button feed-open-room";
openButton.type = "button"; openButton.type = "button";
openButton.textContent = "Open"; openButton.textContent = "Open channel";
openButton.addEventListener("click", () => { openButton.addEventListener("click", () => {
Glow.openFeedEntry(entry); Glow.openFeedEntry(entry);
}); });
footer.append(meta, openButton); card.addEventListener("dblclick", () => {
row.addEventListener("dblclick", () => {
Glow.openFeedEntry(entry); Glow.openFeedEntry(entry);
}); });
row.addEventListener("click", event => { card.append(heading, author, body);
if (
event.target instanceof HTMLElement && if (attachmentBlock) {
(event.target.closest("button") || event.target.closest("a")) card.append(attachmentBlock);
) {
return;
} }
Glow.openFeedEntry(entry);
});
row.append(top, author, body, footer); if (reactions) {
card.append(reactions);
}
return row; card.append(openButton);
return card;
}; };
Glow.appendFeedEntries = (entries, forceScroll = false) => { Glow.appendFeedEntries = (entries, forceScroll = false) => {

View File

@ -1,49 +1,58 @@
// static/js/06-pages.js // static/js/06-pages.js
"use strict"; "use strict";
window.Glow = window.Glow || {}; window.Glow = window.Glow || {};
// comments, version history, and share links intentionally disabled in production until backend support complete
Glow.renderPagesEmptyState = message => { Glow.renderPagesEmptyState = message => {
Glow.els.pagesBox.replaceChildren(); Glow.els.pagesBox.replaceChildren();
const state = document.createElement("div"); const state = document.createElement("div");
state.className = "empty-state"; state.className = "empty-state";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "empty-card"; card.className = "empty-card";
card.textContent = message; card.textContent = message;
state.append(card); state.append(card);
Glow.els.pagesBox.append(state); Glow.els.pagesBox.append(state);
}; };
Glow.pageTypeLabel = type => { Glow.pageTypeLabel = type => {
const labels = { const labels = {
project: "project", project: "Project",
document: "document", document: "Document",
update: "update", update: "Update",
resource: "resource" resource: "Resource"
}; };
return labels[type] || "page";
return labels[type] || "Page";
}; };
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => { Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
const team = Glow.state.activeTeam; const team = Glow.state.activeTeam;
if (!team) { if (!team) {
Glow.renderPagesEmptyState("select a team to view pages."); Glow.renderPagesEmptyState("Select a team to view pages.");
return; return;
} }
Glow.els.pagesBox.setAttribute("aria-busy", "true"); Glow.els.pagesBox.setAttribute("aria-busy", "true");
try { try {
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : ""; const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
const data = await Glow.apiRequest( const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages${query}` `/api/teams/${encodeURIComponent(team.slug)}/pages${query}`
); );
if (Glow.state.activeTeam?.slug !== team.slug) return; if (Glow.state.activeTeam?.slug !== team.slug) return;
Glow.state.pages = data.pages || []; Glow.state.pages = data.pages || [];
Glow.state.pageTypeFilter = pageType || ""; Glow.state.pageTypeFilter = pageType || "";
Glow.renderPages(); Glow.renderPages();
} catch (error) { } catch (error) {
Glow.renderPagesEmptyState(error.message || "could not load pages."); Glow.renderPagesEmptyState(error.message || "Could not load pages.");
Glow.showToast(error.message || "could not load pages.", "error"); Glow.showToast(error.message || "Could not load pages.", "error");
} finally { } finally {
Glow.els.pagesBox.setAttribute("aria-busy", "false"); Glow.els.pagesBox.setAttribute("aria-busy", "false");
} }
@ -53,16 +62,20 @@ Glow.renderPages = () => {
const box = Glow.els.pagesBox; const box = Glow.els.pagesBox;
const team = Glow.state.activeTeam; const team = Glow.state.activeTeam;
const pages = Glow.state.pages || []; const pages = Glow.state.pages || [];
box.replaceChildren(); box.replaceChildren();
const header = document.createElement("div"); const header = document.createElement("div");
header.className = "pages-header"; header.className = "pages-header";
const copy = document.createElement("div"); const copy = document.createElement("div");
const title = document.createElement("h2"); const title = document.createElement("h2");
title.textContent = `${team?.name || "team"} pages`; title.textContent = `${team?.name || "Team"} pages`;
const subtitle = document.createElement("p"); 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); copy.append(title, subtitle);
header.append(copy); header.append(copy);
@ -70,21 +83,23 @@ Glow.renderPages = () => {
const create = document.createElement("button"); const create = document.createElement("button");
create.className = "button"; create.className = "button";
create.type = "button"; create.type = "button";
create.textContent = "new page"; create.textContent = "New page";
create.addEventListener("click", Glow.createPage); create.addEventListener("click", Glow.createPage);
header.append(create); header.append(create);
} }
box.append(header); box.append(header);
const filters = document.createElement("div"); const filters = document.createElement("div");
filters.className = "page-filters compact"; filters.className = "page-filters";
const filterOptions = [ const filterOptions = [
["", "all"], ["", "All"],
["project", "projects"], ["project", "Projects"],
["document", "documents"], ["document", "Documents"],
["update", "updates"], ["update", "Updates"],
["resource", "resources"] ["resource", "Resources"]
]; ];
for (const [value, label] of filterOptions) { for (const [value, label] of filterOptions) {
@ -93,6 +108,7 @@ Glow.renderPages = () => {
button.type = "button"; button.type = "button";
button.textContent = label; button.textContent = label;
button.classList.toggle("active", Glow.state.pageTypeFilter === value); button.classList.toggle("active", Glow.state.pageTypeFilter === value);
button.addEventListener("click", () => Glow.loadPages(value)); button.addEventListener("click", () => Glow.loadPages(value));
filters.append(button); filters.append(button);
} }
@ -102,9 +118,11 @@ Glow.renderPages = () => {
if (!pages.length) { if (!pages.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "empty-state page-empty-state"; empty.className = "empty-state page-empty-state";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "empty-card"; card.className = "empty-card";
card.textContent = "no pages match this filter."; card.textContent = "No pages match this filter.";
empty.append(card); empty.append(card);
box.append(empty); box.append(empty);
return; return;
@ -122,7 +140,7 @@ Glow.renderPages = () => {
Glow.buildPageCard = page => { Glow.buildPageCard = page => {
const article = document.createElement("article"); const article = document.createElement("article");
article.className = "page-card compact"; article.className = "page-card";
article.tabIndex = 0; article.tabIndex = 0;
const meta = document.createElement("div"); const meta = document.createElement("div");
@ -141,22 +159,31 @@ Glow.buildPageCard = page => {
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = page.title; 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"); const footer = document.createElement("div");
footer.className = "page-card-footer"; footer.className = "page-card-footer";
const context = document.createElement("span"); const context = document.createElement("span");
context.textContent = page.channel_name context.textContent = page.channel_name ? `#${page.channel_name}` : "Team page";
? `#${page.channel_name}`
: "team page";
const updated = document.createElement("time"); const updated = document.createElement("time");
updated.dateTime = page.updated_at; updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString(); 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); footer.append(context, updated);
const open = () => Glow.openPage(page.slug); const open = () => Glow.openPage(page.slug);
article.addEventListener("click", open); article.addEventListener("click", open);
article.addEventListener("keydown", event => { article.addEventListener("keydown", event => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
@ -165,7 +192,7 @@ Glow.buildPageCard = page => {
} }
}); });
article.append(meta, title, footer); article.append(meta, title, excerpt, footer);
return article; return article;
}; };
@ -173,6 +200,7 @@ Glow.openPages = async () => {
Glow.state.activeDm = null; Glow.state.activeDm = null;
Glow.state.activeView = "pages"; Glow.state.activeView = "pages";
Glow.state.activePage = null; Glow.state.activePage = null;
Glow.updateViewUI(); Glow.updateViewUI();
await Glow.loadPages(); await Glow.loadPages();
}; };
@ -184,16 +212,18 @@ Glow.openPage = async pageSlug => {
Glow.state.activeView = "page"; Glow.state.activeView = "page";
Glow.state.activePage = null; Glow.state.activePage = null;
Glow.updateViewUI(); Glow.updateViewUI();
Glow.els.pageDetailBox.setAttribute("aria-busy", "true"); Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
try { try {
const data = await Glow.apiRequest( const data = await Glow.apiRequest(
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}` `/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
); );
Glow.state.activePage = data.page; Glow.state.activePage = data.page;
Glow.renderPageDetail(); Glow.renderPageDetail();
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not load page.", "error"); Glow.showToast(error.message || "Could not load page.", "error");
Glow.openPages(); Glow.openPages();
} finally { } finally {
Glow.els.pageDetailBox.setAttribute("aria-busy", "false"); Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
@ -203,14 +233,17 @@ Glow.openPage = async pageSlug => {
Glow.renderPageDetail = () => { Glow.renderPageDetail = () => {
const box = Glow.els.pageDetailBox; const box = Glow.els.pageDetailBox;
const page = Glow.state.activePage; const page = Glow.state.activePage;
box.replaceChildren(); box.replaceChildren();
if (!page) { if (!page) {
const state = document.createElement("div"); const state = document.createElement("div");
state.className = "empty-state"; state.className = "empty-state";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "empty-card"; card.className = "empty-card";
card.textContent = "page not found."; card.textContent = "Page not found.";
state.append(card); state.append(card);
box.append(state); box.append(state);
return; return;
@ -234,7 +267,7 @@ Glow.renderPageDetail = () => {
updated.className = "page-updated"; updated.className = "page-updated";
updated.dateTime = page.updated_at; updated.dateTime = page.updated_at;
updated.title = new Date(page.updated_at).toLocaleString(); 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); meta.append(type, visibility, updated);
@ -252,61 +285,61 @@ Glow.renderPageDetail = () => {
const body = document.createElement("article"); const body = document.createElement("article");
body.className = "page-body markdown-body"; 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); box.append(body);
if (page.can_edit) {
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "page-detail-actions"; actions.className = "page-detail-actions";
if (page.can_edit) {
const edit = document.createElement("button"); const edit = document.createElement("button");
edit.className = "button"; edit.className = "button";
edit.type = "button"; edit.type = "button";
edit.textContent = "edit"; edit.textContent = "Edit";
edit.addEventListener("click", () => Glow.editPage(page)); edit.addEventListener("click", () => Glow.editPage(page));
const remove = document.createElement("button"); const remove = document.createElement("button");
remove.className = "danger-button"; remove.className = "danger-button";
remove.type = "button"; remove.type = "button";
remove.textContent = "delete"; remove.textContent = "Delete";
remove.addEventListener("click", () => Glow.deletePage(page)); remove.addEventListener("click", () => Glow.deletePage(page));
actions.append(edit, remove); actions.append(edit, remove);
}
box.append(actions); box.append(actions);
}
}; };
Glow.pageDialogFields = (page = null) => [ Glow.pageDialogFields = (page = null) => [
{ {
name: "title", name: "title",
label: "title", label: "Title",
value: page?.title || "", value: page?.title || "",
placeholder: "page title", placeholder: "Page title",
maxLength: 160 maxLength: 160
}, },
{ {
name: "page_type", name: "page_type",
label: "type: project, document, update, or resource", label: "Type: project, document, update, or resource",
value: page?.page_type || "document", value: page?.page_type || "document",
maxLength: 12 maxLength: 12
}, },
{ {
name: "visibility", name: "visibility",
label: "visibility: team or public", label: "Visibility: team or public",
value: page?.visibility || "team", value: page?.visibility || "team",
maxLength: 6 maxLength: 6
}, },
{ {
name: "channel_slug", name: "channel_slug",
label: "channel slug (optional)", label: "Channel slug (optional)",
value: page?.channel_slug || "", value: page?.channel_slug || "",
placeholder: "general", placeholder: "general",
maxLength: 48 maxLength: 48
}, },
{ {
name: "body", name: "body",
label: "markdown content", label: "Markdown content",
value: page?.body || "", value: page?.body || "",
multiline: true, multiline: true,
rows: 14, rows: 14,
@ -316,19 +349,19 @@ Glow.pageDialogFields = (page = null) => [
Glow.validatePageValues = values => { Glow.validatePageValues = values => {
if (!values.title) { if (!values.title) {
Glow.showToast("page title is required.", "error"); Glow.showToast("Page title is required.", "error");
return null; return null;
} }
const pageType = values.page_type.toLowerCase(); const pageType = values.page_type.toLowerCase();
if (!["project", "document", "update", "resource"].includes(pageType)) { 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; return null;
} }
const visibility = values.visibility.toLowerCase(); const visibility = values.visibility.toLowerCase();
if (!["team", "public"].includes(visibility)) { 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 null;
} }
@ -346,11 +379,12 @@ Glow.createPage = async () => {
if (!team?.can_manage) return; if (!team?.can_manage) return;
const values = await Glow.openDialog({ const values = await Glow.openDialog({
title: "create page", title: "Create page",
body: "create a shared team page or a public resource. markdown is supported.", body: "Create a shared team page or a public resource. Markdown is supported.",
confirmText: "create page", confirmText: "Create page",
fields: Glow.pageDialogFields() fields: Glow.pageDialogFields()
}); });
if (!values) return; if (!values) return;
const payload = Glow.validatePageValues(values); const payload = Glow.validatePageValues(values);
@ -365,10 +399,11 @@ Glow.createPage = async () => {
body: JSON.stringify(payload) body: JSON.stringify(payload)
} }
); );
Glow.showToast("page created.");
Glow.showToast("Page created.");
await Glow.openPage(data.page.slug); await Glow.openPage(data.page.slug);
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not create page.", "error"); Glow.showToast(error.message || "Could not create page.", "error");
} }
}; };
@ -376,10 +411,11 @@ Glow.editPage = async page => {
if (!page.can_edit) return; if (!page.can_edit) return;
const values = await Glow.openDialog({ const values = await Glow.openDialog({
title: `edit ${page.title}`, title: `Edit ${page.title}`,
confirmText: "save", confirmText: "Save",
fields: Glow.pageDialogFields(page) fields: Glow.pageDialogFields(page)
}); });
if (!values) return; if (!values) return;
const payload = Glow.validatePageValues(values); const payload = Glow.validatePageValues(values);
@ -394,11 +430,12 @@ Glow.editPage = async page => {
body: JSON.stringify(payload) body: JSON.stringify(payload)
} }
); );
Glow.state.activePage = data.page; Glow.state.activePage = data.page;
Glow.renderPageDetail(); Glow.renderPageDetail();
Glow.showToast("page updated."); Glow.showToast("Page updated.");
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not update page.", "error"); Glow.showToast(error.message || "Could not update page.", "error");
} }
}; };
@ -406,11 +443,12 @@ Glow.deletePage = async page => {
if (!page.can_edit) return; if (!page.can_edit) return;
const result = await Glow.openDialog({ const result = await Glow.openDialog({
title: `delete ${page.title}?`, title: `Delete ${page.title}?`,
body: "this page will no longer be available.", body: "This page will no longer be available.",
confirmText: "delete page", confirmText: "Delete page",
danger: true danger: true
}); });
if (!result) return; if (!result) return;
try { try {
@ -418,9 +456,10 @@ Glow.deletePage = async page => {
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`, `/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
{ method: "DELETE" } { method: "DELETE" }
); );
Glow.showToast("page deleted.");
Glow.showToast("Page deleted.");
await Glow.openPages(); await Glow.openPages();
} catch (error) { } catch (error) {
Glow.showToast(error.message || "could not delete page.", "error"); Glow.showToast(error.message || "Could not delete page.", "error");
} }
}; };

View File

@ -3,14 +3,11 @@
window.Glow = window.Glow || {}; 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 || { Glow.teamAdapter = Glow.teamAdapter || {
initialized: false, initialized: false,
enabled: false originalLoadRooms: null,
originalUpdateViewUI: null,
chatBarVisibilityInstalled: false
}; };
Glow.loadTeamsForAdapter = async () => { Glow.loadTeamsForAdapter = async () => {
@ -46,7 +43,7 @@ Glow.loadTeamsForAdapter = async () => {
Glow.els.teamSelect.value = Glow.state.activeTeam.slug; Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
} }
} catch (error) { } catch (error) {
console.warn("could not load teams:", error); console.warn("Team adapter could not load teams:", error);
Glow.state.teams = []; Glow.state.teams = [];
Glow.state.activeTeam = null; Glow.state.activeTeam = null;
Glow.els.teamSelect.disabled = true; Glow.els.teamSelect.disabled = true;
@ -72,6 +69,7 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
Glow.state.channels = channels; Glow.state.channels = channels;
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug); const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
if (matchingTeam || data.team) { if (matchingTeam || data.team) {
Glow.state.activeTeam = { Glow.state.activeTeam = {
...(matchingTeam || {}), ...(matchingTeam || {}),
@ -91,21 +89,24 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
const selected = const selected =
channels.find(channel => channel.slug === preferredChannelSlug) || channels.find(channel => channel.slug === preferredChannelSlug) ||
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) || 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.find(channel => channel.slug === "general") ||
channels[0] || channels[0] ||
null; null;
Glow.state.activeChannel = selected; Glow.state.activeChannel = selected;
if (selected) {
Glow.state.activeRoom = selected; Glow.state.activeRoom = selected;
Glow.els.roomSelect.value = selected.slug;
}
Glow.els.roomSelect.disabled = !selected; Glow.els.roomSelect.disabled = !selected;
if (selected) {
Glow.els.roomSelect.value = selected.slug;
}
}; };
Glow.decorateChannelSelector = () => { Glow.decorateChannelSelector = () => {
if (!Glow.els.roomSelect) return; if (!Glow.els.roomSelect) return;
Glow.els.roomSelect.classList.add("channel-select"); Glow.els.roomSelect.classList.add("channel-select");
Glow.els.roomSelect.setAttribute("aria-label", "Select channel"); Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
}; };
@ -113,23 +114,21 @@ Glow.decorateChannelSelector = () => {
Glow.updateTeamChannelHeader = () => { Glow.updateTeamChannelHeader = () => {
const team = Glow.state.activeTeam; const team = Glow.state.activeTeam;
const channel = Glow.state.activeChannel || Glow.state.activeRoom; const channel = Glow.state.activeChannel || Glow.state.activeRoom;
const view = Glow.state.activeView;
const isChannelView =
Glow.state.activeView === "channel" ||
Glow.state.activeView === "room";
if ( if (
view === "dm" || Glow.state.activeView === "dm" ||
view === "dms" || Glow.state.activeView === "dms"
view === "feed" ||
view === "pages" ||
view === "page" ||
view === "profile" ||
view === "settings" ||
view === "thread"
) { ) {
return; return;
} }
if (!(view === "channel" || view === "room")) return; if (!isChannelView || !team || !channel || !Glow.els.headerTitle) {
if (!team || !channel || !Glow.els.headerTitle) return; return;
}
Glow.els.headerTitle.textContent = `# ${channel.name}`; Glow.els.headerTitle.textContent = `# ${channel.name}`;
Glow.els.headerSubtitle.textContent = Glow.els.headerSubtitle.textContent =
@ -531,10 +530,152 @@ Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) =>
Glow.installTeamAdapter = () => { Glow.installTeamAdapter = () => {
if (Glow.teamAdapter.initialized) return; 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.teamAdapter.initialized = true;
Glow.state.teams ||= [];
Glow.state.channels ||= [];
Glow.state.activeTeam ||= null;
Glow.state.activeChannel ||= null;
Glow.decorateChannelSelector(); Glow.decorateChannelSelector();
console.info("team/channel helper mode enabled; runtime monkey-patching disabled.");
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.");
}; };
Glow.installTeamAdapter(); Glow.installTeamAdapter();
if (!Glow.teamAdapter.chatBarVisibilityInstalled) {
Glow.teamAdapter.chatBarVisibilityInstalled = true;
const previousUpdateViewUI = Glow.updateViewUI;
Glow.updateViewUI = () => {
previousUpdateViewUI?.();
Glow.applyComposerVisibility();
};
}
Glow.applyComposerVisibility(); Glow.applyComposerVisibility();

View File

@ -1,6 +1,8 @@
// static/js/08-settings.js // static/js/08-settings.js
"use strict"; "use strict";
window.Glow = window.Glow || {}; window.Glow = window.Glow || {};
Glow.state = Glow.state || {}; Glow.state = Glow.state || {};
Glow.state.settings = Glow.state.settings || null; Glow.state.settings = Glow.state.settings || null;
@ -12,179 +14,46 @@ Glow.applyThemeMode = mode => {
Glow.applyCustomCss = cssText => { Glow.applyCustomCss = cssText => {
let style = document.getElementById("glowCustomCss"); let style = document.getElementById("glowCustomCss");
if (!style) { if (!style) {
style = document.createElement("style"); style = document.createElement("style");
style.id = "glowCustomCss"; style.id = "glowCustomCss";
document.head.append(style); document.head.append(style);
} }
style.textContent = typeof cssText === "string" ? 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 () => { Glow.loadMySettings = async () => {
try { try {
const data = await Glow.apiGetMySettings(); const data = await Glow.apiGetMySettings();
const base = Glow.cloneSettingsState(data.settings || {}); Glow.state.settings = data.settings || {};
Glow.initializeSettingsDraft(base);
Glow.previewSettingsDraft(); 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 || "");
} catch (_) { } catch (_) {
const fallback = Glow.cloneSettingsState({ const mode = localStorage.getItem("glow_theme_mode") || "dark";
theme_mode: localStorage.getItem("glow_theme_mode") || "dark", Glow.applyThemeMode(mode);
accent_color: Glow.state.accentColor,
profile_visibility: "public",
last_active_visibility: "everyone",
presence_mode: "online"
});
Glow.initializeSettingsDraft(fallback);
Glow.previewSettingsDraft();
} }
}; };
Glow.renderHeaderMenu = () => { Glow.renderHeaderMenu = () => {
const list = Glow.els.headerMenuList; const list = Glow.els.headerMenuList;
if (!list) return; if (!list) return;
list.replaceChildren(); list.replaceChildren();
const addItem = ({ label, hint = "", onClick, hidden = false }) => { const addItem = ({ label, hint = "", onClick, hidden = false }) => {
if (hidden) return; if (hidden) return;
const button = document.createElement("button"); const button = document.createElement("button");
button.className = "header-menu-item"; button.className = "header-menu-item";
button.type = "button"; button.type = "button";
@ -200,6 +69,7 @@ Glow.renderHeaderMenu = () => {
Glow.closeHeaderMenu?.(); Glow.closeHeaderMenu?.();
onClick?.(); onClick?.();
}); });
list.append(button); list.append(button);
}; };
@ -310,17 +180,11 @@ Glow.openSettings = async () => {
Glow.state.activeView = "settings"; Glow.state.activeView = "settings";
Glow.updateViewUI(); Glow.updateViewUI();
if (!Glow.state.settingsSaved && Glow.state.currentUser) { if (!Glow.state.settings && Glow.state.currentUser) {
await Glow.loadMySettings(); await Glow.loadMySettings();
} else if (!Glow.state.settingsDraft) {
Glow.initializeSettingsDraft(
Glow.state.settingsSaved || Glow.cloneSettingsState({})
);
Glow.previewSettingsDraft();
} }
Glow.renderSettingsView(); Glow.renderSettingsView();
Glow.syncSettingsSavePanel();
}; };
Glow.renderNameColorPreview = value => { Glow.renderNameColorPreview = value => {
@ -343,174 +207,13 @@ Glow.renderNameColorPreview = value => {
return wrap; 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 = () => { Glow.renderSettingsView = () => {
const box = Glow.els.profileBox; const box = Glow.els.profileBox;
box.replaceChildren(); box.replaceChildren();
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "settings-card"; card.className = "settings-card";
const identity = document.createElement("section");
identity.className = "settings-section";
const iTitle = document.createElement("h2");
iTitle.textContent = "Identity";
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(draft.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const nameColorText = document.createElement("input");
nameColorText.className = "field accent-hex-input";
nameColorText.type = "text";
nameColorText.maxLength = 7;
nameColorText.value =
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(draft.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
nameColorInput.addEventListener("input", () => {
nameColorText.value = nameColorInput.value;
Glow.updateSettingsDraft({
name_color: nameColorInput.value
});
});
nameColorText.addEventListener("input", () => {
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
Glow.updateSettingsDraft({
name_color: normalized || null
});
});
const resetNameColor = document.createElement("button");
resetNameColor.className = "button secondary";
resetNameColor.type = "button";
resetNameColor.textContent = "Use role default";
resetNameColor.addEventListener("click", () => {
Glow.updateSettingsDraft({
name_color: null
});
Glow.renderSettingsView();
});
nameRow.append(nameColorInput, nameColorText, previewSwatch);
nameColorLabel.append(nameRow);
nameColorWrap.append(
nameColorLabel,
Glow.renderNameColorPreview(draft.name_color),
resetNameColor
);
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 appearance = document.createElement("section"); const appearance = document.createElement("section");
appearance.className = "settings-section"; appearance.className = "settings-section";
@ -518,28 +221,37 @@ Glow.renderSettingsView = () => {
aTitle.textContent = "Appearance"; aTitle.textContent = "Appearance";
const aText = document.createElement("p"); const aText = document.createElement("p");
aText.textContent = "Set theme and accent color."; aText.textContent = "Set your theme mode, accent color, username color, and custom CSS.";
const themeRow = document.createElement("div"); const themeRow = document.createElement("div");
themeRow.className = "settings-theme-row"; themeRow.className = "settings-theme-row";
const currentTheme =
Glow.state.settings?.theme_mode ||
localStorage.getItem("glow_theme_mode") ||
"dark";
const darkButton = document.createElement("button"); const darkButton = document.createElement("button");
darkButton.className = `theme-pill${draft.theme_mode !== "light" ? " active" : ""}`; darkButton.className = `theme-pill${currentTheme !== "light" ? " active" : ""}`;
darkButton.type = "button"; darkButton.type = "button";
darkButton.textContent = "Dark"; darkButton.textContent = "Dark";
const lightButton = document.createElement("button"); const lightButton = document.createElement("button");
lightButton.className = `theme-pill${draft.theme_mode === "light" ? " active" : ""}`; lightButton.className = `theme-pill${currentTheme === "light" ? " active" : ""}`;
lightButton.type = "button"; lightButton.type = "button";
lightButton.textContent = "Light"; lightButton.textContent = "Light";
darkButton.addEventListener("click", () => { darkButton.addEventListener("click", () => {
Glow.updateSettingsDraft({ theme_mode: "dark" }); Glow.applyThemeMode("dark");
Glow.state.settings ||= {};
Glow.state.settings.theme_mode = "dark";
Glow.renderSettingsView(); Glow.renderSettingsView();
}); });
lightButton.addEventListener("click", () => { lightButton.addEventListener("click", () => {
Glow.updateSettingsDraft({ theme_mode: "light" }); Glow.applyThemeMode("light");
Glow.state.settings ||= {};
Glow.state.settings.theme_mode = "light";
Glow.renderSettingsView(); Glow.renderSettingsView();
}); });
@ -556,74 +268,97 @@ Glow.renderSettingsView = () => {
accentInput.type = "color"; accentInput.type = "color";
accentInput.className = "accent-color-input"; accentInput.className = "accent-color-input";
accentInput.value = accentInput.value =
Glow.normalizeAccentColor(draft.accent_color) || Glow.normalizeAccentColor(
Glow.DEFAULT_ACCENT_COLOR; Glow.state.settings?.accent_color || Glow.state.accentColor
) || Glow.DEFAULT_ACCENT_COLOR;
accentInput.addEventListener("input", () => { accentInput.addEventListener("input", () => {
Glow.updateSettingsDraft({ Glow.applyAccentColor(accentInput.value);
accent_color: accentInput.value Glow.state.settings ||= {};
}); Glow.state.settings.accent_color = accentInput.value;
}); });
accentLabel.append(accentInput); accentLabel.append(accentInput);
accentWrap.append(accentLabel); accentWrap.append(accentLabel);
appearance.append(aTitle, aText, themeRow, accentWrap); const nameColorWrap = document.createElement("div");
nameColorWrap.className = "settings-row";
const profileVisuals = document.createElement("section"); const nameColorLabel = document.createElement("label");
profileVisuals.className = "settings-section"; nameColorLabel.className = "dialog-label";
nameColorLabel.textContent = "Username color";
const pvTitle = document.createElement("h2"); const nameColorInput = document.createElement("input");
pvTitle.textContent = "Profile visuals"; nameColorInput.type = "color";
nameColorInput.className = "accent-color-input";
nameColorInput.value =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const pvText = document.createElement("p"); const nameColorText = document.createElement("input");
pvText.textContent = "Adjust background and banner colors used in your profile view."; nameColorText.className = "field accent-hex-input";
nameColorText.type = "text";
nameColorText.maxLength = 7;
nameColorText.value =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
"";
nameColorText.placeholder = "#7dd3fc";
const backgroundWrap = document.createElement("div"); const nameRow = document.createElement("div");
backgroundWrap.className = "settings-row"; nameRow.className = "accent-picker-row";
const backgroundLabel = document.createElement("label"); const previewSwatch = document.createElement("span");
backgroundLabel.className = "dialog-label"; previewSwatch.className = "accent-preview";
backgroundLabel.textContent = "Profile background color"; previewSwatch.style.background =
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
const backgroundInput = document.createElement("input"); const help = document.createElement("div");
backgroundInput.type = "color"; help.className = "header-subtitle";
backgroundInput.className = "accent-color-input"; help.textContent =
backgroundInput.value = `Leave empty to use your role default (${Glow.roleDefaultNameColor(Glow.state.currentUser?.role)}).`;
Glow.normalizeAccentColor(draft.profile_background_color) || "#121214";
backgroundInput.addEventListener("input", () => { const updatePreview = value => {
Glow.updateSettingsDraft({ const normalized = Glow.normalizeNameColor(value);
profile_background_color: backgroundInput.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();
}); });
backgroundLabel.append(backgroundInput); nameColorText.addEventListener("input", () => {
backgroundWrap.append(backgroundLabel); const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
Glow.state.settings ||= {};
const bannerWrap = document.createElement("div"); Glow.state.settings.name_color = normalized || null;
bannerWrap.className = "settings-row"; updatePreview(nameColorText.value.trim());
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); const resetNameColor = document.createElement("button");
bannerWrap.append(bannerLabel); resetNameColor.className = "button secondary";
resetNameColor.type = "button";
resetNameColor.textContent = "Use role default";
resetNameColor.addEventListener("click", () => {
Glow.state.settings ||= {};
Glow.state.settings.name_color = null;
Glow.renderSettingsView();
});
profileVisuals.append(pvTitle, pvText, backgroundWrap, bannerWrap); nameRow.append(nameColorInput, nameColorText, previewSwatch);
nameColorLabel.append(nameRow);
nameColorWrap.append(nameColorLabel, help, Glow.renderNameColorPreview(
Glow.state.settings?.name_color
), resetNameColor);
appearance.append(aTitle, aText, themeRow, accentWrap, nameColorWrap);
const cssSection = document.createElement("section"); const cssSection = document.createElement("section");
cssSection.className = "settings-section"; cssSection.className = "settings-section";
@ -633,16 +368,17 @@ Glow.renderSettingsView = () => {
const cText = document.createElement("p"); const cText = document.createElement("p");
cText.textContent = cText.textContent =
"Optional CSS applied locally for preview and saved to your account when supported."; "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;}";
const cssInput = document.createElement("textarea"); const cssInput = document.createElement("textarea");
cssInput.className = "field settings-custom-css"; cssInput.className = "field settings-custom-css";
cssInput.placeholder = "/* Custom CSS */"; cssInput.placeholder = "/* Custom CSS */";
cssInput.value = draft.custom_css || ""; cssInput.value = Glow.state.settings?.custom_css || "";
cssInput.addEventListener("input", () => { cssInput.addEventListener("input", () => {
Glow.updateSettingsDraft({ Glow.applyCustomCss(cssInput.value);
custom_css: cssInput.value Glow.state.settings ||= {};
}); Glow.state.settings.custom_css = cssInput.value;
}); });
cssSection.append(cTitle, cText, cssInput); cssSection.append(cTitle, cText, cssInput);
@ -654,7 +390,7 @@ Glow.renderSettingsView = () => {
accountTitle.textContent = "Account"; accountTitle.textContent = "Account";
const accountText = document.createElement("p"); const accountText = document.createElement("p");
accountText.textContent = "Open your profile editor or reset visual customizations."; accountText.textContent = "Update your public profile and application preferences.";
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "settings-actions"; actions.className = "settings-actions";
@ -667,57 +403,49 @@ Glow.renderSettingsView = () => {
Glow.openMyProfile?.(true); 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"
};
const data = await Glow.apiUpdateMySettings(payload);
Glow.state.settings = data.settings || payload;
Glow.showToast("Settings saved.");
Glow.renderSettingsView();
} catch (error) {
Glow.showToast(error.message || "Could not save settings.", "error");
}
});
const resetAccent = document.createElement("button"); const resetAccent = document.createElement("button");
resetAccent.className = "button secondary"; resetAccent.className = "button secondary";
resetAccent.type = "button"; resetAccent.type = "button";
resetAccent.textContent = "Reset accent"; resetAccent.textContent = "Reset accent";
resetAccent.addEventListener("click", () => { resetAccent.addEventListener("click", () => {
Glow.updateSettingsDraft({ Glow.resetAccentColor();
accent_color: Glow.DEFAULT_ACCENT_COLOR Glow.state.settings ||= {};
}); Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR;
Glow.renderSettingsView(); Glow.renderSettingsView();
}); });
const discard = document.createElement("button"); actions.append(editProfile, saveSettings, resetAccent);
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); accountSection.append(accountTitle, accountText, actions);
card.append(identity, appearance, profileVisuals, cssSection, accountSection); card.append(appearance, cssSection, accountSection);
box.append(card); 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

@ -1,5 +1,6 @@
// static/js/09-profile.js // static/js/09-profile.js
"use strict"; "use strict";
window.Glow = window.Glow || {}; window.Glow = window.Glow || {};
Glow.escapeHtml = value => Glow.escapeHtml = value =>
@ -12,153 +13,29 @@ Glow.escapeHtml = value =>
Glow.renderProfileBody = profile => { Glow.renderProfileBody = profile => {
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "profile-rendered"; wrap.className = "profile-rendered";
const content = String(profile?.bio_html || "").trim(); const content = String(profile?.bio_html || "").trim();
if (!content) { if (!content) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "profile-empty"; empty.className = "profile-empty";
empty.textContent = "No profile bio yet."; empty.textContent = "No profile bio yet.";
return empty; return empty;
} }
wrap.innerHTML = content; wrap.innerHTML = content;
return wrap; 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.openProfile = async username => {
Glow.state.activeView = "profile"; Glow.state.activeView = "profile";
Glow.state.activeProfile = null; Glow.state.activeProfile = null;
Glow.updateViewUI(); Glow.updateViewUI();
const box = Glow.els.profileBox; const box = Glow.els.profileBox;
box.setAttribute("aria-busy", "true"); box.setAttribute("aria-busy", "true");
box.replaceChildren(); box.replaceChildren();
try { try {
const data = await Glow.apiGetPublicProfile(username); const data = await Glow.apiGetPublicProfile(username);
Glow.state.activeProfile = data.profile; Glow.state.activeProfile = data.profile;
@ -167,11 +44,14 @@ Glow.openProfile = async username => {
box.replaceChildren(); box.replaceChildren();
const state = document.createElement("div"); const state = document.createElement("div");
state.className = "empty-state"; state.className = "empty-state";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "empty-card"; card.className = "empty-card";
card.textContent = error.message || "Could not load profile."; card.textContent = error.message || "Could not load profile.";
state.append(card); state.append(card);
box.append(state); box.append(state);
Glow.showToast(error.message || "Could not load profile.", "error"); Glow.showToast(error.message || "Could not load profile.", "error");
} finally { } finally {
box.setAttribute("aria-busy", "false"); box.setAttribute("aria-busy", "false");
@ -183,12 +63,15 @@ Glow.openMyProfile = async (edit = false) => {
Glow.showToast("Sign in to view your profile.", "error"); Glow.showToast("Sign in to view your profile.", "error");
return; return;
} }
Glow.state.activeView = "profile"; Glow.state.activeView = "profile";
Glow.state.activeProfile = null; Glow.state.activeProfile = null;
Glow.updateViewUI(); Glow.updateViewUI();
const box = Glow.els.profileBox; const box = Glow.els.profileBox;
box.setAttribute("aria-busy", "true"); box.setAttribute("aria-busy", "true");
box.replaceChildren(); box.replaceChildren();
try { try {
const data = await Glow.apiGetMyProfile(); const data = await Glow.apiGetMyProfile();
Glow.state.activeProfile = data.profile; Glow.state.activeProfile = data.profile;
@ -200,61 +83,30 @@ Glow.openMyProfile = async (edit = false) => {
} }
}; };
Glow.uploadBannerForProfile = async file => {
const result = await Glow.uploadAttachment(file);
return result?.attachment || null;
};
Glow.renderProfileView = (editing = false) => { Glow.renderProfileView = (editing = false) => {
const box = Glow.els.profileBox; const box = Glow.els.profileBox;
const profile = Glow.state.activeProfile; const profile = Glow.state.activeProfile;
box.replaceChildren(); box.replaceChildren();
if (!profile) { if (!profile) {
const state = document.createElement("div"); const state = document.createElement("div");
state.className = "empty-state"; state.className = "empty-state";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "empty-card"; card.className = "empty-card";
card.textContent = "Profile not found."; card.textContent = "Profile not found.";
state.append(card); state.append(card);
box.append(state); box.append(state);
return; return;
} }
box.style.background =
profile.profile_background_color || "transparent";
const card = document.createElement("div"); const card = document.createElement("div");
card.className = "profile-card"; card.className = "profile-card";
const banner = document.createElement(profile.is_self ? "button" : "div"); const banner = document.createElement("div");
banner.className = "profile-banner"; 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}`;
}
if (profile.banner_image_url) {
banner.style.backgroundImage =
`linear-gradient(135deg, rgba(0,0,0,0.18), rgba(0,0,0,0.04)), url("${profile.banner_image_url}")`;
banner.style.backgroundSize = "cover";
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"); const content = document.createElement("div");
content.className = "profile-content"; content.className = "profile-content";
@ -262,23 +114,15 @@ Glow.renderProfileView = (editing = false) => {
const header = document.createElement("div"); const header = document.createElement("div");
header.className = "profile-header"; header.className = "profile-header";
const avatar = document.createElement(profile.is_self ? "button" : "div"); const avatar = document.createElement("div");
avatar.className = "profile-avatar"; avatar.className = "profile-avatar";
avatar.textContent = (profile.display_name || profile.username || "?") avatar.textContent = (profile.display_name || profile.username || "?")
.slice(0, 1) .slice(0, 1)
.toUpperCase(); .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"); const actions = document.createElement("div");
actions.className = "profile-actions"; actions.className = "profile-actions";
if (profile.is_self) { if (profile.is_self) {
const editButton = document.createElement("button"); const editButton = document.createElement("button");
editButton.className = "button"; editButton.className = "button";
@ -295,8 +139,10 @@ Glow.renderProfileView = (editing = false) => {
settingsButton.addEventListener("click", () => { settingsButton.addEventListener("click", () => {
Glow.openSettings?.(); Glow.openSettings?.();
}); });
actions.append(editButton, settingsButton); actions.append(editButton, settingsButton);
} }
header.append(avatar, actions); header.append(avatar, actions);
const name = document.createElement("div"); const name = document.createElement("div");
@ -319,23 +165,15 @@ Glow.renderProfileView = (editing = false) => {
badges.className = "profile-badges"; badges.className = "profile-badges";
const roleBadge = document.createElement("span"); const roleBadge = document.createElement("span");
roleBadge.className = `profile-badge role role-${String(profile.role || "user").toLowerCase()}`; roleBadge.className = "profile-badge";
roleBadge.textContent = profile.role; roleBadge.textContent = profile.role;
const statusBadge = document.createElement("span"); const statusBadge = document.createElement("span");
statusBadge.className = `profile-badge status status-${String(profile.status || "active").toLowerCase()}`; statusBadge.className = "profile-badge";
statusBadge.textContent = profile.status; statusBadge.textContent = profile.status;
badges.append(roleBadge, statusBadge); badges.append(roleBadge, statusBadge);
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"); const grid = document.createElement("div");
grid.className = "profile-grid"; grid.className = "profile-grid";
@ -344,67 +182,46 @@ Glow.renderProfileView = (editing = false) => {
} else { } else {
const bioSection = document.createElement("section"); const bioSection = document.createElement("section");
bioSection.className = "profile-section"; bioSection.className = "profile-section";
const bioTitle = document.createElement("h3"); const bioTitle = document.createElement("h3");
bioTitle.textContent = "Bio"; bioTitle.textContent = "Bio";
bioSection.append(bioTitle, Glow.renderProfileBody(profile)); bioSection.append(bioTitle, Glow.renderProfileBody(profile));
const metaSection = document.createElement("section"); const metaSection = document.createElement("section");
metaSection.className = "profile-section"; metaSection.className = "profile-section";
const metaTitle = document.createElement("h3"); const metaTitle = document.createElement("h3");
metaTitle.textContent = "Profile"; metaTitle.textContent = "Profile";
const metaList = document.createElement("dl"); const metaList = document.createElement("dl");
metaList.className = "profile-meta-list"; metaList.className = "profile-meta-list";
const addMeta = (label, value) => { const addMeta = (label, value) => {
if (!value) return;
const dt = document.createElement("dt"); const dt = document.createElement("dt");
dt.textContent = label; dt.textContent = label;
const dd = document.createElement("dd"); const dd = document.createElement("dd");
dd.textContent = value; dd.textContent = value || "—";
metaList.append(dt, dd); metaList.append(dt, dd);
}; };
addMeta("Joined", profile.created_at addMeta("Display name", profile.display_name || profile.username);
? new Date(profile.created_at).toLocaleDateString() addMeta("Website", profile.website || "");
: "");
addMeta("Location", profile.location || ""); addMeta("Location", profile.location || "");
addMeta(
"Joined",
profile.created_at ? new Date(profile.created_at).toLocaleDateString() : ""
);
addMeta("Bio mode", profile.bio_mode || "txt");
addMeta("Name color", profile.name_color || "role default");
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); metaSection.append(metaTitle, metaList);
}
grid.append(bioSection, metaSection); grid.append(bioSection, metaSection);
} }
content.append(header, name, handle); content.append(header, name, handle, badges, grid);
if (profile.status_message) {
const statusMessage = document.createElement("div");
statusMessage.className = "profile-status-message";
statusMessage.textContent = profile.status_message;
content.append(statusMessage);
}
content.append(badges, grid);
card.append(banner, content); card.append(banner, content);
box.append(card); box.append(card);
}; };
@ -412,46 +229,13 @@ Glow.renderProfileView = (editing = false) => {
Glow.buildProfileEditor = profile => { Glow.buildProfileEditor = profile => {
const section = document.createElement("section"); const section = document.createElement("section");
section.className = "profile-section"; section.className = "profile-section";
const title = document.createElement("h3"); const title = document.createElement("h3");
title.textContent = "Edit profile"; title.textContent = "Edit profile";
const editor = document.createElement("div"); const editor = document.createElement("div");
editor.className = "profile-editor"; 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"); const displayName = document.createElement("input");
displayName.className = "field"; displayName.className = "field";
displayName.type = "text"; displayName.type = "text";
@ -459,13 +243,6 @@ Glow.buildProfileEditor = profile => {
displayName.placeholder = "Display name"; displayName.placeholder = "Display name";
displayName.value = profile.display_name || ""; displayName.value = profile.display_name || "";
const statusMessage = document.createElement("input");
statusMessage.className = "field";
statusMessage.type = "text";
statusMessage.maxLength = 160;
statusMessage.placeholder = "Status message";
statusMessage.value = profile.status_message || "";
const website = document.createElement("input"); const website = document.createElement("input");
website.className = "field"; website.className = "field";
website.type = "url"; website.type = "url";
@ -482,6 +259,7 @@ Glow.buildProfileEditor = profile => {
const modeRow = document.createElement("div"); const modeRow = document.createElement("div");
modeRow.className = "profile-mode-row"; modeRow.className = "profile-mode-row";
let selectedMode = profile.bio_mode || "txt"; let selectedMode = profile.bio_mode || "txt";
const body = document.createElement("textarea"); const body = document.createElement("textarea");
@ -495,12 +273,14 @@ Glow.buildProfileEditor = profile => {
button.className = `mode-pill${selectedMode === mode ? " active" : ""}`; button.className = `mode-pill${selectedMode === mode ? " active" : ""}`;
button.type = "button"; button.type = "button";
button.textContent = mode.toUpperCase(); button.textContent = mode.toUpperCase();
button.addEventListener("click", () => { button.addEventListener("click", () => {
selectedMode = mode; selectedMode = mode;
for (const el of modeRow.querySelectorAll(".mode-pill")) { for (const el of modeRow.querySelectorAll(".mode-pill")) {
el.classList.toggle("active", el.textContent.toLowerCase() === mode); el.classList.toggle("active", el.textContent.toLowerCase() === mode);
} }
}); });
modeRow.append(button); modeRow.append(button);
} }
@ -515,34 +295,20 @@ Glow.buildProfileEditor = profile => {
const save = document.createElement("button"); const save = document.createElement("button");
save.className = "button"; save.className = "button";
save.type = "button"; save.type = "button";
save.textContent = "Save profile changes"; save.textContent = "Save profile";
save.addEventListener("click", async () => { save.addEventListener("click", async () => {
try { try {
let bannerAttachmentId = profile.banner_attachment_id || null;
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
if (bannerFile) {
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
}
const data = await Glow.apiUpdateMyProfile({ const data = await Glow.apiUpdateMyProfile({
display_name: displayName.value, display_name: displayName.value,
status_message: statusMessage.value,
website: website.value, website: website.value,
location: location.value, location: location.value,
bio: body.value, bio: body.value,
bio_mode: selectedMode, bio_mode: selectedMode
banner_attachment_id: bannerAttachmentId
}); });
Glow.state.profileMediaDraft.avatarFile = null;
Glow.state.profileMediaDraft.bannerFile = null;
Glow.state.activeProfile = { Glow.state.activeProfile = {
...data.profile, ...data.profile,
name_color: Glow.state.settings?.name_color || profile.name_color || null, name_color: Glow.state.settings?.name_color || profile.name_color || null
profile_background_color: Glow.state.settings?.profile_background_color || null,
profile_banner_color: Glow.state.settings?.profile_banner_color || null
}; };
Glow.showToast("Profile updated."); Glow.showToast("Profile updated.");
Glow.renderProfileView(false); Glow.renderProfileView(false);
@ -551,83 +317,33 @@ Glow.buildProfileEditor = profile => {
} }
}); });
const apply = document.createElement("button"); const preview = document.createElement("button");
apply.className = "button secondary"; preview.className = "button secondary";
apply.type = "button"; preview.type = "button";
apply.textContent = "Apply changes"; preview.textContent = "Preview";
apply.addEventListener("click", async () => { preview.addEventListener("click", async () => {
try { try {
let bannerAttachmentId = profile.banner_attachment_id || null;
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
if (bannerFile) {
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
}
const data = await Glow.apiUpdateMyProfile({ const data = await Glow.apiUpdateMyProfile({
display_name: displayName.value, display_name: displayName.value,
status_message: statusMessage.value,
website: website.value, website: website.value,
location: location.value, location: location.value,
bio: body.value, bio: body.value,
bio_mode: selectedMode, bio_mode: selectedMode
banner_attachment_id: bannerAttachmentId
}); });
Glow.state.activeProfile = { Glow.state.activeProfile = {
...data.profile, ...data.profile,
name_color: Glow.state.settings?.name_color || profile.name_color || null, name_color: Glow.state.settings?.name_color || profile.name_color || null
profile_background_color: Glow.state.settings?.profile_background_color || null,
profile_banner_color: Glow.state.settings?.profile_banner_color || null
}; };
Glow.renderProfileView(false); Glow.renderProfileView(false);
} catch (error) { } catch (error) {
Glow.showToast(error.message || "Could not apply profile changes.", "error"); Glow.showToast(error.message || "Could not preview profile.", "error");
} }
}); });
actions.append(save, apply); actions.append(save, preview);
editor.append( editor.append(displayName, website, location, modeRow, help, body, actions);
uploadInfo,
displayName,
statusMessage,
website,
location,
modeRow,
help,
body,
actions
);
section.append(title, editor); section.append(title, editor);
return section; 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,13 +5,8 @@ window.Glow = window.Glow || {};
Glow.reactions = Glow.reactions || { Glow.reactions = Glow.reactions || {
enabled: true, enabled: true,
emojis: [ emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"],
"👍", "👎", "❤️", "🔥", "😂", "😄", "🙂", "😭", activeMessage: null
"👏", "🙌", "🎉", "✨", "💯", "✅", "👀", "🤔",
"🙏", "💡", "⭐", "🚀", "😮", "😢", "😡", "👋"
],
activeMessage: null,
selectedIndex: 0
}; };
Glow.submitCustomReaction = async () => { Glow.submitCustomReaction = async () => {
@ -46,21 +41,25 @@ Glow.submitCustomReaction = async () => {
} }
}; };
Glow.highlightReactionSelection = () => { Glow.openReactionPopup = async message => {
Glow.reactions.activeMessage = message;
const grid = Glow.els.reactionPopupGrid; const grid = Glow.els.reactionPopupGrid;
if (!grid) return; const list = Glow.els.reactionPopupList;
const customInput = Glow.els.reactionCustomInput;
const buttons = [...grid.querySelectorAll(".reaction-popup-button")]; if (!grid || !list) return;
buttons.forEach((button, index) => {
button.classList.toggle("active", index === Glow.reactions.selectedIndex);
});
};
Glow.activateReactionAtIndex = async index => { grid.replaceChildren();
const message = Glow.reactions.activeMessage; list.replaceChildren();
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 { try {
const existing = (message.reactions || []).find(item => item.emoji === emoji); const existing = (message.reactions || []).find(item => item.emoji === emoji);
@ -73,8 +72,8 @@ Glow.activateReactionAtIndex = async index => {
const data = await Glow.apiGetMessageReactions(message.id); const data = await Glow.apiGetMessageReactions(message.id);
message.reactions = data.reactions || []; message.reactions = data.reactions || [];
Glow.updateMessageReactionsDom?.(message.id, message.reactions); Glow.updateMessageReactionsDom?.(message.id, message.reactions);
Glow.renderReactionPopupUsers(message.reactions); Glow.renderReactionPopupUsers(message.reactions);
Glow.highlightReactionSelection();
if (Glow.state.activeView === "feed") { if (Glow.state.activeView === "feed") {
Glow.loadFeed?.(); Glow.loadFeed?.();
@ -82,36 +81,10 @@ Glow.activateReactionAtIndex = async index => {
} catch (error) { } catch (error) {
Glow.showToast(error.message || "Could not update reaction.", "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); grid.append(button);
}); }
try { try {
const data = await Glow.apiGetMessageReactions(message.id); const data = await Glow.apiGetMessageReactions(message.id);
@ -126,26 +99,12 @@ Glow.openReactionPopup = async message => {
Glow.renderReactionPopupUsers(message.reactions || []); Glow.renderReactionPopupUsers(message.reactions || []);
Glow.els.reactionPopupOverlay?.classList.remove("hidden"); Glow.els.reactionPopupOverlay?.classList.remove("hidden");
Glow.highlightReactionSelection();
setTimeout(() => { setTimeout(() => {
const first = grid.querySelector(".reaction-popup-button"); Glow.els.reactionCustomInput?.focus();
first?.focus();
}, 0); }, 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 => { Glow.renderReactionPopupUsers = reactions => {
const list = Glow.els.reactionPopupList; const list = Glow.els.reactionPopupList;
if (!list) return; if (!list) return;
@ -169,12 +128,19 @@ Glow.renderReactionPopupUsers = reactions => {
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`; title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
section.append(title); section.append(title);
const names = document.createElement("div"); for (const user of reaction.users || []) {
names.className = "reaction-popup-user"; const row = document.createElement("div");
names.textContent = (reaction.users || []) row.className = "reaction-popup-user";
.map(user => user.username)
.join(", "); const left = document.createElement("span");
section.append(names); left.textContent = user.username;
const right = document.createElement("span");
right.textContent = new Date(user.created_at).toLocaleString();
row.append(left, right);
section.append(row);
}
list.append(section); list.append(section);
} }
@ -182,7 +148,6 @@ Glow.renderReactionPopupUsers = reactions => {
Glow.closeReactionPopup = () => { Glow.closeReactionPopup = () => {
Glow.reactions.activeMessage = null; Glow.reactions.activeMessage = null;
Glow.reactions.selectedIndex = 0;
if (Glow.els.reactionCustomInput) { if (Glow.els.reactionCustomInput) {
Glow.els.reactionCustomInput.value = ""; Glow.els.reactionCustomInput.value = "";
} }
@ -198,49 +163,4 @@ Glow.els.reactionCustomInput?.addEventListener("keydown", event => {
event.preventDefault(); event.preventDefault();
Glow.submitCustomReaction?.(); 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,17 +34,15 @@ Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => {
Glow.resetMediaModal(); Glow.resetMediaModal();
const mime = String(mimeType || "").toLowerCase(); const mime = String(mimeType || "").toLowerCase();
const compactName = String(name || "Media").trim() || "Media";
Glow.els.mediaTitle.textContent = compactName; Glow.els.mediaTitle.textContent = name;
Glow.els.mediaTitle.title = compactName;
Glow.els.mediaDownloadLink.href = url; Glow.els.mediaDownloadLink.href = url;
Glow.els.mediaDownloadLink.download = compactName; Glow.els.mediaDownloadLink.download = name;
Glow.els.mediaFallbackLink.href = url; Glow.els.mediaFallbackLink.href = url;
if (mime.startsWith("image/")) { if (mime.startsWith("image/")) {
Glow.els.mediaImage.src = url; Glow.els.mediaImage.src = url;
Glow.els.mediaImage.alt = compactName; Glow.els.mediaImage.alt = name;
Glow.els.mediaImage.classList.remove("hidden"); Glow.els.mediaImage.classList.remove("hidden");
} else if (mime.startsWith("audio/")) { } else if (mime.startsWith("audio/")) {
Glow.els.mediaAudio.src = url; 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

View File

@ -1,17 +0,0 @@
<!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

@ -33,7 +33,6 @@
</div> </div>
<div class="header-navigation"> <div class="header-navigation">
<div class="header-nav-group">
<button <button
id="feedButton" id="feedButton"
class="header-action feed-button active" class="header-action feed-button active"
@ -82,16 +81,14 @@
> >
Back Back
</button> </button>
</div>
<div class="header-context-group">
<select <select
id="teamSelect" id="teamSelect"
class="room-select team-select" class="room-select team-select"
aria-label="Select team" aria-label="Select team"
disabled disabled
> >
<option value="">Loading teams...</option> <option value="">Loading teams…</option>
</select> </select>
<select <select
@ -100,11 +97,9 @@
aria-label="Select channel" aria-label="Select channel"
disabled disabled
> >
<option value="">Loading channels...</option> <option value="">Loading channels…</option>
</select> </select>
</div>
<div class="header-utility-group">
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div> <div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
<button <button
@ -118,17 +113,6 @@
<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"/> <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> </svg>
</button> </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>
<div class="header-account-wrap"> <div class="header-account-wrap">
@ -157,7 +141,7 @@
aria-label="Activity feed" aria-label="Activity feed"
> >
<div class="empty-state"> <div class="empty-state">
<div class="empty-card">Loading activity...</div> <div class="empty-card">Loading activity…</div>
</div> </div>
</main> </main>
@ -223,10 +207,9 @@
<div id="composerWrap" class="composer-wrap hidden"> <div id="composerWrap" class="composer-wrap hidden">
<form id="chatForm" class="composer"> <form id="chatForm" class="composer">
<div class="composer-stack">
<div class="composer-shell">
<label class="sr-only" for="messageInput">Message</label> <label class="sr-only" for="messageInput">Message</label>
<div class="composer-field-wrap">
<textarea <textarea
id="messageInput" id="messageInput"
class="composer-input" class="composer-input"
@ -238,7 +221,12 @@
disabled disabled
></textarea> ></textarea>
<div class="composer-shell-actions"> <div class="composer-meta">
<span id="attachmentName" class="attachment-name"></span>
<span id="messageCount" class="message-count">0 / 1000</span>
</div>
</div>
<label <label
id="attachmentButton" id="attachmentButton"
class="attachment-button disabled" class="attachment-button disabled"
@ -249,20 +237,6 @@
+ +
</label> </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 <input
id="attachmentInput" id="attachmentInput"
class="sr-only" class="sr-only"
@ -271,15 +245,18 @@
multiple multiple
disabled disabled
> >
<button id="sendButton" class="send-button" type="submit" disabled>
Send
</button>
</form> </form>
</div> </div>
<div id="threadComposerWrap" class="composer-wrap hidden"> <div id="threadComposerWrap" class="composer-wrap hidden">
<form id="threadForm" class="composer"> <form id="threadForm" class="composer">
<div class="composer-stack">
<div class="composer-shell">
<label class="sr-only" for="threadInput">Reply in thread</label> <label class="sr-only" for="threadInput">Reply in thread</label>
<div class="composer-field-wrap">
<textarea <textarea
id="threadInput" id="threadInput"
class="composer-input" class="composer-input"
@ -291,7 +268,12 @@
disabled disabled
></textarea> ></textarea>
<div class="composer-shell-actions"> <div class="composer-meta">
<span id="threadAttachmentName" class="attachment-name"></span>
<span id="threadMessageCount" class="message-count">0 / 1000</span>
</div>
</div>
<label <label
id="threadAttachmentButton" id="threadAttachmentButton"
class="attachment-button disabled" class="attachment-button disabled"
@ -302,20 +284,6 @@
+ +
</label> </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 <input
id="threadAttachmentInput" id="threadAttachmentInput"
class="sr-only" class="sr-only"
@ -324,24 +292,14 @@
multiple multiple
disabled disabled
> >
<button id="threadSendButton" class="send-button" type="submit" disabled>
Reply
</button>
</form> </form>
</div> </div>
</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 <section
id="authOverlay" id="authOverlay"
class="auth-overlay" class="auth-overlay"
@ -363,7 +321,7 @@
autocapitalize="none" autocapitalize="none"
spellcheck="false" spellcheck="false"
maxlength="256" maxlength="256"
placeholder="access key" placeholder="private access key"
> >
<label class="sr-only" for="usernameInput">Username</label> <label class="sr-only" for="usernameInput">Username</label>
@ -406,7 +364,7 @@
> >
<div class="auth-card"> <div class="auth-card">
<h1>Access blocked</h1> <h1>Access blocked</h1>
<p id="blockedMessage">Access is unavailable.</p> <p id="blockedMessage">Access to this chat service is unavailable.</p>
</div> </div>
</section> </section>
@ -431,10 +389,7 @@
aria-label="Reactions" aria-label="Reactions"
> >
<div class="reaction-popup-card"> <div class="reaction-popup-card">
<div class="reaction-popup-header">
<h2 class="reaction-popup-title">Reactions</h2> <h2 class="reaction-popup-title">Reactions</h2>
</div>
<div id="reactionPopupGrid" class="reaction-popup-grid"></div> <div id="reactionPopupGrid" class="reaction-popup-grid"></div>
<div class="reaction-popup-custom"> <div class="reaction-popup-custom">
@ -471,15 +426,14 @@
aria-label="Media preview" aria-label="Media preview"
> >
<div class="media-card"> <div class="media-card">
<div class="media-header minimal"> <div class="media-header">
<div id="mediaTitle" class="media-title">Media</div> <div id="mediaTitle" class="media-title">Media</div>
<div class="settings-actions"> <div class="settings-actions">
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a> <a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
<button id="closeMediaButton" class="button secondary" type="button">Close</button> <button id="closeMediaButton" class="button secondary" type="button">Close</button>
</div> </div>
</div> </div>
<div class="media-body">
<div class="media-body spacious">
<img id="mediaImage" class="hidden" alt=""> <img id="mediaImage" class="hidden" alt="">
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio> <audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
<video id="mediaVideo" class="hidden" controls preload="metadata"></video> <video id="mediaVideo" class="hidden" controls preload="metadata"></video>
@ -491,58 +445,17 @@
</div> </div>
</section> </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 <section
id="callOverlay" id="callOverlay"
class="call-overlay hidden" class="call-overlay hidden"
role="dialog" role="dialog"
aria-modal="false" aria-modal="true"
aria-label="Voice call" aria-label="Voice call"
> >
<div class="call-card utility-panel"> <div class="call-card">
<div id="callDragHandle" class="call-drag-handle" aria-hidden="true"></div>
<div class="call-title-row"> <div class="call-title-row">
<div> <div>
<div class="call-title">call</div> <div class="call-title">Voice call</div>
<div id="callStatus" class="call-status">Idle</div> <div id="callStatus" class="call-status">Idle</div>
</div> </div>
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button> <button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
@ -550,40 +463,13 @@
<div id="callPeerLabel" class="call-peer-label">No active call</div> <div id="callPeerLabel" class="call-peer-label">No active call</div>
<div id="callSummaryStats" class="call-summary-stats"> <audio id="remoteAudio" autoplay playsinline></audio>
<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"> <div class="call-actions">
<button id="startCallButton" class="button" type="button">Start call</button> <button id="startCallButton" class="button" type="button">Start call</button>
<button id="retryCallButton" class="button secondary hidden" type="button">Retry connection</button> <button id="answerCallButton" class="button hidden" type="button">Answer</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="muteCallButton" class="button secondary hidden" type="button">Mute</button>
<button id="hangupCallButton" class="danger-button hidden" type="button">Leave call</button> <button id="hangupCallButton" class="danger-button hidden" type="button">Hang up</button>
<button id="endCallButton" class="danger-button hidden" type="button">End call</button>
</div> </div>
</div> </div>
</section> </section>
@ -621,52 +507,14 @@
<div class="admin-actions"> <div class="admin-actions">
<button id="createRoomButton" class="button" type="button">Add chat</button> <button id="createRoomButton" class="button" type="button">Add chat</button>
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</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>
<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 id="adminRooms" class="admin-rooms"></div>
</div>
<div id="adminUsersPanel" class="admin-tab-panel hidden">
<div id="adminUsers" class="admin-users"></div> <div id="adminUsers" class="admin-users"></div>
</div> </div>
<div id="adminTeamsPanel" class="admin-tab-panel hidden">
<div id="adminTeamsChannels" class="admin-rooms"></div>
</div>
</div>
</div>
</section> </section>
<section <section
@ -683,23 +531,6 @@
</div> </div>
</section> </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> <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/00-config.js', v=asset_version) }}" defer></script>