Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b5434e31ba | |||
| b27f2013fd | |||
| bee7213430 | |||
| 2ffc414444 | |||
| 0c0770ab57 | |||
| d9d3ed0131 | |||
| 5a27857303 | |||
| b488bdd080 | |||
| 0256d10718 | |||
| 216ca382b3 | |||
| 2ef3309fb8 | |||
| b84214011a | |||
| 35ef91db09 | |||
| 4becc9d790 | |||
| daa3ac6948 | |||
| d60d1909f4 | |||
| eb5ba57785 | |||
| 8529ae06e1 | |||
| 1945329f40 | |||
| cef6288cfd | |||
| 7b39b8957d | |||
| 48a035401a | |||
| 20af884e7e | |||
| 13b3564b84 | |||
| f08e24696c | |||
| 86b0a7a5ab | |||
| c0a58bbde8 | |||
| deba4b4cfd | |||
| 35b6dc53d9 | |||
| 463a3a1fcb | |||
| 842f97428d | |||
| 513ea3e577 | |||
| 98997e26ca | |||
| 49494e5399 | |||
| c04b19602e | |||
| e65ccce19c | |||
| 301260caa5 | |||
| e2373bcb64 | |||
| 0a31e32f1e | |||
| 6aeac4659c | |||
| 36482c425a | |||
| c4dfbfca71 | |||
| c5ba80129e | |||
| f684f6ff69 | |||
| d6e2ebf750 | |||
| 8e93be2307 | |||
| cb078eacfd | |||
| 368fd18aa7 | |||
| 9e0b24e29a | |||
| ead6a4f709 | |||
| b85df7fdbd | |||
| 6063da595c | |||
| 6b5cd404df | |||
| 80a5dc5c15 | |||
| 1cbdae8717 | |||
| 502836153d | |||
| 952c755e1d | |||
| 2cd955f835 | |||
| 39c4067305 | |||
| 612c64b4d2 | |||
| c1fdaa73e6 | |||
| e77b7fe2e5 | |||
| e1bb8eff25 | |||
| a4ff63e89a | |||
| de82a88091 | |||
| 0f0698f443 | |||
| 832acd7cde | |||
| 3259ceffba | |||
| 559161cd65 | |||
| f7404645e6 | |||
| c8e0501d3c | |||
| cbf6a0498b | |||
| 9dc2bc6311 | |||
| c4167ded8f | |||
| b738a6ed18 | |||
| 746d655b73 | |||
| 2baa0d8d4a | |||
| 0d8bc7a7cb | |||
| a841e8a0ee | |||
| 5b3c4c3c55 | |||
| fc4f7472d1 | |||
| 6196186b22 | |||
| defec49d2c | |||
| a7776b9923 | |||
| 3e2f679000 | |||
| 4084d56b61 | |||
| b99fe06a80 | |||
| e67dc50105 | |||
| 02ecabf0ea | |||
| 11b6848099 | |||
| a648fcdcbb | |||
| 8a3358aec7 | |||
| 18f2670519 | |||
| 6098483251 | |||
| 869b2857e4 | |||
| 8a2ee3af05 | |||
| aa702bb2a9 | |||
| 07f119ee3e | |||
| df43115fe4 | |||
| 7a1691b781 | |||
| 555799bb3d | |||
| 359a8962c3 | |||
| d4c4d7c3e5 | |||
| e91216d3a7 | |||
| 0cbd50f97c | |||
| d5e044d728 | |||
| 21f1dc81ba | |||
| 1832611ff0 | |||
| 3c0fc02829 | |||
| 2c3cd41732 | |||
| 21d9b6fd15 | |||
| cce908096c | |||
| 8c3b68bc89 | |||
| 3381f36412 | |||
| 08b634cafd | |||
| de64a30bd7 | |||
| 3a15c0645a | |||
| b33f8527ac | |||
| 4fac502e7c | |||
| eef4b1f5d7 | |||
| 09a7dd353d | |||
| ca7eae57d2 | |||
| f0d7f26c63 | |||
| 73f10ab2cb | |||
| 1efe0817b2 | |||
| 33eac1c48d | |||
| b880591651 | |||
| d7fedb6295 | |||
| 0b45bd657c | |||
| 627a08ced6 | |||
| fd808d681b | |||
| d2792f32ec | |||
| 78bc89113d | |||
| 5abeb8316d |
@ -1,3 +1,4 @@
|
||||
DOMAIN_URL=
|
||||
ADMIN_USERNAME=
|
||||
ADMIN_PASSWORD=
|
||||
PRIVATE_ACCESS_KEY=333
|
||||
|
||||
335
attachments.py
Normal file
335
attachments.py
Normal file
@ -0,0 +1,335 @@
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from flask import abort, jsonify, send_from_directory
|
||||
|
||||
from db import get_db
|
||||
|
||||
|
||||
IMAGE_MIME_PREFIX = "image/"
|
||||
AUDIO_MIME_PREFIX = "audio/"
|
||||
VIDEO_MIME_PREFIX = "video/"
|
||||
|
||||
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"}
|
||||
VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"}
|
||||
|
||||
|
||||
def attachment_kind_from_name_and_mime(
|
||||
original_name: Optional[str],
|
||||
mime_type: Optional[str],
|
||||
) -> str:
|
||||
mime_type = str(mime_type or "").lower()
|
||||
name = str(original_name or "")
|
||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
||||
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
|
||||
return "image"
|
||||
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
|
||||
return "audio"
|
||||
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
|
||||
return "video"
|
||||
return "file"
|
||||
|
||||
|
||||
def attachment_payload_from_row(row) -> Optional[dict]:
|
||||
if not row or not row["id"]:
|
||||
return None
|
||||
kind = attachment_kind_from_name_and_mime(
|
||||
row["original_name"],
|
||||
row["mime_type"],
|
||||
)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"original_name": row["original_name"],
|
||||
"mime_type": row["mime_type"],
|
||||
"size_bytes": row["size_bytes"],
|
||||
"kind": kind,
|
||||
"url": f"/api/attachments/{row['id']}/download",
|
||||
}
|
||||
|
||||
|
||||
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
|
||||
if not row["attachment_id"]:
|
||||
return None
|
||||
kind = attachment_kind_from_name_and_mime(
|
||||
row["attachment_original_name"],
|
||||
row["attachment_mime_type"],
|
||||
)
|
||||
return {
|
||||
"id": row["attachment_id"],
|
||||
"original_name": row["attachment_original_name"],
|
||||
"mime_type": row["attachment_mime_type"],
|
||||
"size_bytes": row["attachment_size_bytes"],
|
||||
"kind": kind,
|
||||
"url": f"/api/attachments/{row['attachment_id']}/download",
|
||||
}
|
||||
|
||||
|
||||
def attachment_list_for_message(message_id: int) -> list[dict]:
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.id,
|
||||
a.original_name,
|
||||
a.mime_type,
|
||||
a.size_bytes
|
||||
FROM message_attachments AS ma
|
||||
JOIN attachments AS a
|
||||
ON a.id = ma.attachment_id
|
||||
WHERE ma.message_id = ?
|
||||
ORDER BY ma.position ASC, a.id ASC
|
||||
""",
|
||||
(message_id,),
|
||||
).fetchall()
|
||||
return [attachment_payload_from_row(row) for row in rows]
|
||||
|
||||
|
||||
def cleanup_orphaned_attachments(
|
||||
upload_dir: Path,
|
||||
utc_now_func,
|
||||
orphan_ttl_hours: int,
|
||||
app_logger,
|
||||
) -> None:
|
||||
cutoff = (utc_now_func() - timedelta(hours=orphan_ttl_hours)).isoformat()
|
||||
with get_db() as db:
|
||||
orphaned = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
a.id,
|
||||
a.stored_name
|
||||
FROM attachments AS a
|
||||
WHERE a.created_at < ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM messages AS m
|
||||
WHERE m.attachment_id = a.id
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM message_attachments AS ma
|
||||
WHERE ma.attachment_id = a.id
|
||||
)
|
||||
""",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
for attachment in orphaned:
|
||||
db.execute(
|
||||
"""
|
||||
DELETE FROM attachments
|
||||
WHERE id = ?
|
||||
""",
|
||||
(attachment["id"],),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
for attachment in orphaned:
|
||||
try:
|
||||
(upload_dir / attachment["stored_name"]).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
app_logger.warning(
|
||||
"Could not remove orphaned attachment file: %s",
|
||||
attachment["stored_name"],
|
||||
)
|
||||
|
||||
|
||||
def maybe_cleanup_orphaned_attachments(
|
||||
state: dict,
|
||||
cleanup_interval_seconds: int,
|
||||
cleanup_func,
|
||||
app_logger,
|
||||
) -> None:
|
||||
import time
|
||||
|
||||
now_value = time.time()
|
||||
last = state.get("last_attachment_cleanup", 0.0)
|
||||
if now_value - last < cleanup_interval_seconds:
|
||||
return
|
||||
state["last_attachment_cleanup"] = now_value
|
||||
try:
|
||||
cleanup_func()
|
||||
except Exception:
|
||||
app_logger.exception("Could not clean orphaned attachments.")
|
||||
|
||||
|
||||
def resolve_owned_attachment_ids(
|
||||
attachment_ids: object,
|
||||
user_id: int,
|
||||
) -> tuple[Optional[list[int]], Optional[tuple]]:
|
||||
if attachment_ids is None:
|
||||
return [], None
|
||||
if not isinstance(attachment_ids, list):
|
||||
return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
|
||||
|
||||
parsed = []
|
||||
seen = set()
|
||||
for item in attachment_ids[:10]:
|
||||
try:
|
||||
attachment_id = int(item)
|
||||
except (TypeError, ValueError):
|
||||
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
||||
if attachment_id in seen:
|
||||
continue
|
||||
seen.add(attachment_id)
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id, uploader_user_id
|
||||
FROM attachments
|
||||
WHERE id = ?
|
||||
""",
|
||||
(attachment_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None, (jsonify({"error": "Attachment not found."}), 404)
|
||||
if row["uploader_user_id"] != user_id:
|
||||
return None, (
|
||||
jsonify({"error": "You can only attach files you uploaded."}),
|
||||
403,
|
||||
)
|
||||
parsed.append(attachment_id)
|
||||
return parsed, None
|
||||
|
||||
|
||||
def resolve_owned_attachment(
|
||||
attachment_id: object,
|
||||
user_id: int,
|
||||
) -> tuple[Optional[int], Optional[tuple]]:
|
||||
if attachment_id is None:
|
||||
return None, None
|
||||
try:
|
||||
parsed_id = int(attachment_id)
|
||||
except (TypeError, ValueError):
|
||||
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
||||
|
||||
with get_db() as db:
|
||||
attachment = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
uploader_user_id
|
||||
FROM attachments
|
||||
WHERE id = ?
|
||||
""",
|
||||
(parsed_id,),
|
||||
).fetchone()
|
||||
if not attachment:
|
||||
return None, (jsonify({"error": "Attachment not found."}), 404)
|
||||
if attachment["uploader_user_id"] != user_id:
|
||||
return None, (
|
||||
jsonify({"error": "You can only attach files you uploaded."}),
|
||||
403,
|
||||
)
|
||||
return parsed_id, None
|
||||
|
||||
|
||||
def can_download_attachment(
|
||||
attachment_id: int,
|
||||
user: Optional[dict],
|
||||
can_access_room,
|
||||
can_access_channel,
|
||||
get_channel_by_id,
|
||||
) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
with get_db() as db:
|
||||
room_rows = db.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
r.id,
|
||||
r.slug,
|
||||
r.name,
|
||||
r.description,
|
||||
r.visibility,
|
||||
r.created_by_user_id,
|
||||
r.is_active
|
||||
FROM message_attachments AS ma
|
||||
JOIN messages AS m
|
||||
ON m.id = ma.message_id
|
||||
JOIN rooms AS r
|
||||
ON r.id = m.room_id
|
||||
WHERE ma.attachment_id = ?
|
||||
AND r.is_active = 1
|
||||
""",
|
||||
(attachment_id,),
|
||||
).fetchall()
|
||||
channel_rows = db.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
m.channel_id
|
||||
FROM message_attachments AS ma
|
||||
JOIN messages AS m
|
||||
ON m.id = ma.message_id
|
||||
WHERE ma.attachment_id = ?
|
||||
AND m.channel_id IS NOT NULL
|
||||
""",
|
||||
(attachment_id,),
|
||||
).fetchall()
|
||||
dm_rows = db.execute(
|
||||
"""
|
||||
SELECT DISTINCT dc.id
|
||||
FROM message_attachments AS ma
|
||||
JOIN messages AS m
|
||||
ON m.id = ma.message_id
|
||||
JOIN direct_conversations AS dc
|
||||
ON dc.id = m.direct_conversation_id
|
||||
JOIN direct_conversation_members AS dcm
|
||||
ON dcm.direct_conversation_id = dc.id
|
||||
WHERE ma.attachment_id = ?
|
||||
AND dcm.user_id = ?
|
||||
""",
|
||||
(attachment_id, user["id"]),
|
||||
).fetchall()
|
||||
|
||||
if dm_rows:
|
||||
return True
|
||||
if any(can_access_room(room, user) for room in room_rows):
|
||||
return True
|
||||
for item in channel_rows:
|
||||
channel = get_channel_by_id(item["channel_id"])
|
||||
if channel and can_access_channel(channel, user):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def send_attachment_file(
|
||||
upload_dir: Path,
|
||||
attachment_id: int,
|
||||
user: Optional[dict],
|
||||
can_access_room,
|
||||
can_access_channel,
|
||||
get_channel_by_id,
|
||||
):
|
||||
with get_db() as db:
|
||||
attachment = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
stored_name,
|
||||
original_name,
|
||||
mime_type
|
||||
FROM attachments
|
||||
WHERE id = ?
|
||||
""",
|
||||
(attachment_id,),
|
||||
).fetchone()
|
||||
if not attachment:
|
||||
abort(404)
|
||||
|
||||
if not can_download_attachment(
|
||||
attachment_id,
|
||||
user,
|
||||
can_access_room,
|
||||
can_access_channel,
|
||||
get_channel_by_id,
|
||||
):
|
||||
abort(403)
|
||||
|
||||
return send_from_directory(
|
||||
upload_dir,
|
||||
attachment["stored_name"],
|
||||
as_attachment=True,
|
||||
download_name=attachment["original_name"],
|
||||
mimetype=attachment["mime_type"],
|
||||
)
|
||||
498
auth.py
Normal file
498
auth.py
Normal file
@ -0,0 +1,498 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from flask import jsonify, request, session
|
||||
from werkzeug.exceptions import BadRequest
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from config import (
|
||||
CLIENT_HASH_KEY,
|
||||
PLACEHOLDER_SECRETS,
|
||||
PRIVATE_ACCESS_KEY,
|
||||
RATE_LIMITS,
|
||||
ROLES,
|
||||
STATUSES,
|
||||
MAX_PASSWORD_LENGTH,
|
||||
MIN_PASSWORD_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
MIN_USERNAME_LENGTH,
|
||||
now_iso,
|
||||
parse_iso,
|
||||
utc_now,
|
||||
)
|
||||
from db import db_write, get_db
|
||||
|
||||
USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$")
|
||||
|
||||
|
||||
def normalize_username(value: object) -> str:
|
||||
return value.strip().lower() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def validate_username(username: str) -> Optional[str]:
|
||||
if not username:
|
||||
return "Username is required."
|
||||
if len(username) < MIN_USERNAME_LENGTH:
|
||||
return f"Username must be at least {MIN_USERNAME_LENGTH} characters."
|
||||
if len(username) > MAX_USERNAME_LENGTH:
|
||||
return f"Username must be {MAX_USERNAME_LENGTH} characters or less."
|
||||
if not USERNAME_RE.fullmatch(username):
|
||||
return "Username contains invalid characters."
|
||||
return None
|
||||
|
||||
|
||||
def validate_password(password: object, username: str = "") -> Optional[str]:
|
||||
if not isinstance(password, str) or not password:
|
||||
return "Password is required."
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
||||
if len(password) > MAX_PASSWORD_LENGTH:
|
||||
return f"Password must be {MAX_PASSWORD_LENGTH} characters or less."
|
||||
if not re.search(r"[a-z]", password):
|
||||
return "Password must include a lowercase letter."
|
||||
if not re.search(r"[A-Z]", password):
|
||||
return "Password must include an uppercase letter."
|
||||
if not re.search(r"\d", password):
|
||||
return "Password must include a number."
|
||||
if not re.search(r"[^A-Za-z0-9]", password):
|
||||
return "Password must include a symbol."
|
||||
if username and username in password.lower():
|
||||
return "Password must not contain your username."
|
||||
return None
|
||||
|
||||
|
||||
def safe_json() -> dict:
|
||||
if not request.is_json:
|
||||
raise ValueError("Request body must be JSON.")
|
||||
try:
|
||||
data = request.get_json(silent=False)
|
||||
except BadRequest as error:
|
||||
raise ValueError("Invalid JSON request body.") from error
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("JSON body must be an object.")
|
||||
return data
|
||||
|
||||
|
||||
def csrf_token() -> str:
|
||||
token = session.get("csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session["csrf_token"] = token
|
||||
return token
|
||||
|
||||
|
||||
def csrf_valid() -> bool:
|
||||
supplied = request.headers.get("X-CSRF-Token", "")
|
||||
expected = session.get("csrf_token", "")
|
||||
return bool(supplied and expected and hmac.compare_digest(supplied, expected))
|
||||
|
||||
|
||||
def client_ip() -> str:
|
||||
return (request.remote_addr or "unknown")[:128]
|
||||
|
||||
|
||||
def ctx_hash():
|
||||
return hashlib.sha256
|
||||
|
||||
|
||||
def hash_identifier(value: str) -> str:
|
||||
return hmac.new(
|
||||
CLIENT_HASH_KEY.encode("utf-8"),
|
||||
value.encode("utf-8"),
|
||||
ctx_hash(),
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def b64url_encode_bytes(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def sign_call_helper_token(payload: dict, shared_secret: str) -> str:
|
||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
||||
payload_bytes = payload_json.encode("utf-8")
|
||||
signature = hmac.new(
|
||||
shared_secret.encode("utf-8"),
|
||||
payload_bytes,
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
return f"{b64url_encode_bytes(payload_bytes)}.{b64url_encode_bytes(signature)}"
|
||||
|
||||
|
||||
def current_client_hash() -> str:
|
||||
return hash_identifier(client_ip())
|
||||
|
||||
|
||||
def audit(
|
||||
action: str,
|
||||
actor_user_id: Optional[int] = None,
|
||||
target_type: Optional[str] = None,
|
||||
target_id: Optional[str] = None,
|
||||
details: Optional[str] = None,
|
||||
) -> None:
|
||||
db_write(
|
||||
"""
|
||||
INSERT INTO audit_log (
|
||||
actor_user_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
details,
|
||||
client_hash,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
actor_user_id,
|
||||
action,
|
||||
target_type,
|
||||
target_id,
|
||||
details,
|
||||
current_client_hash(),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def active_blacklist_match(kind: str, value_hash: str) -> bool:
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM blacklist_entries
|
||||
WHERE kind = ?
|
||||
AND value_hash = ?
|
||||
AND (
|
||||
expires_at IS NULL
|
||||
OR expires_at > ?
|
||||
)
|
||||
""",
|
||||
(kind, value_hash, now_iso()),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def request_blacklisted() -> bool:
|
||||
if request.endpoint in {"static"}:
|
||||
return False
|
||||
if active_blacklist_match("ip", current_client_hash()):
|
||||
return True
|
||||
user_id = session.get("user_id")
|
||||
if user_id and active_blacklist_match("user", hash_identifier(str(user_id))):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_rate_limit(scope: str) -> Optional[int]:
|
||||
limit, window_seconds = RATE_LIMITS[scope]
|
||||
now_value = time.time()
|
||||
cutoff = now_value - window_seconds
|
||||
subject_hash = current_client_hash()
|
||||
|
||||
with get_db() as db:
|
||||
db.execute("DELETE FROM rate_limit_events WHERE created_at < ?", (cutoff - 60,))
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT created_at
|
||||
FROM rate_limit_events
|
||||
WHERE scope = ?
|
||||
AND subject_hash = ?
|
||||
AND created_at >= ?
|
||||
ORDER BY created_at ASC
|
||||
""",
|
||||
(scope, subject_hash, cutoff),
|
||||
).fetchall()
|
||||
|
||||
if len(rows) >= limit:
|
||||
oldest = rows[0]["created_at"]
|
||||
return max(1, int(window_seconds - (now_value - oldest)) + 1)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO rate_limit_events (
|
||||
scope,
|
||||
subject_hash,
|
||||
created_at
|
||||
) VALUES (?, ?, ?)
|
||||
""",
|
||||
(scope, subject_hash, now_value),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def rate_limited(scope: str):
|
||||
retry_after = check_rate_limit(scope)
|
||||
if retry_after is None:
|
||||
return None
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"error": "Too many requests. Please wait and try again.",
|
||||
"code": "rate_limited",
|
||||
"retry_after": retry_after,
|
||||
}
|
||||
),
|
||||
429,
|
||||
)
|
||||
|
||||
|
||||
def user_payload(row, settings_row=None) -> dict:
|
||||
status = row["status"]
|
||||
restricted_until = parse_iso(row["restricted_until"])
|
||||
if status == "restricted" and restricted_until and restricted_until <= utc_now():
|
||||
status = "active"
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"status": status,
|
||||
"restricted_until": row["restricted_until"],
|
||||
}
|
||||
|
||||
|
||||
def current_user() -> Optional[dict]:
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return None
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
restricted_until
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
session.clear()
|
||||
return None
|
||||
|
||||
user = user_payload(row)
|
||||
|
||||
if user["status"] == "restricted":
|
||||
expires = parse_iso(user["restricted_until"])
|
||||
if expires and expires <= utc_now():
|
||||
db_write(
|
||||
"""
|
||||
UPDATE users
|
||||
SET
|
||||
status = 'active',
|
||||
restricted_until = NULL,
|
||||
restriction_reason = NULL,
|
||||
is_active = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
)
|
||||
user["status"] = "active"
|
||||
user["restricted_until"] = None
|
||||
|
||||
if user["status"] in {"disabled", "blacklisted"}:
|
||||
session.clear()
|
||||
return None
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def login_user(row) -> dict:
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user_id"] = row["id"]
|
||||
session["csrf_token"] = secrets.token_urlsafe(32)
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"status": row["status"],
|
||||
"restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"],
|
||||
}
|
||||
|
||||
|
||||
def require_user():
|
||||
user = current_user()
|
||||
if not user:
|
||||
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
||||
return user, None
|
||||
|
||||
|
||||
def require_writable_user():
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return None, error
|
||||
if user["status"] == "restricted":
|
||||
return None, (
|
||||
jsonify(
|
||||
{
|
||||
"error": "Your account is temporarily restricted.",
|
||||
"code": "restricted",
|
||||
"restricted_until": user["restricted_until"],
|
||||
}
|
||||
),
|
||||
403,
|
||||
)
|
||||
return user, None
|
||||
|
||||
|
||||
def require_moderator():
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return None, error
|
||||
if user["role"] not in {"moderator", "admin"}:
|
||||
return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403)
|
||||
return user, None
|
||||
|
||||
|
||||
def require_admin():
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return None, error
|
||||
if user["role"] != "admin":
|
||||
return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403)
|
||||
return user, None
|
||||
|
||||
|
||||
def private_access_key_valid(candidate: object) -> bool:
|
||||
configured = (PRIVATE_ACCESS_KEY or "").strip()
|
||||
if not configured:
|
||||
return True
|
||||
if not isinstance(candidate, str):
|
||||
return False
|
||||
return candidate.strip() == configured
|
||||
|
||||
|
||||
def register_account(data: dict):
|
||||
access_key = data.get("access_key", "")
|
||||
if not private_access_key_valid(access_key):
|
||||
return None, (jsonify({"error": "Invalid private access key."}), 403)
|
||||
|
||||
username = normalize_username(data.get("username"))
|
||||
password = data.get("password", "")
|
||||
|
||||
username_error = validate_username(username)
|
||||
password_error = validate_password(password, username)
|
||||
|
||||
if username_error:
|
||||
return None, (jsonify({"error": username_error}), 400)
|
||||
if password_error:
|
||||
return None, (jsonify({"error": password_error}), 400)
|
||||
|
||||
try:
|
||||
cursor = db_write(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
status,
|
||||
is_admin,
|
||||
is_active,
|
||||
created_at
|
||||
) VALUES (?, ?, 'user', 'active', 0, 1, ?)
|
||||
""",
|
||||
(username, generate_password_hash(password), now_iso()),
|
||||
)
|
||||
except Exception as error:
|
||||
if "UNIQUE" in str(error).upper():
|
||||
return None, (jsonify({"error": "Unable to create account with those details."}), 409)
|
||||
raise
|
||||
|
||||
user = {
|
||||
"id": cursor.lastrowid,
|
||||
"username": username,
|
||||
"role": "user",
|
||||
"status": "active",
|
||||
"restricted_until": None,
|
||||
}
|
||||
|
||||
db_write(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_profiles (
|
||||
user_id,
|
||||
display_name,
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
updated_at
|
||||
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
||||
""",
|
||||
(user["id"], username, now_iso()),
|
||||
)
|
||||
|
||||
db_write(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_settings (
|
||||
user_id,
|
||||
accent_color,
|
||||
name_color,
|
||||
custom_css,
|
||||
theme_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
presence_mode,
|
||||
dm_layout_mode,
|
||||
show_presence_badge,
|
||||
updated_at
|
||||
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
||||
""",
|
||||
(user["id"], now_iso()),
|
||||
)
|
||||
|
||||
authenticated = login_user(user)
|
||||
audit("account_registered", user["id"], "user", str(user["id"]))
|
||||
return authenticated, None
|
||||
|
||||
|
||||
def authenticate_account(data: dict):
|
||||
access_key = data.get("access_key", "")
|
||||
if not private_access_key_valid(access_key):
|
||||
return None, (jsonify({"error": "Invalid private access key."}), 403)
|
||||
|
||||
username = normalize_username(data.get("username"))
|
||||
password = data.get("password", "")
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
status,
|
||||
restricted_until
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password):
|
||||
return None, (jsonify({"error": "Invalid username or password."}), 401)
|
||||
|
||||
if row["status"] in {"disabled", "blacklisted"}:
|
||||
return None, (jsonify({"error": "Invalid username or password."}), 401)
|
||||
|
||||
db_write("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"]))
|
||||
authenticated = login_user(row)
|
||||
audit("account_logged_in", row["id"], "user", str(row["id"]))
|
||||
return authenticated, None
|
||||
464
calls.py
Normal file
464
calls.py
Normal file
@ -0,0 +1,464 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
from db import db_write, get_db
|
||||
from config import utc_now, now_iso, GROUP_CALL_PARTICIPANT_STALE_SECONDS
|
||||
|
||||
|
||||
def create_call_session(dm_id: int, started_by_user_id: int) -> int:
|
||||
cursor = db_write(
|
||||
"""
|
||||
INSERT INTO call_sessions (
|
||||
direct_conversation_id,
|
||||
started_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
) VALUES (?, ?, 'active', ?, NULL)
|
||||
""",
|
||||
(dm_id, started_by_user_id, now_iso()),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def append_call_signal(call_id: int, from_user_id: int, signal_type: str, payload: dict) -> int:
|
||||
cursor = db_write(
|
||||
"""
|
||||
INSERT INTO call_signals (
|
||||
call_session_id,
|
||||
from_user_id,
|
||||
signal_type,
|
||||
payload_json,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
call_id,
|
||||
from_user_id,
|
||||
signal_type,
|
||||
json.dumps(payload or {}),
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
|
||||
def list_call_signals(call_id: int, after_id: int = 0) -> list[dict]:
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
cs.id,
|
||||
cs.call_session_id,
|
||||
cs.from_user_id,
|
||||
u.username,
|
||||
cs.signal_type,
|
||||
cs.payload_json,
|
||||
cs.created_at
|
||||
FROM call_signals AS cs
|
||||
JOIN users AS u
|
||||
ON u.id = cs.from_user_id
|
||||
WHERE cs.call_session_id = ?
|
||||
AND cs.id > ?
|
||||
ORDER BY cs.id ASC
|
||||
LIMIT 200
|
||||
""",
|
||||
(call_id, after_id),
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
try:
|
||||
payload = json.loads(row["payload_json"] or "{}")
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"call_session_id": row["call_session_id"],
|
||||
"from_user_id": row["from_user_id"],
|
||||
"username": row["username"],
|
||||
"signal_type": row["signal_type"],
|
||||
"payload": payload,
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def end_call(call_id: int) -> None:
|
||||
db_write(
|
||||
"""
|
||||
UPDATE call_sessions
|
||||
SET
|
||||
status = 'ended',
|
||||
ended_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_iso(), call_id),
|
||||
)
|
||||
|
||||
|
||||
def get_call_with_dm(call_id: int):
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
cs.id,
|
||||
cs.direct_conversation_id,
|
||||
cs.started_by_user_id,
|
||||
cs.status,
|
||||
cs.created_at,
|
||||
cs.ended_at
|
||||
FROM call_sessions AS cs
|
||||
WHERE cs.id = ?
|
||||
""",
|
||||
(call_id,),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def call_payload(call_id: int):
|
||||
row = get_call_with_dm(call_id)
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"direct_conversation_id": row["direct_conversation_id"],
|
||||
"started_by_user_id": row["started_by_user_id"],
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
"ended_at": row["ended_at"],
|
||||
}
|
||||
|
||||
|
||||
def build_room_helper_call_id(room) -> str:
|
||||
return f"room-{room['slug']}"
|
||||
|
||||
|
||||
def build_channel_helper_call_id(channel) -> str:
|
||||
return f"channel-{channel['team_slug']}--{channel['slug']}"
|
||||
|
||||
|
||||
def get_or_create_group_call_session(
|
||||
scope_kind: str,
|
||||
scope_id: int,
|
||||
scope_slug: str,
|
||||
helper_call_id: str,
|
||||
created_by_user_id: int,
|
||||
):
|
||||
with get_db() as db:
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
FROM group_call_sessions
|
||||
WHERE helper_call_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(helper_call_id,),
|
||||
).fetchone()
|
||||
|
||||
if existing and existing["status"] == "active":
|
||||
return existing
|
||||
|
||||
with get_db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
FROM group_call_sessions
|
||||
WHERE helper_call_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(helper_call_id,),
|
||||
).fetchone()
|
||||
|
||||
if existing and existing["status"] == "active":
|
||||
db.commit()
|
||||
return existing
|
||||
|
||||
if existing:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE group_call_sessions
|
||||
SET
|
||||
status = 'active',
|
||||
ended_at = NULL,
|
||||
created_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_iso(), existing["id"]),
|
||||
)
|
||||
db.commit()
|
||||
refreshed = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
FROM group_call_sessions
|
||||
WHERE id = ?
|
||||
""",
|
||||
(existing["id"],),
|
||||
).fetchone()
|
||||
return refreshed
|
||||
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO group_call_sessions (
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL)
|
||||
""",
|
||||
(
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
session_id = cursor.lastrowid
|
||||
db.commit()
|
||||
created = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
FROM group_call_sessions
|
||||
WHERE id = ?
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return created
|
||||
|
||||
|
||||
def group_call_session_payload(row):
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"scope_kind": row["scope_kind"],
|
||||
"scope_id": row["scope_id"],
|
||||
"scope_slug": row["scope_slug"],
|
||||
"helper_call_id": row["helper_call_id"],
|
||||
"created_by_user_id": row["created_by_user_id"],
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
"ended_at": row["ended_at"],
|
||||
}
|
||||
|
||||
|
||||
def get_group_call_session_by_id(session_id: int):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
scope_kind,
|
||||
scope_id,
|
||||
scope_slug,
|
||||
helper_call_id,
|
||||
created_by_user_id,
|
||||
status,
|
||||
created_at,
|
||||
ended_at
|
||||
FROM group_call_sessions
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def cleanup_stale_group_call_participants(session_id: int):
|
||||
cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_participants
|
||||
SET
|
||||
left_at = COALESCE(left_at, ?)
|
||||
WHERE group_call_session_id = ?
|
||||
AND left_at IS NULL
|
||||
AND (
|
||||
updated_at IS NULL
|
||||
OR strftime('%s', updated_at) < ?
|
||||
)
|
||||
""",
|
||||
(now_iso(), session_id, int(cutoff)),
|
||||
)
|
||||
|
||||
|
||||
def upsert_group_call_participant(session_id: int, user_id: int):
|
||||
db_write(
|
||||
"""
|
||||
INSERT INTO group_call_participants (
|
||||
group_call_session_id,
|
||||
user_id,
|
||||
joined_at,
|
||||
updated_at,
|
||||
left_at
|
||||
) VALUES (?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(group_call_session_id, user_id)
|
||||
DO UPDATE SET
|
||||
updated_at = excluded.updated_at,
|
||||
left_at = NULL
|
||||
""",
|
||||
(session_id, user_id, now_iso(), now_iso()),
|
||||
)
|
||||
|
||||
|
||||
def heartbeat_group_call_participant(session_id: int, user_id: int):
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_participants
|
||||
SET
|
||||
updated_at = ?,
|
||||
left_at = NULL
|
||||
WHERE group_call_session_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(now_iso(), session_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def mark_group_call_participant_left(session_id: int, user_id: int):
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_participants
|
||||
SET
|
||||
updated_at = ?,
|
||||
left_at = ?
|
||||
WHERE group_call_session_id = ?
|
||||
AND user_id = ?
|
||||
AND left_at IS NULL
|
||||
""",
|
||||
(now_iso(), now_iso(), session_id, user_id),
|
||||
)
|
||||
|
||||
|
||||
def list_group_call_participants(session_id: int) -> list[dict]:
|
||||
cleanup_stale_group_call_participants(session_id)
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
gcp.group_call_session_id,
|
||||
gcp.user_id,
|
||||
gcp.joined_at,
|
||||
gcp.updated_at,
|
||||
gcp.left_at,
|
||||
u.username,
|
||||
u.role,
|
||||
up.display_name,
|
||||
us.name_color
|
||||
FROM group_call_participants AS gcp
|
||||
JOIN users AS u
|
||||
ON u.id = gcp.user_id
|
||||
LEFT JOIN user_profiles AS up
|
||||
ON up.user_id = u.id
|
||||
LEFT JOIN user_settings AS us
|
||||
ON us.user_id = u.id
|
||||
WHERE gcp.group_call_session_id = ?
|
||||
AND gcp.left_at IS NULL
|
||||
ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"user_id": row["user_id"],
|
||||
"username": row["username"],
|
||||
"display_name": row["display_name"] or row["username"],
|
||||
"role": row["role"],
|
||||
"name_color": row["name_color"],
|
||||
"joined_at": row["joined_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def active_group_call_participant_count(session_id: int) -> int:
|
||||
cleanup_stale_group_call_participants(session_id)
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM group_call_participants
|
||||
WHERE group_call_session_id = ?
|
||||
AND left_at IS NULL
|
||||
""",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
return int(row["count"]) if row else 0
|
||||
|
||||
|
||||
def maybe_end_group_call_if_empty(session_id: int):
|
||||
if active_group_call_participant_count(session_id) > 0:
|
||||
return
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_sessions
|
||||
SET
|
||||
status = 'ended',
|
||||
ended_at = ?
|
||||
WHERE id = ?
|
||||
AND status != 'ended'
|
||||
""",
|
||||
(now_iso(), session_id),
|
||||
)
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_invites
|
||||
SET
|
||||
status = 'dismissed',
|
||||
responded_at = ?
|
||||
WHERE group_call_session_id = ?
|
||||
AND status = 'pending'
|
||||
""",
|
||||
(now_iso(), session_id),
|
||||
)
|
||||
231
config.py
Normal file
231
config.py
Normal file
@ -0,0 +1,231 @@
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta, timezone, datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
APP_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
DB_PATH = os.environ.get("CHAT_DB_PATH", str(APP_ROOT / "chat.db"))
|
||||
UPLOAD_DIR = Path(os.environ.get("CHAT_UPLOAD_DIR", str(APP_ROOT / "uploads")))
|
||||
|
||||
SECRET_KEY_PATH = os.environ.get(
|
||||
"CHAT_SECRET_KEY_PATH",
|
||||
str(Path(DB_PATH).resolve().parent / "flask_secret.key"),
|
||||
)
|
||||
CLIENT_HASH_KEY_PATH = os.environ.get(
|
||||
"CHAT_CLIENT_HASH_KEY_PATH",
|
||||
str(Path(DB_PATH).resolve().parent / "client_hash.key"),
|
||||
)
|
||||
|
||||
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
|
||||
MAX_REQUEST_BYTES = max(110 * 1024 * 1024, MAX_ATTACHMENT_BYTES + 10 * 1024 * 1024)
|
||||
|
||||
MAX_MESSAGE_LENGTH = 1000
|
||||
MAX_USERNAME_LENGTH = 30
|
||||
MIN_USERNAME_LENGTH = 3
|
||||
MIN_PASSWORD_LENGTH = 7
|
||||
MAX_PASSWORD_LENGTH = 256
|
||||
MAX_HISTORY_MESSAGES = 100
|
||||
MAX_INCREMENTAL_MESSAGES = 100
|
||||
MAX_FEED_MESSAGES = 50
|
||||
MAX_THREAD_REPLIES = 100
|
||||
MAX_ATTACHMENT_NAME_LENGTH = 150
|
||||
ATTACHMENT_ORPHAN_TTL_HOURS = 24
|
||||
ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60
|
||||
INVITE_TTL_HOURS = 72
|
||||
TEAM_INVITE_TTL_HOURS = 72
|
||||
TYPING_TTL_SECONDS = 7
|
||||
PRESENCE_TTL_SECONDS = 40
|
||||
MAX_PROFILE_BIO_LENGTH = 5000
|
||||
MAX_DISPLAY_NAME_LENGTH = 80
|
||||
MAX_LOCATION_LENGTH = 120
|
||||
MAX_WEBSITE_LENGTH = 300
|
||||
MAX_CUSTOM_CSS_LENGTH = 20000
|
||||
MAX_PROFILE_BADGE_LENGTH = 48
|
||||
MAX_OWNED_TEAMS_PER_USER = 3
|
||||
MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60
|
||||
MAX_SEARCH_QUERY_LENGTH = 120
|
||||
MAX_SEARCH_RESULTS = 100
|
||||
READ_RECEIPT_BATCH_LIMIT = 200
|
||||
GROUP_CALL_PARTICIPANT_STALE_SECONDS = 45
|
||||
|
||||
PRIVATE_ACCESS_KEY = os.environ.get("PRIVATE_ACCESS_KEY", "").strip()
|
||||
|
||||
CALLS_BASE = os.environ.get("CALLS_BASE", "https://calls.example.com").rstrip("/")
|
||||
CALL_HELPER_SHARED_SECRET = os.environ.get("CALL_HELPER_SHARED_SECRET", "").strip()
|
||||
CALL_TOKEN_TTL_SECONDS = int(os.environ.get("CALL_TOKEN_TTL_SECONDS", "60"))
|
||||
|
||||
ALLOWED_ATTACHMENT_EXTENSIONS = {
|
||||
"txt",
|
||||
"pdf",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
"m4a",
|
||||
"aac",
|
||||
"flac",
|
||||
"mp4",
|
||||
"webm",
|
||||
"mov",
|
||||
"m4v",
|
||||
"ogv",
|
||||
}
|
||||
|
||||
ALLOWED_ATTACHMENT_MIME_TYPES = {
|
||||
"text/plain",
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"audio/mpeg",
|
||||
"audio/mp3",
|
||||
"audio/wav",
|
||||
"audio/x-wav",
|
||||
"audio/ogg",
|
||||
"audio/mp4",
|
||||
"audio/aac",
|
||||
"audio/flac",
|
||||
"video/mp4",
|
||||
"video/webm",
|
||||
"video/quicktime",
|
||||
"video/ogg",
|
||||
}
|
||||
|
||||
ROLES = {"user", "moderator", "admin"}
|
||||
STATUSES = {"active", "disabled", "restricted", "blacklisted"}
|
||||
ROOM_VISIBILITIES = {"public", "private"}
|
||||
|
||||
RATE_LIMITS = {
|
||||
"login": (8, 10 * 60),
|
||||
"register": (5, 60 * 60),
|
||||
"send": (30, 60),
|
||||
"admin": (60, 60),
|
||||
"attachment": (20, 60),
|
||||
"typing": (60, 60),
|
||||
"reaction": (120, 60),
|
||||
"profile": (30, 60),
|
||||
"settings": (30, 60),
|
||||
}
|
||||
|
||||
PLACEHOLDER_SECRETS = {
|
||||
"",
|
||||
"change-this-secret-key",
|
||||
"replace-this-with-a-long-random-secret",
|
||||
"replace-me",
|
||||
"secret",
|
||||
"changeme",
|
||||
}
|
||||
|
||||
RUNTIME_STATE = {
|
||||
"last_attachment_cleanup": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return utc_now().isoformat()
|
||||
|
||||
|
||||
def parse_iso(value: Optional[str]) -> Optional[datetime]:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.tzinfo:
|
||||
return parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> str:
|
||||
configured = os.environ.get(env_value, "").strip()
|
||||
if configured and configured.lower() not in PLACEHOLDER_SECRETS and len(configured) >= min_length:
|
||||
return configured
|
||||
|
||||
path = Path(path_value)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if path.exists():
|
||||
existing = path.read_text(encoding="utf-8").strip()
|
||||
if len(existing) >= min_length:
|
||||
return existing
|
||||
|
||||
generated = secrets.token_urlsafe(64)
|
||||
try:
|
||||
path.write_text(generated, encoding="utf-8")
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
return generated
|
||||
|
||||
|
||||
SECRET_KEY = load_or_create_key(SECRET_KEY_PATH, "FLASK_SECRET_KEY")
|
||||
CLIENT_HASH_KEY = load_or_create_key(CLIENT_HASH_KEY_PATH, "CHAT_CLIENT_HASH_KEY")
|
||||
|
||||
TRUSTED_PROXY_HOPS = int(os.environ.get("TRUSTED_PROXY_HOPS", "0"))
|
||||
SESSION_COOKIE_SECURE = (os.environ.get("SESSION_COOKIE_SECURE", "1") == "1")
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(days=14)
|
||||
|
||||
|
||||
def get_asset_version() -> str:
|
||||
try:
|
||||
js_dir = APP_ROOT / "static" / "js"
|
||||
values = [int((APP_ROOT / "static" / "style.css").stat().st_mtime)]
|
||||
values.extend(int(path.stat().st_mtime) for path in js_dir.glob("*.js"))
|
||||
return str(max(values))
|
||||
except OSError:
|
||||
return "1"
|
||||
|
||||
|
||||
def flask_config() -> dict:
|
||||
return {
|
||||
"MAX_CONTENT_LENGTH": MAX_REQUEST_BYTES,
|
||||
"SESSION_COOKIE_NAME": "chat_session",
|
||||
"SESSION_COOKIE_HTTPONLY": True,
|
||||
"SESSION_COOKIE_SAMESITE": "Lax",
|
||||
"SESSION_COOKIE_SECURE": SESSION_COOKIE_SECURE,
|
||||
"PERMANENT_SESSION_LIFETIME": PERMANENT_SESSION_LIFETIME,
|
||||
}
|
||||
|
||||
|
||||
CONSTANTS = {
|
||||
"MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH,
|
||||
"MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES,
|
||||
"MAX_INCREMENTAL_MESSAGES": MAX_INCREMENTAL_MESSAGES,
|
||||
"MAX_FEED_MESSAGES": MAX_FEED_MESSAGES,
|
||||
"MAX_THREAD_REPLIES": MAX_THREAD_REPLIES,
|
||||
"MAX_ATTACHMENT_BYTES": MAX_ATTACHMENT_BYTES,
|
||||
"MAX_ATTACHMENT_NAME_LENGTH": MAX_ATTACHMENT_NAME_LENGTH,
|
||||
"ALLOWED_ATTACHMENT_EXTENSIONS": ALLOWED_ATTACHMENT_EXTENSIONS,
|
||||
"ALLOWED_ATTACHMENT_MIME_TYPES": ALLOWED_ATTACHMENT_MIME_TYPES,
|
||||
"INVITE_TTL_HOURS": INVITE_TTL_HOURS,
|
||||
"TEAM_INVITE_TTL_HOURS": TEAM_INVITE_TTL_HOURS,
|
||||
"TYPING_TTL_SECONDS": TYPING_TTL_SECONDS,
|
||||
"PRESENCE_TTL_SECONDS": PRESENCE_TTL_SECONDS,
|
||||
"MAX_SEARCH_QUERY_LENGTH": MAX_SEARCH_QUERY_LENGTH,
|
||||
"MAX_SEARCH_RESULTS": MAX_SEARCH_RESULTS,
|
||||
"READ_RECEIPT_BATCH_LIMIT": READ_RECEIPT_BATCH_LIMIT,
|
||||
"MAX_MESSAGE_EDIT_WINDOW_SECONDS": MAX_MESSAGE_EDIT_WINDOW_SECONDS,
|
||||
"MAX_DISPLAY_NAME_LENGTH": MAX_DISPLAY_NAME_LENGTH,
|
||||
"MAX_PROFILE_BIO_LENGTH": MAX_PROFILE_BIO_LENGTH,
|
||||
"MAX_LOCATION_LENGTH": MAX_LOCATION_LENGTH,
|
||||
"MAX_WEBSITE_LENGTH": MAX_WEBSITE_LENGTH,
|
||||
"MAX_CUSTOM_CSS_LENGTH": MAX_CUSTOM_CSS_LENGTH,
|
||||
"MAX_PROFILE_BADGE_LENGTH": MAX_PROFILE_BADGE_LENGTH,
|
||||
"MAX_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER,
|
||||
"ROOM_VISIBILITIES": ROOM_VISIBILITIES,
|
||||
"ROLES": ROLES,
|
||||
"STATUSES": STATUSES,
|
||||
"GROUP_CALL_PARTICIPANT_STALE_SECONDS": GROUP_CALL_PARTICIPANT_STALE_SECONDS,
|
||||
}
|
||||
948
db.py
Normal file
948
db.py
Normal file
@ -0,0 +1,948 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from config import (
|
||||
DB_PATH,
|
||||
UPLOAD_DIR,
|
||||
ROLES,
|
||||
STATUSES,
|
||||
now_iso,
|
||||
)
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
db_dir = os.path.dirname(DB_PATH)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA busy_timeout = 8000")
|
||||
conn.execute("PRAGMA synchronous = NORMAL")
|
||||
return conn
|
||||
|
||||
|
||||
def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor:
|
||||
last_error = None
|
||||
for attempt in range(5):
|
||||
try:
|
||||
with get_db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
cursor = db.execute(query, params)
|
||||
db.commit()
|
||||
return cursor
|
||||
except sqlite3.OperationalError as error:
|
||||
last_error = error
|
||||
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
|
||||
raise
|
||||
if attempt == 4:
|
||||
raise
|
||||
time.sleep(0.05 * (2 ** attempt))
|
||||
raise last_error
|
||||
|
||||
|
||||
def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool:
|
||||
rows = db.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return any(row["name"] == column for row in rows)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with get_db() as db:
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
restricted_until TEXT,
|
||||
restriction_reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
visibility TEXT NOT NULL DEFAULT 'public',
|
||||
created_by_user_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS room_members (
|
||||
room_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uploader_user_id INTEGER NOT NULL,
|
||||
stored_name TEXT UNIQUE NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by_user_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (team_id, user_id),
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS team_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
used_by_user_id INTEGER,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id INTEGER NOT NULL,
|
||||
legacy_room_id INTEGER UNIQUE,
|
||||
slug TEXT COLLATE NOCASE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by_user_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(team_id, slug),
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS direct_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
member_pair_key TEXT UNIQUE NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS direct_conversation_members (
|
||||
direct_conversation_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
added_at TEXT NOT NULL,
|
||||
PRIMARY KEY (direct_conversation_id, user_id),
|
||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
direct_conversation_id INTEGER,
|
||||
user_id INTEGER NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
attachment_id INTEGER,
|
||||
parent_message_id INTEGER,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS room_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
used_by_user_id INTEGER,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS typing_events (
|
||||
room_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (room_id, user_id),
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS channel_typing_events (
|
||||
channel_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (channel_id, user_id),
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dm_typing_events (
|
||||
direct_conversation_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
updated_at REAL NOT NULL,
|
||||
PRIMARY KEY (direct_conversation_id, user_id),
|
||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS blacklist_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
kind TEXT NOT NULL,
|
||||
value_hash TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
created_by_user_id INTEGER,
|
||||
UNIQUE(kind, value_hash),
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope TEXT NOT NULL,
|
||||
subject_hash TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
details TEXT,
|
||||
client_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS pages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id INTEGER NOT NULL,
|
||||
channel_id INTEGER,
|
||||
slug TEXT COLLATE NOCASE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
visibility TEXT NOT NULL DEFAULT 'team',
|
||||
page_type TEXT NOT NULL DEFAULT 'document',
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
updated_by_user_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
UNIQUE(team_id, slug),
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE RESTRICT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
badge_text TEXT,
|
||||
bio TEXT NOT NULL DEFAULT '',
|
||||
bio_mode TEXT NOT NULL DEFAULT 'txt',
|
||||
website TEXT,
|
||||
location TEXT,
|
||||
status_message TEXT NOT NULL DEFAULT '',
|
||||
banner_attachment_id INTEGER,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
accent_color TEXT,
|
||||
name_color TEXT,
|
||||
custom_css TEXT NOT NULL DEFAULT '',
|
||||
theme_mode TEXT NOT NULL DEFAULT 'dark',
|
||||
profile_background_color TEXT,
|
||||
profile_banner_color TEXT,
|
||||
profile_visibility TEXT NOT NULL DEFAULT 'public',
|
||||
last_active_visibility TEXT NOT NULL DEFAULT 'everyone',
|
||||
presence_mode TEXT NOT NULL DEFAULT 'online',
|
||||
dm_layout_mode TEXT NOT NULL DEFAULT 'cards',
|
||||
show_presence_badge INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id, emoji),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_edits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id INTEGER NOT NULL,
|
||||
edited_by_user_id INTEGER NOT NULL,
|
||||
previous_text TEXT NOT NULL,
|
||||
new_text TEXT NOT NULL,
|
||||
previous_attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
new_attachments_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_receipts (
|
||||
message_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
seen_at TEXT NOT NULL,
|
||||
PRIMARY KEY (message_id, user_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_attachments (
|
||||
message_id INTEGER NOT NULL,
|
||||
attachment_id INTEGER NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (message_id, attachment_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS presence_events (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
updated_at REAL NOT NULL,
|
||||
view_name TEXT,
|
||||
room_id INTEGER,
|
||||
channel_id INTEGER,
|
||||
direct_conversation_id INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS call_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
direct_conversation_id INTEGER NOT NULL,
|
||||
started_by_user_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS call_signals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
call_session_id INTEGER NOT NULL,
|
||||
from_user_id INTEGER NOT NULL,
|
||||
signal_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS group_call_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
scope_kind TEXT NOT NULL,
|
||||
scope_id INTEGER NOT NULL,
|
||||
scope_slug TEXT NOT NULL,
|
||||
helper_call_id TEXT NOT NULL UNIQUE,
|
||||
created_by_user_id INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS group_call_invites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_call_session_id INTEGER NOT NULL,
|
||||
inviter_user_id INTEGER NOT NULL,
|
||||
invitee_user_id INTEGER NOT NULL,
|
||||
helper_token TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
responded_at TEXT,
|
||||
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS group_call_participants (
|
||||
group_call_session_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
joined_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
left_at TEXT,
|
||||
PRIMARY KEY (group_call_session_id, user_id),
|
||||
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
if not column_exists(db, "messages", "room_id"):
|
||||
db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER")
|
||||
if not column_exists(db, "messages", "attachment_id"):
|
||||
db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER")
|
||||
if not column_exists(db, "messages", "parent_message_id"):
|
||||
db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER")
|
||||
if not column_exists(db, "messages", "channel_id"):
|
||||
db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER")
|
||||
if not column_exists(db, "messages", "direct_conversation_id"):
|
||||
db.execute("ALTER TABLE messages ADD COLUMN direct_conversation_id INTEGER")
|
||||
|
||||
if not column_exists(db, "user_profiles", "badge_text"):
|
||||
db.execute("ALTER TABLE user_profiles ADD COLUMN badge_text TEXT")
|
||||
if not column_exists(db, "user_profiles", "status_message"):
|
||||
db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''")
|
||||
if not column_exists(db, "user_profiles", "banner_attachment_id"):
|
||||
db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER")
|
||||
|
||||
if not column_exists(db, "user_settings", "name_color"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT")
|
||||
if not column_exists(db, "user_settings", "profile_background_color"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT")
|
||||
if not column_exists(db, "user_settings", "profile_banner_color"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT")
|
||||
if not column_exists(db, "user_settings", "profile_visibility"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'")
|
||||
if not column_exists(db, "user_settings", "last_active_visibility"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'")
|
||||
if not column_exists(db, "user_settings", "presence_mode"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'")
|
||||
if not column_exists(db, "user_settings", "dm_layout_mode"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'")
|
||||
if not column_exists(db, "user_settings", "show_presence_badge"):
|
||||
db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
if not column_exists(db, "presence_events", "direct_conversation_id"):
|
||||
db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_id INTEGER")
|
||||
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel_id_id ON messages(channel_id, id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_team_id_slug ON channels(team_id, slug)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_legacy_room_id ON channels(legacy_room_id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_pages_team_updated_at ON pages(team_id, updated_at DESC)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_channel_typing_events_updated_at ON channel_typing_events(channel_id, updated_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_dm_typing_events_updated_at ON dm_typing_events(direct_conversation_id, updated_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_invites_token_hash ON room_invites(token_hash)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_direct_conversation_members_user ON direct_conversation_members(user_id, direct_conversation_id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_call_signals_lookup ON call_signals(call_session_id, id)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_user_settings_name_color ON user_settings(user_id, name_color)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_sessions_scope ON group_call_sessions(scope_kind, scope_id, status)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_invites_invitee_status ON group_call_invites(invitee_user_id, status, expires_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)")
|
||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)")
|
||||
|
||||
general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone()
|
||||
if not general:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO rooms (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
created_at,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, ?, ?, 1)
|
||||
""",
|
||||
("general", "general", "Default public chat room", "public", now_iso()),
|
||||
)
|
||||
general_id = cursor.lastrowid
|
||||
else:
|
||||
general_id = general["id"]
|
||||
|
||||
db.execute(
|
||||
"UPDATE messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL",
|
||||
(general_id,),
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET role = CASE
|
||||
WHEN is_admin = 1 THEN 'admin'
|
||||
ELSE 'user'
|
||||
END
|
||||
WHERE role IS NULL
|
||||
OR role = ''
|
||||
"""
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET status = CASE
|
||||
WHEN is_active = 1 THEN 'active'
|
||||
ELSE 'disabled'
|
||||
END
|
||||
WHERE status IS NULL
|
||||
OR status = ''
|
||||
"""
|
||||
)
|
||||
|
||||
general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone()
|
||||
if general_team:
|
||||
general_team_id = general_team["id"]
|
||||
else:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO teams (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, NULL, ?, 1)
|
||||
""",
|
||||
("general", "General", "Default team for existing chat rooms.", now_iso()),
|
||||
)
|
||||
general_team_id = cursor.lastrowid
|
||||
|
||||
legacy_rooms = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
created_by_user_id,
|
||||
created_at
|
||||
FROM rooms
|
||||
WHERE is_active = 1
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
for room in legacy_rooms:
|
||||
existing_channel = db.execute(
|
||||
"SELECT id FROM channels WHERE legacy_room_id = ?",
|
||||
(room["id"],),
|
||||
).fetchone()
|
||||
if existing_channel:
|
||||
continue
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO channels (
|
||||
team_id,
|
||||
legacy_room_id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
""",
|
||||
(
|
||||
general_team_id,
|
||||
room["id"],
|
||||
room["slug"],
|
||||
room["name"],
|
||||
room["description"],
|
||||
room["created_by_user_id"],
|
||||
room["created_at"],
|
||||
),
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET channel_id = (
|
||||
SELECT c.id
|
||||
FROM channels AS c
|
||||
WHERE c.legacy_room_id = messages.room_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE channel_id IS NULL
|
||||
AND room_id IS NOT NULL
|
||||
AND direct_conversation_id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
admin_users = db.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE role = 'admin'
|
||||
AND status = 'active'
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
for admin_user in admin_users:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO team_members (
|
||||
team_id,
|
||||
user_id,
|
||||
membership_role,
|
||||
added_at
|
||||
) VALUES (?, ?, 'admin', ?)
|
||||
""",
|
||||
(general_team_id, admin_user["id"], now_iso()),
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_profiles (
|
||||
user_id,
|
||||
display_name,
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
'',
|
||||
'',
|
||||
'txt',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
NULL,
|
||||
?
|
||||
FROM users
|
||||
""",
|
||||
(now_iso(),),
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_settings (
|
||||
user_id,
|
||||
accent_color,
|
||||
name_color,
|
||||
custom_css,
|
||||
theme_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
presence_mode,
|
||||
dm_layout_mode,
|
||||
show_presence_badge,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
'#f7dce8',
|
||||
NULL,
|
||||
'',
|
||||
'dark',
|
||||
NULL,
|
||||
NULL,
|
||||
'public',
|
||||
'everyone',
|
||||
'online',
|
||||
'cards',
|
||||
1,
|
||||
?
|
||||
FROM users
|
||||
""",
|
||||
(now_iso(),),
|
||||
)
|
||||
|
||||
existing_message_attachment_counts = db.execute(
|
||||
"SELECT COUNT(*) AS count FROM message_attachments"
|
||||
).fetchone()["count"]
|
||||
|
||||
if existing_message_attachment_counts == 0:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, attachment_id
|
||||
FROM messages
|
||||
WHERE attachment_id IS NOT NULL
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO message_attachments (
|
||||
message_id,
|
||||
attachment_id,
|
||||
position
|
||||
) VALUES (?, ?, 0)
|
||||
""",
|
||||
(row["id"], row["attachment_id"]),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def bootstrap_admin() -> None:
|
||||
username = os.environ.get("ADMIN_USERNAME", "").strip().lower()
|
||||
password = os.environ.get("ADMIN_PASSWORD", "")
|
||||
|
||||
if not username or not password:
|
||||
return
|
||||
|
||||
with get_db() as db:
|
||||
existing = db.execute(
|
||||
"SELECT id FROM users WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET
|
||||
role = 'admin',
|
||||
status = 'active',
|
||||
is_admin = 1,
|
||||
is_active = 1
|
||||
WHERE id = ?
|
||||
""",
|
||||
(existing["id"],),
|
||||
)
|
||||
user_id = existing["id"]
|
||||
else:
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
username,
|
||||
password_hash,
|
||||
role,
|
||||
status,
|
||||
is_admin,
|
||||
is_active,
|
||||
created_at
|
||||
) VALUES (?, ?, 'admin', 'active', 1, 1, ?)
|
||||
""",
|
||||
(username, generate_password_hash(password), now_iso()),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_profiles (
|
||||
user_id,
|
||||
display_name,
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
updated_at
|
||||
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
||||
""",
|
||||
(user_id, username, now_iso()),
|
||||
)
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_settings (
|
||||
user_id,
|
||||
accent_color,
|
||||
name_color,
|
||||
custom_css,
|
||||
theme_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
presence_mode,
|
||||
dm_layout_mode,
|
||||
show_presence_badge,
|
||||
updated_at
|
||||
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
||||
""",
|
||||
(user_id, now_iso()),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
54
desktop/README.md
Normal file
54
desktop/README.md
Normal file
@ -0,0 +1,54 @@
|
||||
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
|
||||
|
||||
```
|
||||
1058
desktop/main.py
Normal file
1058
desktop/main.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -19,11 +19,11 @@ services:
|
||||
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-}
|
||||
CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-}
|
||||
PRIVATE_ACCESS_KEY: ${PRIVATE_ACCESS_KEY:-}
|
||||
|
||||
# VPS2 helper integration
|
||||
CALLS_BASE: ${CALLS_BASE:-https://calls.backend-3.com}
|
||||
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET:-}
|
||||
CALL_TOKEN_TTL_SECONDS: ${CALL_TOKEN_TTL_SECONDS:-60}
|
||||
# set CALLS_BASE to turn server config
|
||||
# helper/.env
|
||||
|
||||
volumes:
|
||||
- chat_data:/app/data
|
||||
@ -43,8 +43,10 @@ services:
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
caddy: 300.backend-3.com
|
||||
caddy: ${DOMAIN_URL:-angel.33.backend-3.com}
|
||||
caddy.reverse_proxy: "{{upstreams 5000}}"
|
||||
# set DOMAIN_URL to domain
|
||||
# .env
|
||||
|
||||
volumes:
|
||||
chat_data:
|
||||
|
||||
154
docs/CSS.md
Normal file
154
docs/CSS.md
Normal file
@ -0,0 +1,154 @@
|
||||
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;
|
||||
}
|
||||
|
||||
```
|
||||
109
docs/README.md
109
docs/README.md
@ -1,40 +1,79 @@
|
||||
|
||||
themes
|
||||
[themes](CSS.md)
|
||||
|
||||
<pre>
|
||||
lowercase classic
|
||||
monospace
|
||||
ver. 1.6
|
||||
|
||||
* {
|
||||
text-transform: lowercase;
|
||||
font-family: monospace;
|
||||
font-size: 94%;
|
||||
}
|
||||
div {
|
||||
font-size: 95%;
|
||||
font-weight: 600 !important;
|
||||
text-shadow: 0px 3px 33px #fff;
|
||||
}
|
||||
* {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
</pre>
|
||||
- sign in, register, logout, session check
|
||||
- private access key for sign up
|
||||
- csrf protection and session handling
|
||||
- user roles (user, moderator, admin)
|
||||
- account states (active, restricted, disabled, blacklisted)
|
||||
|
||||
- direct messages view
|
||||
- start new dm
|
||||
|
||||
- activity feed
|
||||
- highlighted mentions in feed
|
||||
- attachment previews
|
||||
- feed reactions
|
||||
|
||||
- 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>
|
||||
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
1073
features.py
File diff suppressed because it is too large
Load Diff
18
helper/.env.example
Normal file
18
helper/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
CALL_HELPER_SHARED_SECRET=replace-with-same-secret-as-main-app
|
||||
|
||||
PUBLIC_BASE=https://calls.backend-3.com
|
||||
HELPER_LOG_LEVEL=info
|
||||
HELPER_DEFAULT_ROUTE_MODE=direct
|
||||
|
||||
ENABLE_OVERLAY_METADATA=1
|
||||
|
||||
OVERLAY_YGG_ENABLED=0
|
||||
OVERLAY_YGG_BRIDGE_WSS=
|
||||
OVERLAY_YGG_BRIDGE_LABEL=ygg-bridge
|
||||
|
||||
OVERLAY_I2P_ENABLED=0
|
||||
OVERLAY_I2P_BRIDGE_HTTPS=
|
||||
OVERLAY_I2P_BRIDGE_LABEL=i2p-bridge
|
||||
|
||||
RELAY_ENABLED=1
|
||||
RELAY_WSS=
|
||||
14
helper/Dockerfile
Normal file
14
helper/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY server.js ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["npm", "start"]
|
||||
38
helper/docker-compose.yml
Normal file
38
helper/docker-compose.yml
Normal file
@ -0,0 +1,38 @@
|
||||
services:
|
||||
calls-helper:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: calls-helper
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PORT: 8080
|
||||
HOST: 0.0.0.0
|
||||
PUBLIC_BASE: ${PUBLIC_BASE:-https://calls.backend-3.com}
|
||||
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET}
|
||||
HELPER_LOG_LEVEL: ${HELPER_LOG_LEVEL:-info}
|
||||
HELPER_DEFAULT_ROUTE_MODE: ${HELPER_DEFAULT_ROUTE_MODE:-direct}
|
||||
|
||||
ENABLE_OVERLAY_METADATA: ${ENABLE_OVERLAY_METADATA:-1}
|
||||
|
||||
OVERLAY_YGG_ENABLED: ${OVERLAY_YGG_ENABLED:-0}
|
||||
OVERLAY_YGG_BRIDGE_WSS: ${OVERLAY_YGG_BRIDGE_WSS:-}
|
||||
OVERLAY_YGG_BRIDGE_LABEL: ${OVERLAY_YGG_BRIDGE_LABEL:-ygg-bridge}
|
||||
|
||||
OVERLAY_I2P_ENABLED: ${OVERLAY_I2P_ENABLED:-0}
|
||||
OVERLAY_I2P_BRIDGE_HTTPS: ${OVERLAY_I2P_BRIDGE_HTTPS:-}
|
||||
OVERLAY_I2P_BRIDGE_LABEL: ${OVERLAY_I2P_BRIDGE_LABEL:-i2p-bridge}
|
||||
|
||||
RELAY_ENABLED: ${RELAY_ENABLED:-1}
|
||||
RELAY_WSS: ${RELAY_WSS:-}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- privatenet
|
||||
labels:
|
||||
caddy: calls.backend-3.com
|
||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
||||
|
||||
networks:
|
||||
privatenet:
|
||||
external: true
|
||||
14
helper/package.json
Normal file
14
helper/package.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "calls-helper",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"gen:secret": "node scripts/generate-secret.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
4
helper/scripts/generate-secret.js
Normal file
4
helper/scripts/generate-secret.js
Normal file
@ -0,0 +1,4 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
const value = crypto.randomBytes(48).toString("base64url");
|
||||
console.log(value);
|
||||
761
helper/server.js
Normal file
761
helper/server.js
Normal file
@ -0,0 +1,761 @@
|
||||
import crypto from "crypto";
|
||||
import express from "express";
|
||||
import http from "http";
|
||||
import { WebSocketServer } from "ws";
|
||||
|
||||
const TURN_URLS = String(process.env.TURN_URLS || "")
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const TURN_USERNAME = String(process.env.TURN_USERNAME || "").trim();
|
||||
const TURN_PASSWORD = String(process.env.TURN_PASSWORD || "").trim();
|
||||
|
||||
const PORT = Number(process.env.PORT || 8080);
|
||||
const HOST = process.env.HOST || "0.0.0.0";
|
||||
|
||||
const CALL_HELPER_SHARED_SECRET = String(
|
||||
process.env.CALL_HELPER_SHARED_SECRET || ""
|
||||
).trim();
|
||||
|
||||
const PUBLIC_BASE = String(
|
||||
process.env.PUBLIC_BASE || `http://127.0.0.1:${PORT}`
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
const HELPER_LOG_LEVEL = String(process.env.HELPER_LOG_LEVEL || "info").toLowerCase();
|
||||
|
||||
const HELPER_DEFAULT_ROUTE_MODE = String(
|
||||
process.env.HELPER_DEFAULT_ROUTE_MODE || "direct"
|
||||
).toLowerCase();
|
||||
|
||||
const ENABLE_OVERLAY_METADATA = String(
|
||||
process.env.ENABLE_OVERLAY_METADATA || "1"
|
||||
) === "1";
|
||||
|
||||
const OVERLAY_YGG_ENABLED = String(
|
||||
process.env.OVERLAY_YGG_ENABLED || "0"
|
||||
) === "1";
|
||||
|
||||
const OVERLAY_YGG_BRIDGE_WSS = String(
|
||||
process.env.OVERLAY_YGG_BRIDGE_WSS || ""
|
||||
).trim();
|
||||
|
||||
const OVERLAY_YGG_BRIDGE_LABEL = String(
|
||||
process.env.OVERLAY_YGG_BRIDGE_LABEL || "ygg-bridge"
|
||||
).trim();
|
||||
|
||||
const OVERLAY_I2P_ENABLED = String(
|
||||
process.env.OVERLAY_I2P_ENABLED || "0"
|
||||
) === "1";
|
||||
|
||||
const OVERLAY_I2P_BRIDGE_HTTPS = String(
|
||||
process.env.OVERLAY_I2P_BRIDGE_HTTPS || ""
|
||||
).trim();
|
||||
|
||||
const OVERLAY_I2P_BRIDGE_LABEL = String(
|
||||
process.env.OVERLAY_I2P_BRIDGE_LABEL || "i2p-bridge"
|
||||
).trim();
|
||||
|
||||
const RELAY_ENABLED = String(process.env.RELAY_ENABLED || "1") === "1";
|
||||
const RELAY_WSS = String(process.env.RELAY_WSS || "").trim();
|
||||
|
||||
const CORS_ALLOWED_ORIGINS = String(
|
||||
process.env.CORS_ALLOWED_ORIGINS || "https://angel.33.backend-3.com"
|
||||
)
|
||||
.split(",")
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const allowedOrigins = new Set(CORS_ALLOWED_ORIGINS);
|
||||
|
||||
if (!CALL_HELPER_SHARED_SECRET) {
|
||||
console.error("missing CALL_HELPER_SHARED_SECRET");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "64kb" }));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
/**
|
||||
* in-memory state
|
||||
*
|
||||
* calls:
|
||||
* callId -> {
|
||||
* callId,
|
||||
* room,
|
||||
* routeMode,
|
||||
* participants: Map(peerId -> participant),
|
||||
* createdAt,
|
||||
* updatedAt
|
||||
* }
|
||||
*
|
||||
* tickets:
|
||||
* ticket -> {
|
||||
* ticket,
|
||||
* callId,
|
||||
* peerId,
|
||||
* sub,
|
||||
* username,
|
||||
* role,
|
||||
* room,
|
||||
* exp,
|
||||
* routeProfile
|
||||
* }
|
||||
*/
|
||||
const calls = new Map();
|
||||
const tickets = new Map();
|
||||
const recentLogs = [];
|
||||
const MAX_LOGS = 500;
|
||||
|
||||
function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function nowSeconds() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function isoNow() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function log(level, message, meta = {}) {
|
||||
const allowed = ["debug", "info", "warn", "error"];
|
||||
const currentIdx = Math.max(0, allowed.indexOf(HELPER_LOG_LEVEL));
|
||||
const entryIdx = Math.max(0, allowed.indexOf(level));
|
||||
|
||||
const item = {
|
||||
ts: isoNow(),
|
||||
level,
|
||||
message,
|
||||
meta
|
||||
};
|
||||
|
||||
recentLogs.push(item);
|
||||
if (recentLogs.length > MAX_LOGS) {
|
||||
recentLogs.shift();
|
||||
}
|
||||
|
||||
if (entryIdx >= currentIdx) {
|
||||
const method =
|
||||
level === "error" ? "error" :
|
||||
level === "warn" ? "warn" :
|
||||
"log";
|
||||
console[method](`[${item.ts}] [${level}] ${message}`, meta);
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedOrigin(origin) {
|
||||
if (!origin) return false;
|
||||
return allowedOrigins.has(origin);
|
||||
}
|
||||
|
||||
function applyCors(req, res) {
|
||||
const origin = String(req.headers.origin || "").trim();
|
||||
if (origin && isAllowedOrigin(origin)) {
|
||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
||||
res.setHeader("Vary", "Origin");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
}
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
applyCors(req, res);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return res.status(204).end();
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
function b64urlDecode(value) {
|
||||
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
||||
return Buffer.from(normalized, "base64");
|
||||
}
|
||||
|
||||
function verifySignedToken(token) {
|
||||
if (!token || typeof token !== "string" || !token.includes(".")) {
|
||||
throw new Error("invalid token format");
|
||||
}
|
||||
|
||||
const [payloadPart, sigPart] = token.split(".", 2);
|
||||
const payloadBytes = b64urlDecode(payloadPart);
|
||||
const sigBytes = b64urlDecode(sigPart);
|
||||
|
||||
const expected = crypto
|
||||
.createHmac("sha256", CALL_HELPER_SHARED_SECRET)
|
||||
.update(payloadBytes)
|
||||
.digest();
|
||||
|
||||
if (
|
||||
sigBytes.length !== expected.length ||
|
||||
!crypto.timingSafeEqual(sigBytes, expected)
|
||||
) {
|
||||
throw new Error("invalid token signature");
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(payloadBytes.toString("utf8"));
|
||||
} catch {
|
||||
throw new Error("invalid token payload json");
|
||||
}
|
||||
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("invalid token payload");
|
||||
}
|
||||
|
||||
if (!payload.exp || Number(payload.exp) <= nowSeconds()) {
|
||||
throw new Error("token expired");
|
||||
}
|
||||
|
||||
if (!payload.can_call) {
|
||||
throw new Error("token not authorized for calls");
|
||||
}
|
||||
|
||||
if (!payload.call_id || !payload.sub) {
|
||||
throw new Error("token missing required fields");
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function randomId(prefix = "") {
|
||||
return `${prefix}${crypto.randomBytes(8).toString("hex")}`;
|
||||
}
|
||||
|
||||
function chooseRouteMode(payload) {
|
||||
const requested = String(payload.route_mode || "").trim().toLowerCase();
|
||||
|
||||
if (requested === "overlay_ygg" && OVERLAY_YGG_ENABLED) {
|
||||
return "overlay_ygg";
|
||||
}
|
||||
|
||||
if (requested === "overlay_i2p" && OVERLAY_I2P_ENABLED) {
|
||||
return "overlay_i2p";
|
||||
}
|
||||
|
||||
if (HELPER_DEFAULT_ROUTE_MODE === "overlay_ygg" && OVERLAY_YGG_ENABLED) {
|
||||
return "overlay_ygg";
|
||||
}
|
||||
|
||||
if (HELPER_DEFAULT_ROUTE_MODE === "overlay_i2p" && OVERLAY_I2P_ENABLED) {
|
||||
return "overlay_i2p";
|
||||
}
|
||||
|
||||
return "direct";
|
||||
}
|
||||
|
||||
function buildRouteCandidates(callId) {
|
||||
const routes = [];
|
||||
|
||||
routes.push({
|
||||
kind: "direct",
|
||||
target: callId,
|
||||
priority: 100,
|
||||
available: true,
|
||||
label: "direct"
|
||||
});
|
||||
|
||||
if (ENABLE_OVERLAY_METADATA && OVERLAY_YGG_ENABLED) {
|
||||
routes.push({
|
||||
kind: "overlay_ygg",
|
||||
target: OVERLAY_YGG_BRIDGE_WSS || PUBLIC_BASE.replace(/^http/, "ws"),
|
||||
priority: 90,
|
||||
available: Boolean(OVERLAY_YGG_BRIDGE_WSS),
|
||||
label: OVERLAY_YGG_BRIDGE_LABEL
|
||||
});
|
||||
}
|
||||
|
||||
if (ENABLE_OVERLAY_METADATA && OVERLAY_I2P_ENABLED) {
|
||||
routes.push({
|
||||
kind: "overlay_i2p",
|
||||
target: OVERLAY_I2P_BRIDGE_HTTPS || PUBLIC_BASE,
|
||||
priority: 40,
|
||||
available: Boolean(OVERLAY_I2P_BRIDGE_HTTPS),
|
||||
label: OVERLAY_I2P_BRIDGE_LABEL
|
||||
});
|
||||
}
|
||||
|
||||
if (RELAY_ENABLED) {
|
||||
routes.push({
|
||||
kind: "relay",
|
||||
target: RELAY_WSS || PUBLIC_BASE.replace(/^http/, "ws"),
|
||||
priority: 20,
|
||||
available: true,
|
||||
label: "relay"
|
||||
});
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
function buildTransportPolicy(routeMode) {
|
||||
const order = [];
|
||||
|
||||
if (routeMode === "overlay_ygg") {
|
||||
order.push("overlay_ygg", "direct", "relay");
|
||||
} else if (routeMode === "overlay_i2p") {
|
||||
order.push("direct", "relay");
|
||||
} else {
|
||||
order.push("direct", "relay");
|
||||
}
|
||||
|
||||
return {
|
||||
preferred_order: order,
|
||||
allow_relay_fallback: RELAY_ENABLED,
|
||||
private_service_transport: OVERLAY_I2P_ENABLED ? "overlay_i2p" : null
|
||||
};
|
||||
}
|
||||
|
||||
function getIceServers() {
|
||||
const servers = [
|
||||
{
|
||||
urls: ["stun:stun.l.google.com:19302"]
|
||||
}
|
||||
];
|
||||
|
||||
if (TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD) {
|
||||
servers.push({
|
||||
urls: TURN_URLS,
|
||||
username: TURN_USERNAME,
|
||||
credential: TURN_PASSWORD
|
||||
});
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
function getOrCreateCall(callId, room, routeMode = "direct") {
|
||||
let call = calls.get(callId);
|
||||
|
||||
if (!call) {
|
||||
call = {
|
||||
callId,
|
||||
room,
|
||||
routeMode,
|
||||
participants: new Map(),
|
||||
createdAt: nowMs(),
|
||||
updatedAt: nowMs()
|
||||
};
|
||||
calls.set(callId, call);
|
||||
log("info", "created call", { callId, room, routeMode });
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
function buildWsUrl(ticket) {
|
||||
const publicBase = PUBLIC_BASE.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
||||
return `${publicBase}/ws?ticket=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
function sendJson(ws, payload) {
|
||||
if (!ws || ws.readyState !== ws.OPEN) return;
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function broadcast(call, payload, exceptPeerId = null) {
|
||||
for (const [peerId, participant] of call.participants.entries()) {
|
||||
if (exceptPeerId && peerId === exceptPeerId) continue;
|
||||
sendJson(participant.ws, payload);
|
||||
}
|
||||
}
|
||||
|
||||
function callSnapshot(call) {
|
||||
return {
|
||||
call_id: call.callId,
|
||||
room: call.room,
|
||||
route_mode: call.routeMode,
|
||||
participant_count: call.participants.size,
|
||||
created_at: new Date(call.createdAt).toISOString(),
|
||||
updated_at: new Date(call.updatedAt).toISOString(),
|
||||
participants: [...call.participants.values()].map(participant => ({
|
||||
peer_id: participant.peerId,
|
||||
sub: participant.sub,
|
||||
username: participant.username,
|
||||
role: participant.role,
|
||||
route_mode: participant.routeMode,
|
||||
joined_at: new Date(participant.joinedAt).toISOString()
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function statusSnapshot() {
|
||||
return {
|
||||
ok: true,
|
||||
ts: isoNow(),
|
||||
public_base: PUBLIC_BASE,
|
||||
route_defaults: {
|
||||
default_route_mode: HELPER_DEFAULT_ROUTE_MODE,
|
||||
overlay_metadata_enabled: ENABLE_OVERLAY_METADATA,
|
||||
relay_enabled: RELAY_ENABLED
|
||||
},
|
||||
overlays: {
|
||||
ygg: {
|
||||
enabled: OVERLAY_YGG_ENABLED,
|
||||
bridge_label: OVERLAY_YGG_BRIDGE_LABEL,
|
||||
bridge_wss: OVERLAY_YGG_BRIDGE_WSS || null
|
||||
},
|
||||
i2p: {
|
||||
enabled: OVERLAY_I2P_ENABLED,
|
||||
bridge_label: OVERLAY_I2P_BRIDGE_LABEL,
|
||||
bridge_https: OVERLAY_I2P_BRIDGE_HTTPS || null
|
||||
}
|
||||
},
|
||||
turn: {
|
||||
enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD),
|
||||
urls: TURN_URLS
|
||||
},
|
||||
cors: {
|
||||
allowed_origins: [...allowedOrigins]
|
||||
},
|
||||
counts: {
|
||||
active_calls: calls.size,
|
||||
active_tickets: tickets.size
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function removeParticipant(callId, peerId, notify = true) {
|
||||
const call = calls.get(callId);
|
||||
if (!call) return;
|
||||
|
||||
const participant = call.participants.get(peerId);
|
||||
if (!participant) return;
|
||||
|
||||
call.participants.delete(peerId);
|
||||
call.updatedAt = nowMs();
|
||||
|
||||
log("info", "participant removed", {
|
||||
callId,
|
||||
peerId,
|
||||
username: participant.username
|
||||
});
|
||||
|
||||
if (notify) {
|
||||
broadcast(call, {
|
||||
type: "participant-left",
|
||||
participantId: peerId,
|
||||
roomId: callId
|
||||
});
|
||||
}
|
||||
|
||||
if (call.participants.size === 0) {
|
||||
calls.delete(callId);
|
||||
log("info", "call removed because empty", { callId });
|
||||
}
|
||||
}
|
||||
|
||||
app.get("/healthz", (req, res) => {
|
||||
applyCors(req, res);
|
||||
res.json(statusSnapshot());
|
||||
});
|
||||
|
||||
app.get("/status", (req, res) => {
|
||||
applyCors(req, res);
|
||||
res.json({
|
||||
...statusSnapshot(),
|
||||
calls: [...calls.values()].map(callSnapshot)
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/logs", (req, res) => {
|
||||
applyCors(req, res);
|
||||
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)));
|
||||
res.json({
|
||||
ok: true,
|
||||
logs: recentLogs.slice(-limit)
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/api/call/session", (req, res) => {
|
||||
applyCors(req, res);
|
||||
|
||||
try {
|
||||
const token = req.body?.token;
|
||||
const payload = verifySignedToken(token);
|
||||
|
||||
const callId = String(payload.call_id);
|
||||
const room = String(payload.room || callId);
|
||||
const peerId = randomId("peer_");
|
||||
const ticket = randomId("ticket_");
|
||||
const routeMode = chooseRouteMode(payload);
|
||||
const routeCandidates = buildRouteCandidates(callId);
|
||||
const transportPolicy = buildTransportPolicy(routeMode);
|
||||
|
||||
getOrCreateCall(callId, room, routeMode);
|
||||
|
||||
tickets.set(ticket, {
|
||||
ticket,
|
||||
callId,
|
||||
peerId,
|
||||
sub: String(payload.sub),
|
||||
username: String(payload.username || peerId),
|
||||
role: String(payload.role || "user"),
|
||||
room,
|
||||
exp: Number(payload.exp),
|
||||
routeProfile: {
|
||||
routeMode,
|
||||
routeCandidates,
|
||||
transportPolicy
|
||||
}
|
||||
});
|
||||
|
||||
log("info", "created call session ticket", {
|
||||
callId,
|
||||
peerId,
|
||||
username: payload.username,
|
||||
routeMode,
|
||||
origin: req.headers.origin || null,
|
||||
turnEnabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD)
|
||||
});
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
call_id: callId,
|
||||
peer_id: peerId,
|
||||
ws_url: buildWsUrl(ticket),
|
||||
iceServers: getIceServers(),
|
||||
transport_policy: transportPolicy,
|
||||
route_candidates: routeCandidates
|
||||
});
|
||||
} catch (error) {
|
||||
log("warn", "call session rejected", {
|
||||
error: error.message,
|
||||
origin: req.headers.origin || null
|
||||
});
|
||||
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: error.message || "invalid call token"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const origin = String(req.headers.origin || "").trim();
|
||||
|
||||
if (origin && !isAllowedOrigin(origin)) {
|
||||
log("warn", "websocket upgrade rejected by origin", { origin });
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
if (url.pathname !== "/ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const ticket = url.searchParams.get("ticket");
|
||||
const ticketData = ticket ? tickets.get(ticket) : null;
|
||||
|
||||
if (!ticketData || ticketData.exp <= nowSeconds()) {
|
||||
log("warn", "websocket upgrade rejected by invalid/expired ticket", {
|
||||
origin: origin || null
|
||||
});
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(req, socket, head, ws => {
|
||||
ws._ticketData = ticketData;
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", ws => {
|
||||
const ticketData = ws._ticketData;
|
||||
if (!ticketData) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
tickets.delete(ticketData.ticket);
|
||||
|
||||
const call = getOrCreateCall(
|
||||
ticketData.callId,
|
||||
ticketData.room,
|
||||
ticketData.routeProfile?.routeMode || "direct"
|
||||
);
|
||||
|
||||
const participant = {
|
||||
peerId: ticketData.peerId,
|
||||
sub: ticketData.sub,
|
||||
username: ticketData.username,
|
||||
role: ticketData.role,
|
||||
room: ticketData.room,
|
||||
routeMode: ticketData.routeProfile?.routeMode || "direct",
|
||||
routeCandidates: ticketData.routeProfile?.routeCandidates || [],
|
||||
transportPolicy: ticketData.routeProfile?.transportPolicy || {},
|
||||
ws,
|
||||
joinedAt: nowMs()
|
||||
};
|
||||
|
||||
call.participants.set(ticketData.peerId, participant);
|
||||
call.updatedAt = nowMs();
|
||||
|
||||
log("info", "participant joined websocket", {
|
||||
callId: call.callId,
|
||||
peerId: participant.peerId,
|
||||
username: participant.username,
|
||||
routeMode: participant.routeMode
|
||||
});
|
||||
|
||||
sendJson(ws, {
|
||||
type: "joined",
|
||||
participantId: ticketData.peerId,
|
||||
roomId: call.callId,
|
||||
transportPolicy: participant.transportPolicy,
|
||||
routeCandidates: participant.routeCandidates
|
||||
});
|
||||
|
||||
for (const [peerId, existing] of call.participants.entries()) {
|
||||
if (peerId === ticketData.peerId) continue;
|
||||
sendJson(ws, {
|
||||
type: "participant-joined",
|
||||
participantId: peerId,
|
||||
roomId: call.callId,
|
||||
username: existing.username,
|
||||
routeMode: existing.routeMode
|
||||
});
|
||||
}
|
||||
|
||||
broadcast(
|
||||
call,
|
||||
{
|
||||
type: "participant-joined",
|
||||
participantId: ticketData.peerId,
|
||||
roomId: call.callId,
|
||||
username: participant.username,
|
||||
routeMode: participant.routeMode
|
||||
},
|
||||
ticketData.peerId
|
||||
);
|
||||
|
||||
ws.on("message", raw => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(String(raw));
|
||||
} catch {
|
||||
log("warn", "invalid ws json from peer", {
|
||||
callId: call.callId,
|
||||
peerId: participant.peerId
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const senderPeerId = ticketData.peerId;
|
||||
call.updatedAt = nowMs();
|
||||
|
||||
if (!msg || typeof msg !== "object" || !msg.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "hangup") {
|
||||
log("info", "peer requested hangup", {
|
||||
callId: call.callId,
|
||||
peerId: senderPeerId
|
||||
});
|
||||
removeParticipant(call.callId, senderPeerId, true);
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "offer" || msg.type === "answer") {
|
||||
const to = String(msg.to || "").trim();
|
||||
if (!to) return;
|
||||
|
||||
const target = call.participants.get(to);
|
||||
if (!target) {
|
||||
log("warn", "signal target missing", {
|
||||
callId: call.callId,
|
||||
from: senderPeerId,
|
||||
to,
|
||||
type: msg.type
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(target.ws, {
|
||||
type: msg.type,
|
||||
from: senderPeerId,
|
||||
sdp: msg.sdp,
|
||||
username: participant.username,
|
||||
roomId: call.callId,
|
||||
routeMode: participant.routeMode
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === "ice") {
|
||||
const to = String(msg.to || "").trim();
|
||||
if (!to) return;
|
||||
|
||||
const target = call.participants.get(to);
|
||||
if (!target) {
|
||||
log("warn", "ice target missing", {
|
||||
callId: call.callId,
|
||||
from: senderPeerId,
|
||||
to
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(target.ws, {
|
||||
type: "ice",
|
||||
from: senderPeerId,
|
||||
candidate: msg.candidate,
|
||||
sdpMid: msg.sdpMid,
|
||||
sdpMLineIndex: msg.sdpMLineIndex,
|
||||
username: participant.username,
|
||||
roomId: call.callId,
|
||||
routeMode: participant.routeMode
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
log("info", "websocket closed", {
|
||||
callId: call.callId,
|
||||
peerId: participant.peerId
|
||||
});
|
||||
removeParticipant(call.callId, ticketData.peerId, true);
|
||||
});
|
||||
|
||||
ws.on("error", error => {
|
||||
log("warn", "websocket error", {
|
||||
callId: call.callId,
|
||||
peerId: participant.peerId,
|
||||
error: error?.message || "unknown"
|
||||
});
|
||||
removeParticipant(call.callId, ticketData.peerId, true);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
log("info", "calls helper listening", {
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
publicBase: PUBLIC_BASE,
|
||||
overlays: {
|
||||
ygg: OVERLAY_YGG_ENABLED,
|
||||
i2p: OVERLAY_I2P_ENABLED
|
||||
},
|
||||
turn: {
|
||||
enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD),
|
||||
urls: TURN_URLS
|
||||
},
|
||||
corsAllowedOrigins: [...allowedOrigins]
|
||||
});
|
||||
});
|
||||
642
models.py
Normal file
642
models.py
Normal file
@ -0,0 +1,642 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from config import utc_now
|
||||
from db import get_db
|
||||
from auth import user_payload
|
||||
|
||||
ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$")
|
||||
|
||||
|
||||
MESSAGE_SELECT = """
|
||||
SELECT
|
||||
m.id,
|
||||
m.room_id,
|
||||
m.channel_id,
|
||||
m.direct_conversation_id,
|
||||
m.user_id,
|
||||
m.username,
|
||||
m.text,
|
||||
m.created_at,
|
||||
m.attachment_id,
|
||||
m.parent_message_id,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM messages AS thread_reply
|
||||
WHERE thread_reply.parent_message_id = m.id
|
||||
) AS thread_reply_count,
|
||||
a.original_name AS attachment_original_name,
|
||||
a.mime_type AS attachment_mime_type,
|
||||
a.size_bytes AS attachment_size_bytes,
|
||||
u.role AS user_role,
|
||||
us.name_color AS user_name_color
|
||||
FROM messages AS m
|
||||
JOIN users AS u
|
||||
ON u.id = m.user_id
|
||||
LEFT JOIN user_settings AS us
|
||||
ON us.user_id = u.id
|
||||
LEFT JOIN attachments AS a
|
||||
ON a.id = m.attachment_id
|
||||
"""
|
||||
|
||||
|
||||
def team_page_select() -> str:
|
||||
has_parent_page = False
|
||||
try:
|
||||
with get_db() as db:
|
||||
rows = db.execute("PRAGMA table_info(pages)").fetchall()
|
||||
has_parent_page = any(row["name"] == "parent_page_id" for row in rows)
|
||||
except Exception:
|
||||
has_parent_page = False
|
||||
|
||||
if has_parent_page:
|
||||
return """
|
||||
SELECT
|
||||
p.id,
|
||||
p.team_id,
|
||||
p.channel_id,
|
||||
p.parent_page_id,
|
||||
parent.slug AS parent_page_slug,
|
||||
parent.title AS parent_page_title,
|
||||
p.slug,
|
||||
p.title,
|
||||
p.body,
|
||||
p.visibility,
|
||||
p.page_type,
|
||||
p.created_by_user_id,
|
||||
p.updated_by_user_id,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
p.is_active,
|
||||
t.slug AS team_slug,
|
||||
t.name AS team_name,
|
||||
c.slug AS channel_slug,
|
||||
c.name AS channel_name
|
||||
FROM pages AS p
|
||||
JOIN teams AS t
|
||||
ON t.id = p.team_id
|
||||
LEFT JOIN channels AS c
|
||||
ON c.id = p.channel_id
|
||||
LEFT JOIN pages AS parent
|
||||
ON parent.id = p.parent_page_id
|
||||
"""
|
||||
|
||||
return """
|
||||
SELECT
|
||||
p.id,
|
||||
p.team_id,
|
||||
p.channel_id,
|
||||
NULL AS parent_page_id,
|
||||
NULL AS parent_page_slug,
|
||||
NULL AS parent_page_title,
|
||||
p.slug,
|
||||
p.title,
|
||||
p.body,
|
||||
p.visibility,
|
||||
p.page_type,
|
||||
p.created_by_user_id,
|
||||
p.updated_by_user_id,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
p.is_active,
|
||||
t.slug AS team_slug,
|
||||
t.name AS team_name,
|
||||
c.slug AS channel_slug,
|
||||
c.name AS channel_name
|
||||
FROM pages AS p
|
||||
JOIN teams AS t
|
||||
ON t.id = p.team_id
|
||||
LEFT JOIN channels AS c
|
||||
ON c.id = p.channel_id
|
||||
"""
|
||||
|
||||
|
||||
def team_slug_from_name(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")[:48].strip("-")
|
||||
|
||||
|
||||
def page_slug_from_title(title: str) -> str:
|
||||
return re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")[:48].strip("-")
|
||||
|
||||
|
||||
def room_by_id(room_id: int):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
created_by_user_id,
|
||||
is_active
|
||||
FROM rooms
|
||||
WHERE id = ?
|
||||
AND is_active = 1
|
||||
""",
|
||||
(room_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_room_by_slug(slug: str):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
created_by_user_id,
|
||||
is_active
|
||||
FROM rooms
|
||||
WHERE slug = ?
|
||||
AND is_active = 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def room_member(room_id: int, user_id: int) -> bool:
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM room_members
|
||||
WHERE room_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(room_id, user_id),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def room_owner(room_id: int, user_id: int) -> bool:
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM room_members
|
||||
WHERE room_id = ?
|
||||
AND user_id = ?
|
||||
AND membership_role = 'owner'
|
||||
""",
|
||||
(room_id, user_id),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def can_access_room(room, user: Optional[dict]) -> bool:
|
||||
if room["visibility"] == "public":
|
||||
return True
|
||||
if not user:
|
||||
return False
|
||||
if user["role"] in {"moderator", "admin"}:
|
||||
return True
|
||||
return room_member(room["id"], user["id"])
|
||||
|
||||
|
||||
def can_write_room(room, user: Optional[dict]) -> bool:
|
||||
if not user or user["status"] != "active":
|
||||
return False
|
||||
return can_access_room(room, user)
|
||||
|
||||
|
||||
def can_manage_room(room, user: Optional[dict]) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user["role"] == "admin":
|
||||
return True
|
||||
return room_owner(room["id"], user["id"])
|
||||
|
||||
|
||||
def room_payload(room, user: Optional[dict]) -> dict:
|
||||
return {
|
||||
"id": room["id"],
|
||||
"slug": room["slug"],
|
||||
"name": room["name"],
|
||||
"description": room["description"],
|
||||
"visibility": room["visibility"],
|
||||
"can_read": can_access_room(room, user),
|
||||
"can_write": can_write_room(room, user),
|
||||
"can_manage": can_manage_room(room, user),
|
||||
}
|
||||
|
||||
|
||||
def get_team_by_slug(slug: str):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
is_active
|
||||
FROM teams
|
||||
WHERE slug = ?
|
||||
AND is_active = 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_channel_by_id(channel_id: int):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.team_id,
|
||||
c.legacy_room_id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.description,
|
||||
c.created_by_user_id,
|
||||
c.created_at,
|
||||
c.is_active,
|
||||
t.slug AS team_slug,
|
||||
t.name AS team_name,
|
||||
t.description AS team_description,
|
||||
t.is_active AS team_is_active
|
||||
FROM channels AS c
|
||||
JOIN teams AS t
|
||||
ON t.id = c.team_id
|
||||
WHERE c.id = ?
|
||||
AND c.is_active = 1
|
||||
AND t.is_active = 1
|
||||
""",
|
||||
(channel_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_channel_by_slug(team_slug: str, channel_slug: str):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.team_id,
|
||||
c.legacy_room_id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.description,
|
||||
c.created_by_user_id,
|
||||
c.created_at,
|
||||
c.is_active,
|
||||
t.slug AS team_slug,
|
||||
t.name AS team_name,
|
||||
t.description AS team_description,
|
||||
t.is_active AS team_is_active
|
||||
FROM channels AS c
|
||||
JOIN teams AS t
|
||||
ON t.id = c.team_id
|
||||
WHERE t.slug = ?
|
||||
AND c.slug = ?
|
||||
AND t.is_active = 1
|
||||
AND c.is_active = 1
|
||||
""",
|
||||
(team_slug, channel_slug),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def get_channel_by_legacy_room_id(room_id: int):
|
||||
with get_db() as db:
|
||||
return db.execute(
|
||||
"""
|
||||
SELECT
|
||||
c.id,
|
||||
c.team_id,
|
||||
c.legacy_room_id,
|
||||
c.slug,
|
||||
c.name,
|
||||
c.description,
|
||||
c.created_by_user_id,
|
||||
c.created_at,
|
||||
c.is_active,
|
||||
t.slug AS team_slug,
|
||||
t.name AS team_name,
|
||||
t.description AS team_description,
|
||||
t.is_active AS team_is_active
|
||||
FROM channels AS c
|
||||
JOIN teams AS t
|
||||
ON t.id = c.team_id
|
||||
WHERE c.legacy_room_id = ?
|
||||
AND c.is_active = 1
|
||||
AND t.is_active = 1
|
||||
""",
|
||||
(room_id,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def team_member_role(team_id: int, user_id: int) -> Optional[str]:
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT membership_role
|
||||
FROM team_members
|
||||
WHERE team_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(team_id, user_id),
|
||||
).fetchone()
|
||||
return row["membership_role"] if row else None
|
||||
|
||||
|
||||
def is_team_member(team_id: int, user_id: int) -> bool:
|
||||
return team_member_role(team_id, user_id) is not None
|
||||
|
||||
|
||||
def is_team_admin(team_id: int, user: Optional[dict]) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
if user["role"] == "admin":
|
||||
return True
|
||||
return team_member_role(team_id, user["id"]) in {"owner", "admin"}
|
||||
|
||||
|
||||
def can_edit_team(team_id: int, user: Optional[dict]) -> bool:
|
||||
return is_team_admin(team_id, user)
|
||||
|
||||
|
||||
def can_access_team(team, user: Optional[dict]) -> bool:
|
||||
if not team["is_active"]:
|
||||
return False
|
||||
if user and user["role"] in {"moderator", "admin"}:
|
||||
return True
|
||||
return bool(user and is_team_member(team["id"], user["id"]))
|
||||
|
||||
|
||||
def legacy_room_for_channel(channel):
|
||||
if not channel["legacy_room_id"]:
|
||||
return None
|
||||
return room_by_id(channel["legacy_room_id"])
|
||||
|
||||
|
||||
def can_access_channel(channel, user: Optional[dict]) -> bool:
|
||||
if user and user["role"] in {"moderator", "admin"}:
|
||||
return True
|
||||
legacy_room = legacy_room_for_channel(channel)
|
||||
if legacy_room:
|
||||
return can_access_room(legacy_room, user)
|
||||
return bool(user and is_team_member(channel["team_id"], user["id"]))
|
||||
|
||||
|
||||
def can_write_channel(channel, user: Optional[dict]) -> bool:
|
||||
return bool(user and user["status"] == "active" and can_access_channel(channel, user))
|
||||
|
||||
|
||||
def team_payload(team, user: Optional[dict]) -> dict:
|
||||
membership_role = team_member_role(team["id"], user["id"]) if user else None
|
||||
return {
|
||||
"id": team["id"],
|
||||
"slug": team["slug"],
|
||||
"name": team["name"],
|
||||
"description": team["description"],
|
||||
"membership_role": membership_role,
|
||||
"can_read": can_access_team(team, user),
|
||||
"can_manage": is_team_admin(team["id"], user),
|
||||
}
|
||||
|
||||
|
||||
def channel_payload(channel, user: Optional[dict]) -> dict:
|
||||
return {
|
||||
"id": channel["id"],
|
||||
"team_id": channel["team_id"],
|
||||
"team_slug": channel["team_slug"],
|
||||
"team_name": channel["team_name"],
|
||||
"legacy_room_id": channel["legacy_room_id"],
|
||||
"slug": channel["slug"],
|
||||
"name": channel["name"],
|
||||
"description": channel["description"],
|
||||
"can_read": can_access_channel(channel, user),
|
||||
"can_write": can_write_channel(channel, user),
|
||||
"can_manage": is_team_admin(channel["team_id"], user),
|
||||
}
|
||||
|
||||
|
||||
def get_dm_for_user(dm_id: int, user_id: int):
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
dc.id,
|
||||
dc.created_at
|
||||
FROM direct_conversations AS dc
|
||||
JOIN direct_conversation_members AS dcm
|
||||
ON dcm.direct_conversation_id = dc.id
|
||||
WHERE dc.id = ?
|
||||
AND dcm.user_id = ?
|
||||
""",
|
||||
(dm_id, user_id),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def get_dm_other_user(dm_id: int, user_id: int):
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
up.display_name,
|
||||
up.status_message,
|
||||
us.name_color,
|
||||
us.presence_mode
|
||||
FROM direct_conversation_members AS dcm
|
||||
JOIN users AS u
|
||||
ON u.id = dcm.user_id
|
||||
LEFT JOIN user_profiles AS up
|
||||
ON up.user_id = u.id
|
||||
LEFT JOIN user_settings AS us
|
||||
ON us.user_id = u.id
|
||||
WHERE dcm.direct_conversation_id = ?
|
||||
AND dcm.user_id != ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(dm_id, user_id),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def validate_dm_access(dm_id: int, user: Optional[dict]) -> bool:
|
||||
if not user:
|
||||
return False
|
||||
return bool(get_dm_for_user(dm_id, user["id"]))
|
||||
|
||||
|
||||
def dm_payload(dm_id: int, user_id: int, resolve_name_color) -> dict:
|
||||
dm = get_dm_for_user(dm_id, user_id)
|
||||
other = get_dm_other_user(dm_id, user_id)
|
||||
return {
|
||||
"id": dm["id"],
|
||||
"created_at": dm["created_at"],
|
||||
"other_user": {
|
||||
"id": other["id"],
|
||||
"username": other["username"],
|
||||
"display_name": other["display_name"] or other["username"],
|
||||
"role": other["role"],
|
||||
"name_color": other["name_color"],
|
||||
"resolved_name_color": resolve_name_color(other["role"], other["name_color"]),
|
||||
"status_message": other["status_message"] or "",
|
||||
"presence_mode": other["presence_mode"] or "online",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def find_or_create_dm(user_a_id: int, user_b_id: int, now_iso_func) -> int:
|
||||
low_id, high_id = sorted([int(user_a_id), int(user_b_id)])
|
||||
with get_db() as db:
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT dc.id
|
||||
FROM direct_conversations AS dc
|
||||
JOIN direct_conversation_members AS m1
|
||||
ON m1.direct_conversation_id = dc.id AND m1.user_id = ?
|
||||
JOIN direct_conversation_members AS m2
|
||||
ON m2.direct_conversation_id = dc.id AND m2.user_id = ?
|
||||
WHERE dc.member_pair_key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(low_id, high_id, f"{low_id}:{high_id}"),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
|
||||
with get_db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
existing = db.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM direct_conversations
|
||||
WHERE member_pair_key = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(f"{low_id}:{high_id}",),
|
||||
).fetchone()
|
||||
if existing:
|
||||
db.commit()
|
||||
return existing["id"]
|
||||
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO direct_conversations (
|
||||
member_pair_key,
|
||||
created_at
|
||||
) VALUES (?, ?)
|
||||
""",
|
||||
(f"{low_id}:{high_id}", now_iso_func()),
|
||||
)
|
||||
dm_id = cursor.lastrowid
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO direct_conversation_members (
|
||||
direct_conversation_id,
|
||||
user_id,
|
||||
added_at
|
||||
) VALUES (?, ?, ?)
|
||||
""",
|
||||
(dm_id, low_id, now_iso_func()),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO direct_conversation_members (
|
||||
direct_conversation_id,
|
||||
user_id,
|
||||
added_at
|
||||
) VALUES (?, ?, ?)
|
||||
""",
|
||||
(dm_id, high_id, now_iso_func()),
|
||||
)
|
||||
db.commit()
|
||||
return dm_id
|
||||
|
||||
|
||||
def update_presence(
|
||||
db_write_func,
|
||||
user_id: int,
|
||||
view_name: Optional[str] = None,
|
||||
room_id: Optional[int] = None,
|
||||
channel_id: Optional[int] = None,
|
||||
direct_conversation_id: Optional[int] = None,
|
||||
) -> None:
|
||||
db_write_func(
|
||||
"""
|
||||
INSERT INTO presence_events (
|
||||
user_id,
|
||||
updated_at,
|
||||
view_name,
|
||||
room_id,
|
||||
channel_id,
|
||||
direct_conversation_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id)
|
||||
DO UPDATE SET
|
||||
updated_at = excluded.updated_at,
|
||||
view_name = excluded.view_name,
|
||||
room_id = excluded.room_id,
|
||||
channel_id = excluded.channel_id,
|
||||
direct_conversation_id = excluded.direct_conversation_id
|
||||
""",
|
||||
(
|
||||
user_id,
|
||||
time.time(),
|
||||
view_name,
|
||||
room_id,
|
||||
channel_id,
|
||||
direct_conversation_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def room_presence_count(ttl_seconds: int, room_id: int) -> int:
|
||||
cutoff = time.time() - ttl_seconds
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM presence_events
|
||||
WHERE room_id = ?
|
||||
AND updated_at >= ?
|
||||
""",
|
||||
(room_id, cutoff),
|
||||
).fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
|
||||
def channel_presence_count(ttl_seconds: int, channel_id: int) -> int:
|
||||
cutoff = time.time() - ttl_seconds
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM presence_events
|
||||
WHERE channel_id = ?
|
||||
AND updated_at >= ?
|
||||
""",
|
||||
(channel_id, cutoff),
|
||||
).fetchone()
|
||||
return row["count"] if row else 0
|
||||
|
||||
|
||||
def dm_presence_count(ttl_seconds: int, dm_id: int) -> int:
|
||||
cutoff = time.time() - ttl_seconds
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM presence_events
|
||||
WHERE direct_conversation_id = ?
|
||||
AND updated_at >= ?
|
||||
""",
|
||||
(dm_id, cutoff),
|
||||
).fetchone()
|
||||
return row["count"] if row else 0
|
||||
749
profiles.py
Normal file
749
profiles.py
Normal file
@ -0,0 +1,749 @@
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
import markdown
|
||||
from html.parser import HTMLParser
|
||||
from db import get_db
|
||||
|
||||
DEFAULT_ROLE_NAME_COLORS = {
|
||||
"admin": "#ff7b7b",
|
||||
"moderator": "#8ab4ff",
|
||||
"user": "#f5f5f7",
|
||||
}
|
||||
_ALLOWED_TAGS = {
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"p",
|
||||
"br",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"a",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
}
|
||||
|
||||
_ALLOWED_ATTRS = {
|
||||
"a": {"href", "title", "target", "rel"},
|
||||
}
|
||||
|
||||
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
|
||||
|
||||
|
||||
def _escape_attr(value: str) -> str:
|
||||
return html.escape(value, quote=True)
|
||||
|
||||
|
||||
def sanitize_html_fragment(raw: str) -> str:
|
||||
class SafeHtmlParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tag = tag.lower()
|
||||
if tag not in _ALLOWED_TAGS:
|
||||
return
|
||||
|
||||
allowed = _ALLOWED_ATTRS.get(tag, set())
|
||||
clean_attrs = []
|
||||
|
||||
for key, value in attrs:
|
||||
key = (key or "").lower()
|
||||
value = value or ""
|
||||
|
||||
if key not in allowed:
|
||||
continue
|
||||
|
||||
if tag == "a" and key == "href":
|
||||
lowered = value.strip().lower()
|
||||
if not lowered.startswith(_ALLOWED_SCHEMES):
|
||||
continue
|
||||
|
||||
clean_attrs.append((key, value))
|
||||
|
||||
if tag == "a":
|
||||
attr_keys = {key for key, _ in clean_attrs}
|
||||
if "target" not in attr_keys:
|
||||
clean_attrs.append(("target", "_blank"))
|
||||
if "rel" not in attr_keys:
|
||||
clean_attrs.append(("rel", "noopener noreferrer"))
|
||||
|
||||
attr_text = "".join(
|
||||
f' {key}="{_escape_attr(value)}"'
|
||||
for key, value in clean_attrs
|
||||
)
|
||||
self.parts.append(f"<{tag}{attr_text}>")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
tag = tag.lower()
|
||||
if tag in _ALLOWED_TAGS and tag not in {"br"}:
|
||||
self.parts.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(html.escape(data))
|
||||
|
||||
def handle_entityref(self, name):
|
||||
self.parts.append(f"&{name};")
|
||||
|
||||
def handle_charref(self, name):
|
||||
self.parts.append(f"&#{name};")
|
||||
|
||||
parser = SafeHtmlParser()
|
||||
parser.feed(raw or "")
|
||||
parser.close()
|
||||
return "".join(parser.parts)
|
||||
|
||||
PROFILE_BIO_MODES = {"txt", "md", "html"}
|
||||
THEME_MODES = {"dark", "light"}
|
||||
PAGE_VISIBILITIES = {"team", "public"}
|
||||
PAGE_TYPES = {"project", "document", "update", "resource"}
|
||||
TEAM_MEMBER_ROLES = {"owner", "admin", "member"}
|
||||
PROFILE_VISIBILITIES = {"public", "members", "private"}
|
||||
LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"}
|
||||
PRESENCE_MODES = {"online", "silent", "focus", "dnd"}
|
||||
DM_LAYOUT_MODES = {"cards", "compact"}
|
||||
REACTION_EMOJI_RE = re.compile(r"^.{1,16}$")
|
||||
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE)
|
||||
|
||||
|
||||
def valid_hex_color(value: object) -> bool:
|
||||
return bool(isinstance(value, str) and HEX_COLOR_RE.fullmatch(value.strip()))
|
||||
|
||||
|
||||
def normalized_hex_color(value: object) -> Optional[str]:
|
||||
if not valid_hex_color(value):
|
||||
return None
|
||||
return str(value).strip().lower()
|
||||
|
||||
|
||||
def role_default_name_color(role: Optional[str]) -> str:
|
||||
return DEFAULT_ROLE_NAME_COLORS.get(str(role or "user"), DEFAULT_ROLE_NAME_COLORS["user"])
|
||||
|
||||
|
||||
def resolve_name_color(user_role: Optional[str], explicit_color: Optional[str]) -> str:
|
||||
return normalized_hex_color(explicit_color) or role_default_name_color(user_role)
|
||||
|
||||
|
||||
def render_markdown_to_safe_html(text: str) -> str:
|
||||
rendered = markdown.markdown(
|
||||
text or "",
|
||||
extensions=[
|
||||
"extra",
|
||||
"sane_lists",
|
||||
"nl2br",
|
||||
"tables",
|
||||
"fenced_code",
|
||||
],
|
||||
output_format="html5",
|
||||
)
|
||||
return sanitize_html_fragment(rendered)
|
||||
|
||||
|
||||
def safe_profile_bio_html(bio: str, bio_mode: str) -> str:
|
||||
mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt"
|
||||
if mode == "html":
|
||||
return sanitize_html_fragment(bio or "")
|
||||
if mode == "md":
|
||||
return render_markdown_to_safe_html(bio or "")
|
||||
return "<p>" + html.escape(bio or "").replace("\n", "<br>") + "</p>"
|
||||
|
||||
|
||||
def extract_page_reference_slugs(markdown_text: str) -> list[str]:
|
||||
text = str(markdown_text or "")
|
||||
matches = PAGE_LINK_RE.findall(text)
|
||||
seen = set()
|
||||
slugs = []
|
||||
for slug in matches:
|
||||
normalized = slug.lower().strip()
|
||||
if not normalized or normalized in seen:
|
||||
continue
|
||||
seen.add(normalized)
|
||||
slugs.append(normalized)
|
||||
return slugs
|
||||
|
||||
|
||||
def detect_website_brand(website: str) -> dict:
|
||||
value = str(website or "").strip().lower()
|
||||
if not value:
|
||||
return {"host": "", "brand": "web", "label": "website"}
|
||||
|
||||
host = value
|
||||
host = host.replace("https://", "").replace("http://", "")
|
||||
host = host.split("/", 1)[0]
|
||||
host = host.removeprefix("www.")
|
||||
|
||||
mappings = [
|
||||
("github.com", "github", "GitHub"),
|
||||
("youtube.com", "youtube", "YouTube"),
|
||||
("youtu.be", "youtube", "YouTube"),
|
||||
("spotify.com", "spotify", "Spotify"),
|
||||
("soundcloud.com", "soundcloud", "SoundCloud"),
|
||||
("twitter.com", "x", "X"),
|
||||
("x.com", "x", "X"),
|
||||
]
|
||||
|
||||
for suffix, brand, label in mappings:
|
||||
if host.endswith(suffix):
|
||||
return {"host": host, "brand": brand, "label": label}
|
||||
|
||||
return {"host": host, "brand": "web", "label": "website"}
|
||||
|
||||
|
||||
def sqlite_table_exists(table_name: str) -> bool:
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name = ?
|
||||
""",
|
||||
(table_name,),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
|
||||
def sqlite_column_exists(table_name: str, column_name: str) -> bool:
|
||||
with get_db() as db:
|
||||
rows = db.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||
return any(row["name"] == column_name for row in rows)
|
||||
|
||||
|
||||
def load_page_children(page_id: int) -> list[dict]:
|
||||
if not sqlite_column_exists("pages", "parent_page_id"):
|
||||
return []
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
updated_at
|
||||
FROM pages
|
||||
WHERE parent_page_id = ?
|
||||
AND is_active = 1
|
||||
ORDER BY title COLLATE NOCASE, id ASC
|
||||
""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def load_page_backlinks(page_id: int, page_slug: str, team_id: int) -> list[dict]:
|
||||
pattern_1 = f"%]({page_slug})%"
|
||||
pattern_2 = f"%/pages/{page_slug})%"
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
title,
|
||||
updated_at
|
||||
FROM pages
|
||||
WHERE team_id = ?
|
||||
AND is_active = 1
|
||||
AND id != ?
|
||||
AND (body LIKE ? OR body LIKE ?)
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
""",
|
||||
(team_id, page_id, pattern_1, pattern_2),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def load_page_comments(page_id: int) -> list[dict]:
|
||||
if not sqlite_table_exists("page_comments"):
|
||||
return []
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
pc.id,
|
||||
pc.page_id,
|
||||
pc.user_id,
|
||||
pc.username,
|
||||
pc.body,
|
||||
pc.created_at,
|
||||
pc.updated_at,
|
||||
u.role AS user_role,
|
||||
us.name_color AS user_name_color
|
||||
FROM page_comments AS pc
|
||||
JOIN users AS u
|
||||
ON u.id = pc.user_id
|
||||
LEFT JOIN user_settings AS us
|
||||
ON us.user_id = u.id
|
||||
WHERE pc.page_id = ?
|
||||
AND pc.deleted_at IS NULL
|
||||
ORDER BY pc.created_at ASC, pc.id ASC
|
||||
""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"page_id": row["page_id"],
|
||||
"user_id": row["user_id"],
|
||||
"username": row["username"],
|
||||
"body": row["body"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"user_role": row["user_role"],
|
||||
"user_name_color": resolve_name_color(row["user_role"], row["user_name_color"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def reaction_summary_for_message(message_id: int, user: Optional[dict]) -> list[dict]:
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
emoji,
|
||||
COUNT(*) AS count,
|
||||
MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted
|
||||
FROM message_reactions
|
||||
WHERE message_id = ?
|
||||
GROUP BY emoji
|
||||
ORDER BY count DESC, emoji ASC
|
||||
""",
|
||||
(user["id"] if user else -1, message_id),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"emoji": row["emoji"],
|
||||
"count": row["count"],
|
||||
"reacted": bool(row["reacted"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def build_profile_privacy_context(viewer: Optional[dict], target_user_id: int) -> dict:
|
||||
is_self = bool(viewer and viewer["id"] == target_user_id)
|
||||
is_staff = bool(viewer and viewer["role"] in {"moderator", "admin"})
|
||||
is_authenticated = bool(viewer)
|
||||
return {
|
||||
"is_self": is_self,
|
||||
"is_staff": is_staff,
|
||||
"is_authenticated": is_authenticated,
|
||||
}
|
||||
|
||||
|
||||
def can_view_profile_record(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
||||
visibility = "public"
|
||||
if settings_row and "profile_visibility" in settings_row.keys():
|
||||
visibility = (settings_row["profile_visibility"] or "public").lower()
|
||||
if visibility not in PROFILE_VISIBILITIES:
|
||||
visibility = "public"
|
||||
|
||||
ctx = build_profile_privacy_context(viewer, target_user_id)
|
||||
|
||||
if visibility == "public":
|
||||
return True
|
||||
if visibility == "members":
|
||||
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
|
||||
if visibility == "private":
|
||||
return ctx["is_self"] or ctx["is_staff"]
|
||||
return True
|
||||
|
||||
|
||||
def can_view_last_active(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
||||
visibility = "everyone"
|
||||
if settings_row and "last_active_visibility" in settings_row.keys():
|
||||
visibility = (settings_row["last_active_visibility"] or "everyone").lower()
|
||||
if visibility not in LAST_ACTIVE_VISIBILITIES:
|
||||
visibility = "everyone"
|
||||
|
||||
ctx = build_profile_privacy_context(viewer, target_user_id)
|
||||
|
||||
if visibility == "everyone":
|
||||
return True
|
||||
if visibility == "members":
|
||||
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
|
||||
if visibility == "nobody":
|
||||
return ctx["is_self"] or ctx["is_staff"]
|
||||
return False
|
||||
|
||||
|
||||
def profile_presence_badge_visible(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
||||
ctx = build_profile_privacy_context(viewer, target_user_id)
|
||||
if ctx["is_self"] or ctx["is_staff"]:
|
||||
return True
|
||||
if settings_row and "show_presence_badge" in settings_row.keys():
|
||||
return bool(settings_row["show_presence_badge"])
|
||||
return True
|
||||
|
||||
|
||||
def profile_payload(user_payload_func, user_row, profile_row, settings_row, viewer: Optional[dict]) -> dict:
|
||||
is_self = bool(viewer and viewer["id"] == user_row["id"])
|
||||
status_payload = user_payload_func(user_row, settings_row)
|
||||
bio = profile_row["bio"] or ""
|
||||
bio_mode = profile_row["bio_mode"] or "txt"
|
||||
explicit_name_color = settings_row["name_color"] if settings_row else None
|
||||
resolved = resolve_name_color(user_row["role"], explicit_name_color)
|
||||
|
||||
profile_visibility = settings_row["profile_visibility"] if settings_row and "profile_visibility" in settings_row.keys() else "public"
|
||||
if profile_visibility not in PROFILE_VISIBILITIES:
|
||||
profile_visibility = "public"
|
||||
|
||||
last_active_visibility = settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone"
|
||||
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
|
||||
last_active_visibility = "everyone"
|
||||
|
||||
presence_mode = settings_row["presence_mode"] if settings_row and "presence_mode" in settings_row.keys() else "online"
|
||||
if presence_mode not in PRESENCE_MODES:
|
||||
presence_mode = "online"
|
||||
|
||||
dm_layout_mode = settings_row["dm_layout_mode"] if settings_row and "dm_layout_mode" in settings_row.keys() else "cards"
|
||||
if dm_layout_mode not in DM_LAYOUT_MODES:
|
||||
dm_layout_mode = "cards"
|
||||
|
||||
show_presence_badge = profile_presence_badge_visible(settings_row, viewer, user_row["id"])
|
||||
|
||||
last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None
|
||||
if not can_view_last_active(settings_row, viewer, user_row["id"]):
|
||||
last_active_at = None
|
||||
|
||||
website = profile_row["website"] or ""
|
||||
website_meta = detect_website_brand(website) if website else {"host": "", "brand": "web", "label": "website"}
|
||||
|
||||
return {
|
||||
"id": user_row["id"],
|
||||
"username": user_row["username"],
|
||||
"role": user_row["role"],
|
||||
"status": status_payload["status"],
|
||||
"presence_mode": presence_mode,
|
||||
"created_at": user_row["created_at"],
|
||||
"last_active_at": last_active_at,
|
||||
"display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"],
|
||||
"badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "",
|
||||
"bio": bio,
|
||||
"bio_mode": bio_mode,
|
||||
"bio_html": safe_profile_bio_html(bio, bio_mode),
|
||||
"website": website,
|
||||
"website_meta": website_meta,
|
||||
"location": profile_row["location"] or "",
|
||||
"status_message": profile_row["status_message"] if "status_message" in profile_row.keys() else "",
|
||||
"banner_attachment_id": profile_row["banner_attachment_id"] if "banner_attachment_id" in profile_row.keys() else None,
|
||||
"banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if ("banner_attachment_id" in profile_row.keys() and profile_row["banner_attachment_id"]) else "",
|
||||
"updated_at": profile_row["updated_at"],
|
||||
"is_self": is_self,
|
||||
"name_color": explicit_name_color,
|
||||
"resolved_name_color": resolved,
|
||||
"profile_background_color": settings_row["profile_background_color"] if settings_row and "profile_background_color" in settings_row.keys() else None,
|
||||
"profile_banner_color": settings_row["profile_banner_color"] if settings_row and "profile_banner_color" in settings_row.keys() else None,
|
||||
"profile_visibility": profile_visibility,
|
||||
"last_active_visibility": last_active_visibility,
|
||||
"show_presence_badge": show_presence_badge,
|
||||
"dm_layout_mode": dm_layout_mode,
|
||||
}
|
||||
|
||||
|
||||
def page_payload(page, user: Optional[dict], is_team_member_func, is_team_admin_func) -> dict:
|
||||
can_read = bool(
|
||||
page["visibility"] == "public"
|
||||
or (
|
||||
user
|
||||
and (
|
||||
user["role"] in {"moderator", "admin"}
|
||||
or is_team_member_func(page["team_id"], user["id"])
|
||||
)
|
||||
)
|
||||
)
|
||||
rendered_html = render_markdown_to_safe_html(page["body"] or "")
|
||||
parent_page_id = page["parent_page_id"] if "parent_page_id" in page.keys() else None
|
||||
parent_page_slug = page["parent_page_slug"] if "parent_page_slug" in page.keys() else None
|
||||
parent_page_title = page["parent_page_title"] if "parent_page_title" in page.keys() else None
|
||||
return {
|
||||
"id": page["id"],
|
||||
"team_id": page["team_id"],
|
||||
"team_slug": page["team_slug"],
|
||||
"team_name": page["team_name"],
|
||||
"channel_id": page["channel_id"],
|
||||
"channel_slug": page["channel_slug"],
|
||||
"channel_name": page["channel_name"],
|
||||
"parent_page_id": parent_page_id,
|
||||
"parent_page_slug": parent_page_slug,
|
||||
"parent_page_title": parent_page_title,
|
||||
"slug": page["slug"],
|
||||
"title": page["title"],
|
||||
"body": page["body"],
|
||||
"body_html": rendered_html,
|
||||
"visibility": page["visibility"],
|
||||
"page_type": page["page_type"],
|
||||
"created_by_user_id": page["created_by_user_id"],
|
||||
"updated_by_user_id": page["updated_by_user_id"],
|
||||
"created_at": page["created_at"],
|
||||
"updated_at": page["updated_at"],
|
||||
"can_read": can_read,
|
||||
"can_edit": bool(
|
||||
user
|
||||
and (
|
||||
user["role"] in {"moderator", "admin"}
|
||||
or is_team_admin_func(page["team_id"], user)
|
||||
)
|
||||
),
|
||||
"children": load_page_children(page["id"]),
|
||||
"backlinks": load_page_backlinks(
|
||||
page["id"],
|
||||
page["slug"],
|
||||
page["team_id"],
|
||||
),
|
||||
"references": extract_page_reference_slugs(page["body"] or ""),
|
||||
"comments": load_page_comments(page["id"]),
|
||||
}
|
||||
|
||||
|
||||
def list_dms_for_user(user_id: int) -> list[dict]:
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
dc.id,
|
||||
dc.created_at,
|
||||
other.id AS other_user_id,
|
||||
other.username AS other_username,
|
||||
other.role AS other_role,
|
||||
us.name_color AS other_name_color,
|
||||
up.display_name AS other_display_name,
|
||||
up.status_message AS other_status_message,
|
||||
us.presence_mode AS other_presence_mode,
|
||||
(
|
||||
SELECT m.text
|
||||
FROM messages AS m
|
||||
WHERE m.direct_conversation_id = dc.id
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 1
|
||||
) AS last_message_text,
|
||||
(
|
||||
SELECT m.created_at
|
||||
FROM messages AS m
|
||||
WHERE m.direct_conversation_id = dc.id
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 1
|
||||
) AS last_message_created_at,
|
||||
(
|
||||
SELECT m.id
|
||||
FROM messages AS m
|
||||
WHERE m.direct_conversation_id = dc.id
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 1
|
||||
) AS last_message_id,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM messages AS m2
|
||||
WHERE m2.direct_conversation_id = dc.id
|
||||
AND m2.user_id != ?
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM message_receipts AS mr
|
||||
WHERE mr.message_id = m2.id
|
||||
AND mr.user_id = ?
|
||||
)
|
||||
) AS unread_count
|
||||
FROM direct_conversations AS dc
|
||||
JOIN direct_conversation_members AS self_member
|
||||
ON self_member.direct_conversation_id = dc.id
|
||||
JOIN direct_conversation_members AS other_member
|
||||
ON other_member.direct_conversation_id = dc.id
|
||||
AND other_member.user_id != self_member.user_id
|
||||
JOIN users AS other
|
||||
ON other.id = other_member.user_id
|
||||
LEFT JOIN user_profiles AS up
|
||||
ON up.user_id = other.id
|
||||
LEFT JOIN user_settings AS us
|
||||
ON us.user_id = other.id
|
||||
WHERE self_member.user_id = ?
|
||||
ORDER BY COALESCE(last_message_id, 0) DESC, dc.id DESC
|
||||
""",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"created_at": row["created_at"],
|
||||
"other_user": {
|
||||
"id": row["other_user_id"],
|
||||
"username": row["other_username"],
|
||||
"display_name": row["other_display_name"] or row["other_username"],
|
||||
"role": row["other_role"],
|
||||
"name_color": row["other_name_color"],
|
||||
"resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]),
|
||||
"status_message": row["other_status_message"] or "",
|
||||
"presence_mode": row["other_presence_mode"] or "online",
|
||||
},
|
||||
"last_message_text": row["last_message_text"],
|
||||
"last_message_created_at": row["last_message_created_at"],
|
||||
"last_message_id": row["last_message_id"],
|
||||
"unread_count": int(row["unread_count"] or 0),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def richer_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
|
||||
with get_db() as db:
|
||||
edited = db.execute(
|
||||
"""
|
||||
SELECT created_at
|
||||
FROM message_edits
|
||||
WHERE message_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(row["id"],),
|
||||
).fetchone()
|
||||
|
||||
receipt_count = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM message_receipts
|
||||
WHERE message_id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
).fetchone()["count"]
|
||||
|
||||
seen_by_user = False
|
||||
is_bookmarked = False
|
||||
is_pinned = False
|
||||
pin_row = None
|
||||
|
||||
bookmark_table_exists = bool(
|
||||
db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name = 'message_bookmarks'
|
||||
"""
|
||||
).fetchone()
|
||||
)
|
||||
pin_table_exists = bool(
|
||||
db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name = 'message_pins'
|
||||
"""
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
if user:
|
||||
seen_by_user = bool(
|
||||
db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM message_receipts
|
||||
WHERE message_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(row["id"], user["id"]),
|
||||
).fetchone()
|
||||
)
|
||||
if bookmark_table_exists:
|
||||
is_bookmarked = bool(
|
||||
db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM message_bookmarks
|
||||
WHERE message_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(row["id"], user["id"]),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
if pin_table_exists:
|
||||
pin_row = db.execute(
|
||||
"""
|
||||
SELECT pinned_at, pinned_by_user_id
|
||||
FROM message_pins
|
||||
WHERE message_id = ?
|
||||
""",
|
||||
(row["id"],),
|
||||
).fetchone()
|
||||
is_pinned = bool(pin_row)
|
||||
|
||||
primary_attachment = attachment_payload_legacy_from_message_select_row(row)
|
||||
attachments = attachment_list_for_message(row["id"])
|
||||
if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments):
|
||||
attachments.insert(0, primary_attachment)
|
||||
|
||||
user_role = row["user_role"] if "user_role" in row.keys() else "user"
|
||||
user_name_color = row["user_name_color"] if "user_name_color" in row.keys() else None
|
||||
resolved_color = resolve_name_color(user_role, user_name_color)
|
||||
|
||||
return {
|
||||
"id": row["id"],
|
||||
"room_id": row["room_id"],
|
||||
"channel_id": row["channel_id"],
|
||||
"direct_conversation_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
|
||||
"user_id": row["user_id"],
|
||||
"username": row["username"],
|
||||
"user_role": user_role,
|
||||
"user_name_color": resolved_color,
|
||||
"text": row["text"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": edited["created_at"] if edited else None,
|
||||
"is_edited": bool(edited),
|
||||
"parent_message_id": row["parent_message_id"],
|
||||
"thread_reply_count": row["thread_reply_count"],
|
||||
"attachment": primary_attachment,
|
||||
"attachments": attachments,
|
||||
"read_receipt_count": receipt_count,
|
||||
"seen_by_current_user": seen_by_user,
|
||||
"reactions": reaction_summary_for_message(row["id"], user),
|
||||
"is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]),
|
||||
"is_pinned": is_pinned,
|
||||
"pinned_at": pin_row["pinned_at"] if pin_row else None,
|
||||
"pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None,
|
||||
"is_bookmarked": is_bookmarked,
|
||||
}
|
||||
|
||||
|
||||
def feed_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
|
||||
payload = richer_message_payload(
|
||||
row,
|
||||
user,
|
||||
attachment_payload_legacy_from_message_select_row,
|
||||
attachment_list_for_message,
|
||||
)
|
||||
payload.update(
|
||||
{
|
||||
"room_slug": row["room_slug"] if "room_slug" in row.keys() else None,
|
||||
"room_name": row["room_name"] if "room_name" in row.keys() else None,
|
||||
"room_visibility": row["room_visibility"] if "room_visibility" in row.keys() else None,
|
||||
"team_slug": row["team_slug"] if "team_slug" in row.keys() else None,
|
||||
"team_name": row["team_name"] if "team_name" in row.keys() else None,
|
||||
"channel_slug": row["channel_slug"] if "channel_slug" in row.keys() else None,
|
||||
"channel_name": row["channel_name"] if "channel_name" in row.keys() else None,
|
||||
"dm_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
|
||||
"is_mention": bool(
|
||||
user and f"@{user['username'].lower()}" in payload["text"].lower()
|
||||
),
|
||||
}
|
||||
)
|
||||
return payload
|
||||
23
routes/__init__.py
Normal file
23
routes/__init__.py
Normal file
@ -0,0 +1,23 @@
|
||||
from .attachments import register_attachment_routes
|
||||
from .messages import register_message_routes
|
||||
from .feed import register_feed_routes
|
||||
from .presence import register_presence_routes
|
||||
from .rooms import register_room_routes
|
||||
from .teams import register_team_routes
|
||||
from .profiles import register_profile_routes
|
||||
from .dms import register_dm_routes
|
||||
from .calls import register_call_routes
|
||||
from .admin import register_admin_routes
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
register_attachment_routes(app)
|
||||
register_message_routes(app)
|
||||
register_feed_routes(app)
|
||||
register_presence_routes(app)
|
||||
register_room_routes(app)
|
||||
register_team_routes(app)
|
||||
register_profile_routes(app)
|
||||
register_dm_routes(app)
|
||||
register_call_routes(app)
|
||||
register_admin_routes(app)
|
||||
268
routes/admin.py
Normal file
268
routes/admin.py
Normal file
@ -0,0 +1,268 @@
|
||||
from .common import (
|
||||
ROLES,
|
||||
STATUSES,
|
||||
now_iso,
|
||||
parse_iso,
|
||||
utc_now,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
require_moderator,
|
||||
require_admin,
|
||||
audit,
|
||||
hash_identifier,
|
||||
safe_json,
|
||||
jsonify,
|
||||
get_db,
|
||||
db_write,
|
||||
)
|
||||
|
||||
|
||||
def register_admin_routes(app):
|
||||
@app.route("/api/admin/users")
|
||||
def admin_list_users():
|
||||
moderator, error = require_moderator()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
u.status,
|
||||
u.restricted_until,
|
||||
u.created_at,
|
||||
u.last_login_at,
|
||||
COUNT(m.id) AS message_count
|
||||
FROM users AS u
|
||||
LEFT JOIN messages AS m
|
||||
ON m.user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY
|
||||
CASE u.role
|
||||
WHEN 'admin' THEN 0
|
||||
WHEN 'moderator' THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
u.username COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"status": row["status"],
|
||||
"restricted_until": row["restricted_until"],
|
||||
"created_at": row["created_at"],
|
||||
"last_login_at": row["last_login_at"],
|
||||
"message_count": row["message_count"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
"requested_by": moderator["username"],
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/admin/users/<int:user_id>", methods=["PATCH"])
|
||||
def admin_update_user(user_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("admin")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
with get_db() as db:
|
||||
target = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
restricted_until
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
return jsonify({"error": "User not found."}), 404
|
||||
|
||||
if target["id"] == admin["id"]:
|
||||
return jsonify({"error": "You cannot modify your own account here."}), 400
|
||||
|
||||
role = data.get("role", target["role"])
|
||||
status = data.get("status", target["status"])
|
||||
restricted_until = data.get("restricted_until", target["restricted_until"])
|
||||
|
||||
if role not in ROLES:
|
||||
return jsonify({"error": "Invalid role."}), 400
|
||||
if status not in STATUSES:
|
||||
return jsonify({"error": "Invalid account status."}), 400
|
||||
|
||||
if status == "restricted" and restricted_until:
|
||||
parsed = parse_iso(restricted_until)
|
||||
if not parsed or parsed <= utc_now():
|
||||
return jsonify({"error": "restricted_until must be a future ISO timestamp."}), 400
|
||||
elif status != "restricted":
|
||||
restricted_until = None
|
||||
|
||||
if target["role"] == "admin" and role != "admin":
|
||||
with get_db() as db:
|
||||
count = db.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM users
|
||||
WHERE role = 'admin'
|
||||
AND status = 'active'
|
||||
"""
|
||||
).fetchone()["count"]
|
||||
if count <= 1:
|
||||
return jsonify({"error": "At least one active administrator is required."}), 400
|
||||
|
||||
is_admin_value = 1 if role == "admin" else 0
|
||||
is_active = 1 if status in {"active", "restricted"} else 0
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE users
|
||||
SET
|
||||
role = ?,
|
||||
status = ?,
|
||||
restricted_until = ?,
|
||||
is_admin = ?,
|
||||
is_active = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(role, status, restricted_until, is_admin_value, is_active, user_id),
|
||||
)
|
||||
|
||||
if status == "blacklisted":
|
||||
db_write(
|
||||
"""
|
||||
INSERT OR REPLACE INTO blacklist_entries (
|
||||
kind,
|
||||
value_hash,
|
||||
reason,
|
||||
created_at,
|
||||
created_by_user_id
|
||||
) VALUES ('user', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hash_identifier(str(user_id)),
|
||||
"Account blacklisted by administrator",
|
||||
now_iso(),
|
||||
admin["id"],
|
||||
),
|
||||
)
|
||||
|
||||
if target["status"] == "blacklisted" and status != "blacklisted":
|
||||
db_write(
|
||||
"DELETE FROM blacklist_entries WHERE kind = 'user' AND value_hash = ?",
|
||||
(hash_identifier(str(user_id)),),
|
||||
)
|
||||
|
||||
audit(
|
||||
"user_updated",
|
||||
admin["id"],
|
||||
"user",
|
||||
str(user_id),
|
||||
f"role={role};status={status};restricted_until={restricted_until}",
|
||||
)
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
@app.route("/api/admin/rooms")
|
||||
def admin_rooms():
|
||||
moderator, error = require_moderator()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
created_by_user_id,
|
||||
is_active
|
||||
FROM rooms
|
||||
WHERE is_active = 1
|
||||
ORDER BY name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"rooms": [
|
||||
{
|
||||
"id": row["id"],
|
||||
"slug": row["slug"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"visibility": row["visibility"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/admin/blacklist/ip", methods=["POST"])
|
||||
def blacklist_ip():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
ip = str(data.get("ip", "")).strip()
|
||||
reason = str(data.get("reason", "")).strip()[:300]
|
||||
|
||||
if not ip or len(ip) > 128:
|
||||
return jsonify({"error": "A valid IP value is required."}), 400
|
||||
|
||||
db_write(
|
||||
"""
|
||||
INSERT OR REPLACE INTO blacklist_entries (
|
||||
kind,
|
||||
value_hash,
|
||||
reason,
|
||||
created_at,
|
||||
created_by_user_id
|
||||
) VALUES ('ip', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
hash_identifier(ip),
|
||||
reason or "IP blacklisted by administrator",
|
||||
now_iso(),
|
||||
admin["id"],
|
||||
),
|
||||
)
|
||||
|
||||
audit("ip_blacklisted", admin["id"], "ip", hash_identifier(ip), reason)
|
||||
return jsonify({"status": "blacklisted"})
|
||||
125
routes/attachments.py
Normal file
125
routes/attachments.py
Normal file
@ -0,0 +1,125 @@
|
||||
from .common import (
|
||||
ALLOWED_ATTACHMENT_EXTENSIONS,
|
||||
ALLOWED_ATTACHMENT_MIME_TYPES,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
MAX_ATTACHMENT_NAME_LENGTH,
|
||||
UPLOAD_DIR,
|
||||
now_iso,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
require_user,
|
||||
require_writable_user,
|
||||
audit,
|
||||
safe_json,
|
||||
jsonify,
|
||||
request,
|
||||
db_write,
|
||||
can_access_room,
|
||||
can_access_channel,
|
||||
get_channel_by_id,
|
||||
)
|
||||
from attachments import send_attachment_file
|
||||
|
||||
|
||||
def register_attachment_routes(app):
|
||||
@app.route("/api/attachments", methods=["POST"])
|
||||
def upload_attachment():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("attachment")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
app.maybe_cleanup_orphaned_attachments()
|
||||
|
||||
uploaded = request.files.get("attachment")
|
||||
if not uploaded or not uploaded.filename:
|
||||
return jsonify({"error": "Choose a file to upload."}), 400
|
||||
|
||||
original_name = app.secure_filename(uploaded.filename)[:MAX_ATTACHMENT_NAME_LENGTH]
|
||||
if not original_name or "." not in original_name:
|
||||
return jsonify({"error": "Attachment must have an allowed file extension."}), 400
|
||||
|
||||
extension = original_name.rsplit(".", 1)[1].lower()
|
||||
if extension not in ALLOWED_ATTACHMENT_EXTENSIONS:
|
||||
return jsonify({"error": "This file type is not allowed."}), 400
|
||||
|
||||
declared_mime = (uploaded.mimetype or "").lower()
|
||||
guessed_mime = app.mimetypes.guess_type(original_name)[0] or ""
|
||||
|
||||
if declared_mime and declared_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
|
||||
return jsonify({"error": "This file MIME type is not allowed."}), 400
|
||||
|
||||
if guessed_mime and guessed_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
|
||||
return jsonify({"error": "This file extension is not allowed."}), 400
|
||||
|
||||
content = uploaded.stream.read(MAX_ATTACHMENT_BYTES + 1)
|
||||
if not content:
|
||||
return jsonify({"error": "Attachment cannot be empty."}), 400
|
||||
|
||||
if len(content) > MAX_ATTACHMENT_BYTES:
|
||||
return jsonify({"error": "Attachments must be 100 MB or smaller."}), 400
|
||||
|
||||
stored_name = f"{app.secrets.token_urlsafe(24)}.{extension}"
|
||||
destination = UPLOAD_DIR / stored_name
|
||||
|
||||
try:
|
||||
destination.write_bytes(content)
|
||||
app.os.chmod(destination, 0o600)
|
||||
except OSError:
|
||||
app.logger.exception("Could not save attachment.")
|
||||
return jsonify({"error": "Could not save attachment."}), 500
|
||||
|
||||
mime_type = declared_mime or guessed_mime or "application/octet-stream"
|
||||
|
||||
try:
|
||||
cursor = db_write(
|
||||
"""
|
||||
INSERT INTO attachments (
|
||||
uploader_user_id,
|
||||
stored_name,
|
||||
original_name,
|
||||
mime_type,
|
||||
size_bytes,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(user["id"], stored_name, original_name, mime_type, len(content), now_iso()),
|
||||
)
|
||||
except Exception:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
audit("attachment_uploaded", user["id"], "attachment", str(cursor.lastrowid), original_name)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"attachment": {
|
||||
"id": cursor.lastrowid,
|
||||
"original_name": original_name,
|
||||
"mime_type": mime_type,
|
||||
"size_bytes": len(content),
|
||||
"url": f"/api/attachments/{cursor.lastrowid}/download",
|
||||
}
|
||||
}
|
||||
), 201
|
||||
|
||||
@app.route("/api/attachments/<int:attachment_id>/download")
|
||||
def download_attachment(attachment_id: int):
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
return send_attachment_file(
|
||||
UPLOAD_DIR,
|
||||
attachment_id,
|
||||
user,
|
||||
can_access_room,
|
||||
can_access_channel,
|
||||
get_channel_by_id,
|
||||
)
|
||||
876
routes/calls.py
Normal file
876
routes/calls.py
Normal file
@ -0,0 +1,876 @@
|
||||
import time
|
||||
|
||||
from .common import (
|
||||
CALLS_BASE,
|
||||
CALL_HELPER_SHARED_SECRET,
|
||||
CALL_TOKEN_TTL_SECONDS,
|
||||
now_iso,
|
||||
parse_iso,
|
||||
utc_now,
|
||||
csrf_valid,
|
||||
require_user,
|
||||
require_writable_user,
|
||||
audit,
|
||||
safe_json,
|
||||
jsonify,
|
||||
request,
|
||||
get_db,
|
||||
db_write,
|
||||
sign_call_helper_token,
|
||||
normalize_username,
|
||||
get_room_by_slug,
|
||||
can_access_room,
|
||||
get_channel_by_slug,
|
||||
can_access_channel,
|
||||
validate_dm_access,
|
||||
get_dm_other_user,
|
||||
build_room_helper_call_id,
|
||||
build_channel_helper_call_id,
|
||||
get_or_create_group_call_session,
|
||||
group_call_session_payload,
|
||||
get_group_call_session_by_id,
|
||||
upsert_group_call_participant,
|
||||
heartbeat_group_call_participant,
|
||||
mark_group_call_participant_left,
|
||||
list_group_call_participants,
|
||||
maybe_end_group_call_if_empty,
|
||||
create_call_session,
|
||||
call_payload,
|
||||
get_call_with_dm,
|
||||
append_call_signal,
|
||||
list_call_signals,
|
||||
end_call,
|
||||
can_access_group_call_session,
|
||||
can_manage_group_call_session,
|
||||
group_call_scope_payload,
|
||||
group_call_permissions_payload,
|
||||
)
|
||||
|
||||
|
||||
def register_call_routes(app):
|
||||
@app.route("/api/group-calls/token", methods=["POST"])
|
||||
def api_group_call_token():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not CALL_HELPER_SHARED_SECRET:
|
||||
return jsonify({"error": "Call helper is not configured."}), 503
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
scope_kind = str(data.get("scope_kind", "")).strip().lower()
|
||||
helper_call_id = None
|
||||
scope_id = None
|
||||
scope_slug = None
|
||||
room_value = None
|
||||
|
||||
if scope_kind == "room":
|
||||
room_slug = str(data.get("room_slug", "")).strip()
|
||||
room = get_room_by_slug(room_slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
if not can_access_room(room, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
helper_call_id = build_room_helper_call_id(room)
|
||||
scope_id = room["id"]
|
||||
scope_slug = room["slug"]
|
||||
room_value = room["slug"]
|
||||
|
||||
elif scope_kind == "channel":
|
||||
team_slug = str(data.get("team_slug", "")).strip()
|
||||
channel_slug = str(data.get("channel_slug", "")).strip()
|
||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
||||
if not channel:
|
||||
return jsonify({"error": "Channel not found."}), 404
|
||||
if not can_access_channel(channel, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
helper_call_id = build_channel_helper_call_id(channel)
|
||||
scope_id = channel["id"]
|
||||
scope_slug = f"{channel['team_slug']}/{channel['slug']}"
|
||||
room_value = scope_slug
|
||||
|
||||
else:
|
||||
return jsonify({"error": "scope_kind must be room or channel."}), 400
|
||||
|
||||
session_row = get_or_create_group_call_session(
|
||||
scope_kind=scope_kind,
|
||||
scope_id=scope_id,
|
||||
scope_slug=scope_slug,
|
||||
helper_call_id=helper_call_id,
|
||||
created_by_user_id=user["id"],
|
||||
)
|
||||
|
||||
upsert_group_call_participant(session_row["id"], user["id"])
|
||||
|
||||
payload = {
|
||||
"sub": f"user-{user['id']}",
|
||||
"username": user["username"],
|
||||
"room": room_value,
|
||||
"call_id": helper_call_id,
|
||||
"role": user["role"],
|
||||
"can_call": True,
|
||||
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
|
||||
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
|
||||
|
||||
audit(
|
||||
"group_call_token_issued",
|
||||
user["id"],
|
||||
"group_call_session",
|
||||
str(session_row["id"]),
|
||||
helper_call_id,
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"token": token,
|
||||
"calls_base": CALLS_BASE,
|
||||
"call_id": helper_call_id,
|
||||
"session": group_call_session_payload(session_row),
|
||||
"participants": list_group_call_participants(session_row["id"]),
|
||||
"permissions": group_call_permissions_payload(session_row, user),
|
||||
"scope": group_call_scope_payload(session_row, user),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/invite", methods=["POST"])
|
||||
def api_group_call_invite():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not CALL_HELPER_SHARED_SECRET:
|
||||
return jsonify({"error": "Call helper is not configured."}), 503
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
session_id_raw = data.get("group_call_session_id")
|
||||
invite_username = normalize_username(data.get("username"))
|
||||
|
||||
try:
|
||||
session_id = int(session_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid group_call_session_id."}), 400
|
||||
|
||||
if not invite_username:
|
||||
return jsonify({"error": "username is required."}), 400
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row or session_row["status"] != "active":
|
||||
return jsonify({"error": "Active group call not found."}), 404
|
||||
|
||||
if not can_access_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
is_active_participant = False
|
||||
with get_db() as db:
|
||||
is_active_participant = bool(
|
||||
db.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM group_call_participants
|
||||
WHERE group_call_session_id = ?
|
||||
AND user_id = ?
|
||||
AND left_at IS NULL
|
||||
""",
|
||||
(session_row["id"], user["id"]),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
if not (is_active_participant or can_manage_group_call_session(session_row, user)):
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Only active participants or scope managers can invite users.",
|
||||
"code": "forbidden",
|
||||
}
|
||||
), 403
|
||||
|
||||
with get_db() as db:
|
||||
target = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
status
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""",
|
||||
(invite_username,),
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
return jsonify({"error": "User not found."}), 404
|
||||
|
||||
if int(target["id"]) == int(user["id"]):
|
||||
return jsonify({"error": "You are already in the call."}), 400
|
||||
|
||||
if target["status"] in {"disabled", "blacklisted"}:
|
||||
return jsonify({"error": "User is not available."}), 400
|
||||
|
||||
payload = {
|
||||
"sub": f"user-{target['id']}",
|
||||
"username": target["username"],
|
||||
"room": session_row["scope_slug"],
|
||||
"call_id": session_row["helper_call_id"],
|
||||
"role": target["role"],
|
||||
"can_call": True,
|
||||
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
|
||||
helper_token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
|
||||
expires_at = (utc_now() + app.timedelta(minutes=10)).isoformat()
|
||||
|
||||
cursor = db_write(
|
||||
"""
|
||||
INSERT INTO group_call_invites (
|
||||
group_call_session_id,
|
||||
inviter_user_id,
|
||||
invitee_user_id,
|
||||
helper_token,
|
||||
status,
|
||||
created_at,
|
||||
expires_at,
|
||||
responded_at
|
||||
) VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL)
|
||||
""",
|
||||
(
|
||||
session_row["id"],
|
||||
user["id"],
|
||||
target["id"],
|
||||
helper_token,
|
||||
now_iso(),
|
||||
expires_at,
|
||||
),
|
||||
)
|
||||
|
||||
audit(
|
||||
"group_call_invite_created",
|
||||
user["id"],
|
||||
"group_call_session",
|
||||
str(session_row["id"]),
|
||||
f"invitee={target['username']}",
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"invite": {
|
||||
"id": cursor.lastrowid,
|
||||
"invitee_username": target["username"],
|
||||
"group_call_session_id": session_row["id"],
|
||||
"status": "pending",
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
}
|
||||
), 201
|
||||
|
||||
@app.route("/api/group-calls/invites")
|
||||
def api_list_group_call_invites():
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
i.id,
|
||||
i.group_call_session_id,
|
||||
i.status,
|
||||
i.created_at,
|
||||
i.expires_at,
|
||||
i.responded_at,
|
||||
s.scope_kind,
|
||||
s.scope_slug,
|
||||
s.helper_call_id,
|
||||
s.status AS session_status,
|
||||
inviter.username AS inviter_username
|
||||
FROM group_call_invites AS i
|
||||
JOIN group_call_sessions AS s
|
||||
ON s.id = i.group_call_session_id
|
||||
JOIN users AS inviter
|
||||
ON inviter.id = i.inviter_user_id
|
||||
WHERE i.invitee_user_id = ?
|
||||
AND i.status = 'pending'
|
||||
AND i.expires_at > ?
|
||||
AND s.status = 'active'
|
||||
ORDER BY i.id DESC
|
||||
""",
|
||||
(user["id"], now_iso()),
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"invites": [
|
||||
{
|
||||
"id": row["id"],
|
||||
"group_call_session_id": row["group_call_session_id"],
|
||||
"scope_kind": row["scope_kind"],
|
||||
"scope_slug": row["scope_slug"],
|
||||
"helper_call_id": row["helper_call_id"],
|
||||
"inviter_username": row["inviter_username"],
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/invites/<int:invite_id>/accept", methods=["POST"])
|
||||
def api_accept_group_call_invite(invite_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
i.id,
|
||||
i.group_call_session_id,
|
||||
i.invitee_user_id,
|
||||
i.helper_token,
|
||||
i.status,
|
||||
i.expires_at,
|
||||
s.helper_call_id
|
||||
FROM group_call_invites AS i
|
||||
JOIN group_call_sessions AS s
|
||||
ON s.id = i.group_call_session_id
|
||||
WHERE i.id = ?
|
||||
AND i.invitee_user_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(invite_id, user["id"]),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Invite not found."}), 404
|
||||
|
||||
if row["status"] != "pending" or parse_iso(row["expires_at"]) <= utc_now():
|
||||
return jsonify({"error": "Invite expired or unavailable."}), 400
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_invites
|
||||
SET
|
||||
status = 'accepted',
|
||||
responded_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_iso(), invite_id),
|
||||
)
|
||||
|
||||
upsert_group_call_participant(row["group_call_session_id"], user["id"])
|
||||
|
||||
audit(
|
||||
"group_call_invite_accepted",
|
||||
user["id"],
|
||||
"group_call_invite",
|
||||
str(invite_id),
|
||||
row["helper_call_id"],
|
||||
)
|
||||
|
||||
session_row = get_group_call_session_by_id(row["group_call_session_id"])
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"token": row["helper_token"],
|
||||
"calls_base": CALLS_BASE,
|
||||
"call_id": row["helper_call_id"],
|
||||
"session": group_call_session_payload(session_row),
|
||||
"participants": list_group_call_participants(row["group_call_session_id"]),
|
||||
"permissions": group_call_permissions_payload(session_row, user),
|
||||
"scope": group_call_scope_payload(session_row, user),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/invites/<int:invite_id>/dismiss", methods=["POST"])
|
||||
def api_dismiss_group_call_invite(invite_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
group_call_session_id
|
||||
FROM group_call_invites
|
||||
WHERE id = ?
|
||||
AND invitee_user_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(invite_id, user["id"]),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Invite not found."}), 404
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_invites
|
||||
SET
|
||||
status = 'dismissed',
|
||||
responded_at = ?
|
||||
WHERE id = ?
|
||||
AND invitee_user_id = ?
|
||||
AND status = 'pending'
|
||||
""",
|
||||
(now_iso(), invite_id, user["id"]),
|
||||
)
|
||||
|
||||
audit(
|
||||
"group_call_invite_dismissed",
|
||||
user["id"],
|
||||
"group_call_invite",
|
||||
str(invite_id),
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "dismissed",
|
||||
"group_call_session_id": row["group_call_session_id"],
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/<int:session_id>")
|
||||
def api_get_group_call_session(session_id: int):
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row:
|
||||
return jsonify({"error": "Group call session not found."}), 404
|
||||
|
||||
if not can_access_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"session": group_call_session_payload(session_row),
|
||||
"scope": group_call_scope_payload(session_row, user),
|
||||
"participants": list_group_call_participants(session_id),
|
||||
"permissions": group_call_permissions_payload(session_row, user),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/<int:session_id>/participants")
|
||||
def api_get_group_call_participants(session_id: int):
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row:
|
||||
return jsonify({"error": "Group call session not found."}), 404
|
||||
|
||||
if not can_access_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"participants": list_group_call_participants(session_id),
|
||||
"permissions": group_call_permissions_payload(session_row, user),
|
||||
"scope": group_call_scope_payload(session_row, user),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/<int:session_id>/join", methods=["POST"])
|
||||
def api_join_group_call_session(session_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row:
|
||||
return jsonify({"error": "Group call session not found."}), 404
|
||||
|
||||
if session_row["status"] != "active":
|
||||
return jsonify({"error": "Group call session is not active."}), 400
|
||||
|
||||
if not can_access_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
upsert_group_call_participant(session_id, user["id"])
|
||||
|
||||
audit(
|
||||
"group_call_session_joined",
|
||||
user["id"],
|
||||
"group_call_session",
|
||||
str(session_id),
|
||||
session_row["helper_call_id"],
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "joined",
|
||||
"session": group_call_session_payload(session_row),
|
||||
"participants": list_group_call_participants(session_id),
|
||||
"permissions": group_call_permissions_payload(session_row, user),
|
||||
"scope": group_call_scope_payload(session_row, user),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/<int:session_id>/heartbeat", methods=["POST"])
|
||||
def api_heartbeat_group_call_session(session_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row:
|
||||
return jsonify({"error": "Group call session not found."}), 404
|
||||
|
||||
if session_row["status"] != "active":
|
||||
return jsonify({"error": "Group call session is not active."}), 400
|
||||
|
||||
if not can_access_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
heartbeat_group_call_participant(session_id, user["id"])
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"session": group_call_session_payload(session_row),
|
||||
"participants": list_group_call_participants(session_id),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/<int:session_id>/leave", methods=["POST"])
|
||||
def api_leave_group_call_session(session_id: int):
|
||||
"""
|
||||
keep csrf behavior for normal requests, but allow same-origin unload-style cleanup
|
||||
where the frontend keepalive/beacon path may not be able to send the custom header.
|
||||
"""
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not csrf_valid():
|
||||
origin = request.headers.get("Origin", "")
|
||||
sec_fetch_site = request.headers.get("Sec-Fetch-Site", "")
|
||||
same_origin_hint = (not origin) or origin.rstrip("/") == request.host_url.rstrip("/") or sec_fetch_site == "same-origin"
|
||||
if not same_origin_hint:
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row:
|
||||
return jsonify({"error": "Group call session not found."}), 404
|
||||
|
||||
if not can_access_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
mark_group_call_participant_left(session_id, user["id"])
|
||||
maybe_end_group_call_if_empty(session_id)
|
||||
|
||||
refreshed = get_group_call_session_by_id(session_id)
|
||||
|
||||
audit(
|
||||
"group_call_session_left",
|
||||
user["id"],
|
||||
"group_call_session",
|
||||
str(session_id),
|
||||
session_row["helper_call_id"],
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "left",
|
||||
"session": group_call_session_payload(refreshed) if refreshed else None,
|
||||
"participants": list_group_call_participants(session_id),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/group-calls/<int:session_id>/end", methods=["POST"])
|
||||
def api_end_group_call_session(session_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
session_row = get_group_call_session_by_id(session_id)
|
||||
if not session_row:
|
||||
return jsonify({"error": "Group call session not found."}), 404
|
||||
|
||||
if not can_manage_group_call_session(session_row, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_sessions
|
||||
SET
|
||||
status = 'ended',
|
||||
ended_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_iso(), session_id),
|
||||
)
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_participants
|
||||
SET
|
||||
updated_at = ?,
|
||||
left_at = COALESCE(left_at, ?)
|
||||
WHERE group_call_session_id = ?
|
||||
AND left_at IS NULL
|
||||
""",
|
||||
(now_iso(), now_iso(), session_id),
|
||||
)
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE group_call_invites
|
||||
SET
|
||||
status = 'dismissed',
|
||||
responded_at = COALESCE(responded_at, ?)
|
||||
WHERE group_call_session_id = ?
|
||||
AND status = 'pending'
|
||||
""",
|
||||
(now_iso(), session_id),
|
||||
)
|
||||
|
||||
refreshed = get_group_call_session_by_id(session_id)
|
||||
|
||||
audit(
|
||||
"group_call_session_ended",
|
||||
user["id"],
|
||||
"group_call_session",
|
||||
str(session_id),
|
||||
session_row["helper_call_id"],
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ended",
|
||||
"session": group_call_session_payload(refreshed) if refreshed else None,
|
||||
"participants": [],
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/call-token", methods=["POST"])
|
||||
def api_call_token():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not CALL_HELPER_SHARED_SECRET:
|
||||
return jsonify({"error": "Call helper is not configured."}), 503
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
room_slug = str(data.get("room", "")).strip()
|
||||
dm_id_raw = data.get("dm_id")
|
||||
|
||||
call_id = None
|
||||
room_value = None
|
||||
|
||||
if dm_id_raw is not None:
|
||||
try:
|
||||
dm_id = int(dm_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid dm_id."}), 400
|
||||
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
|
||||
peer = get_dm_other_user(dm_id, user["id"])
|
||||
if not peer:
|
||||
return jsonify({"error": "Direct-message peer not found."}), 404
|
||||
|
||||
call_id = f"dm-{dm_id}"
|
||||
room_value = call_id
|
||||
|
||||
elif room_slug:
|
||||
room = get_room_by_slug(room_slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
if not can_access_room(room, user):
|
||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
||||
|
||||
call_id = f"room-{room['slug']}"
|
||||
room_value = room["slug"]
|
||||
|
||||
else:
|
||||
return jsonify({"error": "room or dm_id is required."}), 400
|
||||
|
||||
payload = {
|
||||
"sub": f"user-{user['id']}",
|
||||
"username": user["username"],
|
||||
"room": room_value,
|
||||
"call_id": call_id,
|
||||
"role": user["role"],
|
||||
"can_call": True,
|
||||
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
|
||||
}
|
||||
|
||||
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
|
||||
|
||||
audit(
|
||||
"call_helper_token_issued",
|
||||
user["id"],
|
||||
"call",
|
||||
call_id,
|
||||
f"room={room_value}",
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"token": token,
|
||||
"calls_base": CALLS_BASE,
|
||||
"call_id": call_id,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/dms/<int:dm_id>/calls", methods=["POST"])
|
||||
def api_start_dm_call(dm_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
|
||||
call_id = create_call_session(dm_id, user["id"])
|
||||
audit("call_started", user["id"], "call", str(call_id), str(dm_id))
|
||||
return jsonify({"call": call_payload(call_id)}), 201
|
||||
|
||||
@app.route("/api/calls/<int:call_id>")
|
||||
def api_get_call(call_id: int):
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
call = get_call_with_dm(call_id)
|
||||
if not call:
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
return jsonify({"call": call_payload(call_id)})
|
||||
|
||||
@app.route("/api/calls/<int:call_id>/signal", methods=["POST"])
|
||||
def api_post_call_signal(call_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
call = get_call_with_dm(call_id)
|
||||
if not call:
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
if call["status"] != "active":
|
||||
return jsonify({"error": "Call is not active."}), 400
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
signal_type = str(data.get("signal_type", "")).strip()[:40]
|
||||
payload = data.get("payload", {})
|
||||
|
||||
if not signal_type:
|
||||
return jsonify({"error": "signal_type is required."}), 400
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify({"error": "payload must be an object."}), 400
|
||||
|
||||
signal_id = append_call_signal(call_id, user["id"], signal_type, payload)
|
||||
return jsonify({"status": "ok", "signal_id": signal_id}), 201
|
||||
|
||||
@app.route("/api/calls/<int:call_id>/signals")
|
||||
def api_get_call_signals(call_id: int):
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
call = get_call_with_dm(call_id)
|
||||
if not call:
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
after_raw = str(request.args.get("after_id", "")).strip()
|
||||
after_id = 0
|
||||
if after_raw:
|
||||
try:
|
||||
after_id = max(0, int(after_raw))
|
||||
except ValueError:
|
||||
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
||||
|
||||
signals = list_call_signals(call_id, after_id)
|
||||
latest_id = signals[-1]["id"] if signals else after_id
|
||||
return jsonify({"signals": signals, "latest_id": latest_id})
|
||||
|
||||
@app.route("/api/calls/<int:call_id>/end", methods=["POST"])
|
||||
def api_end_call(call_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
call = get_call_with_dm(call_id)
|
||||
if not call:
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
||||
return jsonify({"error": "Call not found."}), 404
|
||||
|
||||
end_call(call_id)
|
||||
audit("call_ended", user["id"], "call", str(call_id))
|
||||
return jsonify({"status": "ended", "call": call_payload(call_id)})
|
||||
413
routes/common.py
Normal file
413
routes/common.py
Normal file
@ -0,0 +1,413 @@
|
||||
import json
|
||||
|
||||
from flask import jsonify, request
|
||||
|
||||
from config import (
|
||||
CALLS_BASE,
|
||||
CALL_HELPER_SHARED_SECRET,
|
||||
CALL_TOKEN_TTL_SECONDS,
|
||||
CONSTANTS,
|
||||
PRIVATE_ACCESS_KEY,
|
||||
UPLOAD_DIR,
|
||||
now_iso,
|
||||
parse_iso,
|
||||
utc_now,
|
||||
)
|
||||
from db import get_db, db_write
|
||||
from auth import (
|
||||
safe_json,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
current_user,
|
||||
require_user,
|
||||
require_writable_user,
|
||||
require_moderator,
|
||||
require_admin,
|
||||
normalize_username,
|
||||
audit,
|
||||
hash_identifier,
|
||||
sign_call_helper_token,
|
||||
user_payload,
|
||||
)
|
||||
from models import (
|
||||
ROOM_SLUG_RE,
|
||||
MESSAGE_SELECT,
|
||||
team_page_select,
|
||||
team_slug_from_name,
|
||||
page_slug_from_title,
|
||||
room_by_id,
|
||||
get_room_by_slug,
|
||||
can_access_room,
|
||||
can_write_room,
|
||||
can_manage_room,
|
||||
room_payload,
|
||||
get_team_by_slug,
|
||||
get_channel_by_id,
|
||||
get_channel_by_slug,
|
||||
get_channel_by_legacy_room_id,
|
||||
team_member_role,
|
||||
is_team_member,
|
||||
is_team_admin,
|
||||
can_edit_team,
|
||||
can_access_team,
|
||||
can_access_channel,
|
||||
can_write_channel,
|
||||
team_payload,
|
||||
channel_payload,
|
||||
get_dm_for_user,
|
||||
get_dm_other_user,
|
||||
validate_dm_access,
|
||||
dm_payload,
|
||||
find_or_create_dm,
|
||||
update_presence,
|
||||
room_presence_count,
|
||||
channel_presence_count,
|
||||
dm_presence_count,
|
||||
)
|
||||
from attachments import (
|
||||
attachment_list_for_message,
|
||||
attachment_payload_legacy_from_message_select_row,
|
||||
resolve_owned_attachment,
|
||||
resolve_owned_attachment_ids,
|
||||
)
|
||||
from profiles import (
|
||||
DM_LAYOUT_MODES,
|
||||
LAST_ACTIVE_VISIBILITIES,
|
||||
PAGE_TYPES,
|
||||
PAGE_VISIBILITIES,
|
||||
PRESENCE_MODES,
|
||||
PROFILE_BIO_MODES,
|
||||
PROFILE_VISIBILITIES,
|
||||
REACTION_EMOJI_RE,
|
||||
TEAM_MEMBER_ROLES,
|
||||
THEME_MODES,
|
||||
normalized_hex_color,
|
||||
valid_hex_color,
|
||||
resolve_name_color,
|
||||
page_payload,
|
||||
profile_payload,
|
||||
reaction_summary_for_message,
|
||||
list_dms_for_user,
|
||||
richer_message_payload,
|
||||
feed_message_payload,
|
||||
can_view_profile_record,
|
||||
)
|
||||
from calls import (
|
||||
append_call_signal,
|
||||
call_payload,
|
||||
create_call_session,
|
||||
end_call,
|
||||
get_call_with_dm,
|
||||
list_call_signals,
|
||||
build_room_helper_call_id,
|
||||
build_channel_helper_call_id,
|
||||
get_or_create_group_call_session,
|
||||
group_call_session_payload,
|
||||
get_group_call_session_by_id,
|
||||
upsert_group_call_participant,
|
||||
heartbeat_group_call_participant,
|
||||
mark_group_call_participant_left,
|
||||
list_group_call_participants,
|
||||
maybe_end_group_call_if_empty,
|
||||
)
|
||||
|
||||
MAX_MESSAGE_LENGTH = CONSTANTS["MAX_MESSAGE_LENGTH"]
|
||||
MAX_HISTORY_MESSAGES = CONSTANTS["MAX_HISTORY_MESSAGES"]
|
||||
MAX_INCREMENTAL_MESSAGES = CONSTANTS["MAX_INCREMENTAL_MESSAGES"]
|
||||
MAX_FEED_MESSAGES = CONSTANTS["MAX_FEED_MESSAGES"]
|
||||
MAX_THREAD_REPLIES = CONSTANTS["MAX_THREAD_REPLIES"]
|
||||
MAX_ATTACHMENT_BYTES = CONSTANTS["MAX_ATTACHMENT_BYTES"]
|
||||
MAX_ATTACHMENT_NAME_LENGTH = CONSTANTS["MAX_ATTACHMENT_NAME_LENGTH"]
|
||||
ALLOWED_ATTACHMENT_EXTENSIONS = CONSTANTS["ALLOWED_ATTACHMENT_EXTENSIONS"]
|
||||
ALLOWED_ATTACHMENT_MIME_TYPES = CONSTANTS["ALLOWED_ATTACHMENT_MIME_TYPES"]
|
||||
INVITE_TTL_HOURS = CONSTANTS["INVITE_TTL_HOURS"]
|
||||
TEAM_INVITE_TTL_HOURS = CONSTANTS["TEAM_INVITE_TTL_HOURS"]
|
||||
TYPING_TTL_SECONDS = CONSTANTS["TYPING_TTL_SECONDS"]
|
||||
PRESENCE_TTL_SECONDS = CONSTANTS["PRESENCE_TTL_SECONDS"]
|
||||
MAX_SEARCH_QUERY_LENGTH = CONSTANTS["MAX_SEARCH_QUERY_LENGTH"]
|
||||
MAX_SEARCH_RESULTS = CONSTANTS["MAX_SEARCH_RESULTS"]
|
||||
READ_RECEIPT_BATCH_LIMIT = CONSTANTS["READ_RECEIPT_BATCH_LIMIT"]
|
||||
MAX_MESSAGE_EDIT_WINDOW_SECONDS = CONSTANTS["MAX_MESSAGE_EDIT_WINDOW_SECONDS"]
|
||||
MAX_DISPLAY_NAME_LENGTH = CONSTANTS["MAX_DISPLAY_NAME_LENGTH"]
|
||||
MAX_PROFILE_BIO_LENGTH = CONSTANTS["MAX_PROFILE_BIO_LENGTH"]
|
||||
MAX_LOCATION_LENGTH = CONSTANTS["MAX_LOCATION_LENGTH"]
|
||||
MAX_WEBSITE_LENGTH = CONSTANTS["MAX_WEBSITE_LENGTH"]
|
||||
MAX_CUSTOM_CSS_LENGTH = CONSTANTS["MAX_CUSTOM_CSS_LENGTH"]
|
||||
MAX_PROFILE_BADGE_LENGTH = CONSTANTS["MAX_PROFILE_BADGE_LENGTH"]
|
||||
MAX_OWNED_TEAMS_PER_USER = CONSTANTS["MAX_OWNED_TEAMS_PER_USER"]
|
||||
GROUP_CALL_PARTICIPANT_STALE_SECONDS = CONSTANTS["GROUP_CALL_PARTICIPANT_STALE_SECONDS"]
|
||||
ROOM_VISIBILITIES = CONSTANTS["ROOM_VISIBILITIES"]
|
||||
ROLES = CONSTANTS["ROLES"]
|
||||
STATUSES = CONSTANTS["STATUSES"]
|
||||
|
||||
|
||||
def private_access_key_valid(candidate: object) -> bool:
|
||||
configured = (PRIVATE_ACCESS_KEY or "").strip()
|
||||
if not configured:
|
||||
return True
|
||||
if not isinstance(candidate, str):
|
||||
return False
|
||||
return candidate.strip() == configured
|
||||
|
||||
|
||||
def build_message_payload(row, user):
|
||||
return richer_message_payload(
|
||||
row,
|
||||
user,
|
||||
attachment_payload_legacy_from_message_select_row,
|
||||
attachment_list_for_message,
|
||||
)
|
||||
|
||||
|
||||
def build_feed_payload(row, user):
|
||||
return feed_message_payload(
|
||||
row,
|
||||
user,
|
||||
attachment_payload_legacy_from_message_select_row,
|
||||
attachment_list_for_message,
|
||||
)
|
||||
|
||||
|
||||
def build_local_stub_share_url(kind: str, object_id: int) -> str:
|
||||
return f"/shared/{kind}/{object_id}"
|
||||
|
||||
|
||||
def require_csrf():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
return None
|
||||
|
||||
|
||||
def parse_json_request():
|
||||
try:
|
||||
return safe_json(), None
|
||||
except ValueError as error:
|
||||
return None, (jsonify({"error": str(error)}), 400)
|
||||
|
||||
|
||||
def require_dm_access(dm_id: int, user):
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return None, (jsonify({"error": "DM not found."}), 404)
|
||||
return dm_id, None
|
||||
|
||||
|
||||
def require_room_access(
|
||||
slug: str,
|
||||
user,
|
||||
login_message="Login required.",
|
||||
forbidden_message="You do not have access to this room.",
|
||||
):
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return None, (jsonify({"error": "Room not found."}), 404)
|
||||
|
||||
if not can_access_room(room, user):
|
||||
if not user:
|
||||
return None, (jsonify({"error": login_message, "code": "login_required"}), 401)
|
||||
return None, (jsonify({"error": forbidden_message, "code": "forbidden"}), 403)
|
||||
|
||||
return room, None
|
||||
|
||||
|
||||
def require_room_write(slug: str, user):
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return None, (jsonify({"error": "Room not found."}), 404)
|
||||
|
||||
if not can_write_room(room, user):
|
||||
return None, (jsonify({"error": "You cannot post in this room.", "code": "forbidden"}), 403)
|
||||
|
||||
return room, None
|
||||
|
||||
|
||||
def require_channel_access(team_slug: str, channel_slug: str, user):
|
||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
||||
if not channel:
|
||||
return None, (jsonify({"error": "Channel not found."}), 404)
|
||||
|
||||
if not can_access_channel(channel, user):
|
||||
if not user:
|
||||
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
||||
return None, (jsonify({"error": "You do not have access to this channel.", "code": "forbidden"}), 403)
|
||||
|
||||
return channel, None
|
||||
|
||||
|
||||
def require_channel_write(team_slug: str, channel_slug: str, user):
|
||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
||||
if not channel:
|
||||
return None, (jsonify({"error": "Channel not found."}), 404)
|
||||
|
||||
if not can_write_channel(channel, user):
|
||||
return None, (jsonify({"error": "You cannot post in this channel.", "code": "forbidden"}), 403)
|
||||
|
||||
return channel, None
|
||||
|
||||
|
||||
def user_can_access_message_scope(message_row, user) -> bool:
|
||||
if not message_row:
|
||||
return False
|
||||
|
||||
if message_row["direct_conversation_id"]:
|
||||
return bool(user and validate_dm_access(message_row["direct_conversation_id"], user))
|
||||
|
||||
if message_row["channel_id"]:
|
||||
channel = get_channel_by_id(message_row["channel_id"])
|
||||
return bool(channel and can_access_channel(channel, user))
|
||||
|
||||
if message_row["room_id"]:
|
||||
room = room_by_id(message_row["room_id"])
|
||||
return bool(room and can_access_room(room, user))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def resolve_message_attachment_inputs(data: dict, user_id: int):
|
||||
attachment_ids, attachment_error = resolve_owned_attachment_ids(
|
||||
data.get("attachment_ids", []),
|
||||
user_id,
|
||||
)
|
||||
if attachment_error:
|
||||
return None, None, attachment_error
|
||||
|
||||
if not attachment_ids and data.get("attachment_id") is not None:
|
||||
single_attachment_id, single_attachment_error = resolve_owned_attachment(
|
||||
data.get("attachment_id"),
|
||||
user_id,
|
||||
)
|
||||
if single_attachment_error:
|
||||
return None, None, single_attachment_error
|
||||
attachment_ids = [single_attachment_id] if single_attachment_id else []
|
||||
|
||||
attachment_id = attachment_ids[0] if attachment_ids else None
|
||||
return attachment_ids, attachment_id, None
|
||||
|
||||
|
||||
def require_team_admin(team_slug: str, user):
|
||||
team = get_team_by_slug(team_slug)
|
||||
if not team:
|
||||
return None, (jsonify({"error": "Team not found."}), 404)
|
||||
|
||||
if not is_team_admin(team["id"], user):
|
||||
return None, (jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403)
|
||||
|
||||
return team, None
|
||||
|
||||
|
||||
def require_team_access(team_slug: str, user):
|
||||
team = get_team_by_slug(team_slug)
|
||||
if not team:
|
||||
return None, (jsonify({"error": "Team not found."}), 404)
|
||||
|
||||
if not can_access_team(team, user):
|
||||
if not user:
|
||||
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
||||
return None, (jsonify({"error": "You do not have access to this team.", "code": "forbidden"}), 403)
|
||||
|
||||
return team, None
|
||||
|
||||
|
||||
def load_active_page(team_slug: str, page_slug: str):
|
||||
with get_db() as db:
|
||||
page = db.execute(
|
||||
f"""
|
||||
{team_page_select()}
|
||||
WHERE t.slug = ?
|
||||
AND p.slug = ?
|
||||
AND p.is_active = 1
|
||||
AND t.is_active = 1
|
||||
""",
|
||||
(team_slug, page_slug),
|
||||
).fetchone()
|
||||
return page
|
||||
|
||||
|
||||
def can_read_page(page, user) -> bool:
|
||||
return bool(
|
||||
page
|
||||
and (
|
||||
page["visibility"] == "public"
|
||||
or (
|
||||
user
|
||||
and (
|
||||
user["role"] in {"moderator", "admin"}
|
||||
or is_team_member(page["team_id"], user["id"])
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def can_edit_page(page, user) -> bool:
|
||||
return bool(page and user and is_team_admin(page["team_id"], user))
|
||||
|
||||
|
||||
def can_access_group_call_session(session_row, user: dict) -> bool:
|
||||
if not session_row or not user:
|
||||
return False
|
||||
|
||||
if session_row["scope_kind"] == "room":
|
||||
room = room_by_id(session_row["scope_id"])
|
||||
return bool(room and can_access_room(room, user))
|
||||
|
||||
if session_row["scope_kind"] == "channel":
|
||||
channel = get_channel_by_id(session_row["scope_id"])
|
||||
return bool(channel and can_access_channel(channel, user))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def can_manage_group_call_session(session_row, user: dict) -> bool:
|
||||
if not session_row or not user:
|
||||
return False
|
||||
|
||||
if user["role"] in {"moderator", "admin"}:
|
||||
return True
|
||||
|
||||
if int(session_row["created_by_user_id"]) == int(user["id"]):
|
||||
return True
|
||||
|
||||
if session_row["scope_kind"] == "room":
|
||||
room = room_by_id(session_row["scope_id"])
|
||||
return bool(room and can_manage_room(room, user))
|
||||
|
||||
if session_row["scope_kind"] == "channel":
|
||||
channel = get_channel_by_id(session_row["scope_id"])
|
||||
return bool(channel and can_edit_team(channel["team_id"], user))
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def group_call_scope_payload(session_row, user):
|
||||
payload = {
|
||||
"scope_kind": session_row["scope_kind"],
|
||||
"scope_id": session_row["scope_id"],
|
||||
"scope_slug": session_row["scope_slug"],
|
||||
}
|
||||
|
||||
if session_row["scope_kind"] == "room":
|
||||
room = room_by_id(session_row["scope_id"])
|
||||
if room:
|
||||
payload["room"] = room_payload(room, user)
|
||||
payload["scope_label"] = room["name"]
|
||||
elif session_row["scope_kind"] == "channel":
|
||||
channel = get_channel_by_id(session_row["scope_id"])
|
||||
if channel:
|
||||
payload["channel"] = channel_payload(channel, user)
|
||||
payload["team"] = {
|
||||
"slug": channel["team_slug"],
|
||||
"name": channel["team_name"],
|
||||
}
|
||||
payload["scope_label"] = f"# {channel['name']}"
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def group_call_permissions_payload(session_row, user):
|
||||
can_manage = can_manage_group_call_session(session_row, user)
|
||||
can_access = can_access_group_call_session(session_row, user)
|
||||
return {
|
||||
"can_manage": can_manage,
|
||||
"can_end": can_manage,
|
||||
"can_invite": can_access,
|
||||
}
|
||||
236
routes/dms.py
Normal file
236
routes/dms.py
Normal file
@ -0,0 +1,236 @@
|
||||
from .common import (
|
||||
MAX_HISTORY_MESSAGES,
|
||||
MAX_INCREMENTAL_MESSAGES,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
now_iso,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
require_user,
|
||||
require_writable_user,
|
||||
audit,
|
||||
safe_json,
|
||||
jsonify,
|
||||
request,
|
||||
get_db,
|
||||
build_message_payload,
|
||||
resolve_name_color,
|
||||
validate_dm_access,
|
||||
dm_payload,
|
||||
find_or_create_dm,
|
||||
get_dm_other_user,
|
||||
MESSAGE_SELECT,
|
||||
resolve_owned_attachment,
|
||||
resolve_owned_attachment_ids,
|
||||
normalize_username,
|
||||
list_dms_for_user,
|
||||
)
|
||||
|
||||
|
||||
def register_dm_routes(app):
|
||||
@app.route("/api/dms")
|
||||
def api_list_dms():
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
return jsonify({"dms": list_dms_for_user(user["id"])})
|
||||
|
||||
@app.route("/api/dms", methods=["POST"])
|
||||
def api_create_or_open_dm():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
other_user_id = data.get("user_id")
|
||||
username = normalize_username(data.get("username"))
|
||||
|
||||
with get_db() as db:
|
||||
target = None
|
||||
if other_user_id is not None:
|
||||
try:
|
||||
parsed_id = int(other_user_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid user_id."}), 400
|
||||
target = db.execute(
|
||||
"""
|
||||
SELECT id, username, role, status
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(parsed_id,),
|
||||
).fetchone()
|
||||
elif username:
|
||||
target = db.execute(
|
||||
"""
|
||||
SELECT id, username, role, status
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
return jsonify({"error": "User not found."}), 404
|
||||
if int(target["id"]) == int(user["id"]):
|
||||
return jsonify({"error": "You cannot DM yourself."}), 400
|
||||
if target["status"] in {"disabled", "blacklisted"}:
|
||||
return jsonify({"error": "User not available."}), 400
|
||||
|
||||
dm_id = find_or_create_dm(user["id"], target["id"], now_iso)
|
||||
audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"]))
|
||||
return jsonify({"dm": dm_payload(dm_id, user["id"], resolve_name_color)}), 201
|
||||
|
||||
@app.route("/api/dms/<int:dm_id>/messages")
|
||||
def api_get_dm_messages(dm_id: int):
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
|
||||
after_raw = request.args.get("after_id", "").strip()
|
||||
after_id = 0
|
||||
if after_raw:
|
||||
try:
|
||||
after_id = max(0, int(after_raw))
|
||||
except ValueError:
|
||||
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
||||
|
||||
with get_db() as db:
|
||||
if after_raw:
|
||||
rows = db.execute(
|
||||
f"""
|
||||
{MESSAGE_SELECT}
|
||||
WHERE m.direct_conversation_id = ?
|
||||
AND m.parent_message_id IS NULL
|
||||
AND m.id > ?
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(dm_id, after_id, MAX_INCREMENTAL_MESSAGES),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
f"""
|
||||
SELECT *
|
||||
FROM (
|
||||
{MESSAGE_SELECT}
|
||||
WHERE m.direct_conversation_id = ?
|
||||
AND m.parent_message_id IS NULL
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(dm_id, MAX_HISTORY_MESSAGES),
|
||||
).fetchall()
|
||||
|
||||
messages = [build_message_payload(row, user) for row in rows]
|
||||
latest_id = messages[-1]["id"] if messages else after_id
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"dm": dm_payload(dm_id, user["id"], resolve_name_color),
|
||||
"messages": messages,
|
||||
"latest_id": latest_id,
|
||||
"has_more": len(messages) == MAX_INCREMENTAL_MESSAGES,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/dms/<int:dm_id>/messages", methods=["POST"])
|
||||
def api_send_dm_message(dm_id: int):
|
||||
limited = rate_limited("send")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
text = data.get("text", "")
|
||||
if not isinstance(text, str):
|
||||
return jsonify({"error": "Message must be text."}), 400
|
||||
|
||||
text = text.strip()
|
||||
if len(text) > MAX_MESSAGE_LENGTH:
|
||||
return jsonify({"error": "Message is too long."}), 400
|
||||
|
||||
attachment_ids, attachment_error = resolve_owned_attachment_ids(
|
||||
data.get("attachment_ids", []),
|
||||
user["id"],
|
||||
)
|
||||
if attachment_error:
|
||||
return attachment_error
|
||||
|
||||
if not attachment_ids and data.get("attachment_id") is not None:
|
||||
single_attachment_id, single_attachment_error = resolve_owned_attachment(
|
||||
data.get("attachment_id"),
|
||||
user["id"],
|
||||
)
|
||||
if single_attachment_error:
|
||||
return single_attachment_error
|
||||
attachment_ids = [single_attachment_id] if single_attachment_id else []
|
||||
|
||||
attachment_id = attachment_ids[0] if attachment_ids else None
|
||||
|
||||
if not text and not attachment_id:
|
||||
return jsonify({"error": "Message cannot be empty."}), 400
|
||||
|
||||
with get_db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO messages (
|
||||
room_id,
|
||||
channel_id,
|
||||
direct_conversation_id,
|
||||
user_id,
|
||||
username,
|
||||
text,
|
||||
attachment_id,
|
||||
parent_message_id,
|
||||
created_at
|
||||
) VALUES (NULL, NULL, ?, ?, ?, ?, ?, NULL, ?)
|
||||
""",
|
||||
(dm_id, user["id"], user["username"], text, attachment_id, now_iso()),
|
||||
)
|
||||
message_id = cursor.lastrowid
|
||||
|
||||
for position, extra_attachment_id in enumerate(attachment_ids):
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO message_attachments (
|
||||
message_id,
|
||||
attachment_id,
|
||||
position
|
||||
) VALUES (?, ?, ?)
|
||||
""",
|
||||
(message_id, extra_attachment_id, position),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
|
||||
|
||||
audit("dm_message_sent", user["id"], "dm", str(dm_id), str(message_id))
|
||||
return jsonify({"message": build_message_payload(row, user)}), 201
|
||||
189
routes/feed.py
Normal file
189
routes/feed.py
Normal file
@ -0,0 +1,189 @@
|
||||
from .common import (
|
||||
MAX_FEED_MESSAGES,
|
||||
current_user,
|
||||
jsonify,
|
||||
request,
|
||||
get_db,
|
||||
build_feed_payload,
|
||||
room_by_id,
|
||||
can_access_room,
|
||||
get_channel_by_id,
|
||||
can_access_channel,
|
||||
validate_dm_access,
|
||||
)
|
||||
|
||||
|
||||
def register_feed_routes(app):
|
||||
@app.route("/api/feed")
|
||||
def get_feed():
|
||||
user = current_user()
|
||||
after_raw = request.args.get("after_id", "").strip()
|
||||
after_id = 0
|
||||
|
||||
if after_raw:
|
||||
try:
|
||||
after_id = max(0, int(after_raw))
|
||||
except ValueError:
|
||||
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
||||
|
||||
base_select = f"""
|
||||
SELECT
|
||||
m.id,
|
||||
m.room_id,
|
||||
m.channel_id,
|
||||
m.direct_conversation_id,
|
||||
m.user_id,
|
||||
m.username,
|
||||
m.text,
|
||||
m.created_at,
|
||||
m.attachment_id,
|
||||
m.parent_message_id,
|
||||
(
|
||||
SELECT COUNT(*)
|
||||
FROM messages AS thread_reply
|
||||
WHERE thread_reply.parent_message_id = m.id
|
||||
) AS thread_reply_count,
|
||||
r.slug AS room_slug,
|
||||
r.name AS room_name,
|
||||
r.visibility AS room_visibility,
|
||||
c.slug AS channel_slug,
|
||||
c.name AS channel_name,
|
||||
t.slug AS team_slug,
|
||||
t.name AS team_name,
|
||||
a.original_name AS attachment_original_name,
|
||||
a.mime_type AS attachment_mime_type,
|
||||
a.size_bytes AS attachment_size_bytes,
|
||||
u.role AS user_role,
|
||||
us.name_color AS user_name_color
|
||||
FROM messages AS m
|
||||
JOIN users AS u
|
||||
ON u.id = m.user_id
|
||||
LEFT JOIN user_settings AS us
|
||||
ON us.user_id = u.id
|
||||
LEFT JOIN rooms AS r
|
||||
ON r.id = m.room_id
|
||||
LEFT JOIN channels AS c
|
||||
ON c.id = m.channel_id
|
||||
AND c.is_active = 1
|
||||
LEFT JOIN teams AS t
|
||||
ON t.id = c.team_id
|
||||
AND t.is_active = 1
|
||||
LEFT JOIN attachments AS a
|
||||
ON a.id = m.attachment_id
|
||||
"""
|
||||
|
||||
params = []
|
||||
if user and user["role"] in {"moderator", "admin"}:
|
||||
access_clause = """
|
||||
(
|
||||
(m.direct_conversation_id IS NULL AND r.id IS NOT NULL AND r.is_active = 1)
|
||||
OR
|
||||
(m.direct_conversation_id IS NULL AND c.id IS NOT NULL AND c.is_active = 1 AND t.is_active = 1)
|
||||
OR
|
||||
(
|
||||
m.direct_conversation_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM direct_conversation_members AS dcm
|
||||
WHERE dcm.direct_conversation_id = m.direct_conversation_id
|
||||
AND dcm.user_id = ?
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
params.append(user["id"])
|
||||
elif user:
|
||||
access_clause = """
|
||||
(
|
||||
(
|
||||
m.direct_conversation_id IS NULL
|
||||
AND r.id IS NOT NULL
|
||||
AND r.is_active = 1
|
||||
AND (
|
||||
r.visibility = 'public'
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM room_members AS rm
|
||||
WHERE rm.room_id = r.id
|
||||
AND rm.user_id = ?
|
||||
)
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
m.direct_conversation_id IS NULL
|
||||
AND c.id IS NOT NULL
|
||||
AND c.is_active = 1
|
||||
AND t.is_active = 1
|
||||
AND c.legacy_room_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM team_members AS tm
|
||||
WHERE tm.team_id = c.team_id
|
||||
AND tm.user_id = ?
|
||||
)
|
||||
)
|
||||
OR
|
||||
(
|
||||
m.direct_conversation_id IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM direct_conversation_members AS dcm
|
||||
WHERE dcm.direct_conversation_id = m.direct_conversation_id
|
||||
AND dcm.user_id = ?
|
||||
)
|
||||
)
|
||||
)
|
||||
"""
|
||||
params.extend([user["id"], user["id"], user["id"]])
|
||||
else:
|
||||
access_clause = """
|
||||
m.direct_conversation_id IS NULL
|
||||
AND r.id IS NOT NULL
|
||||
AND r.is_active = 1
|
||||
AND r.visibility = 'public'
|
||||
"""
|
||||
|
||||
if after_raw:
|
||||
query = f"""
|
||||
{base_select}
|
||||
WHERE m.parent_message_id IS NULL
|
||||
AND {access_clause}
|
||||
AND m.id > ?
|
||||
ORDER BY m.id ASC
|
||||
LIMIT ?
|
||||
"""
|
||||
query_params = [*params, after_id, MAX_FEED_MESSAGES]
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(query, tuple(query_params)).fetchall()
|
||||
|
||||
entries = [build_feed_payload(row, user) for row in rows]
|
||||
latest_id = entries[-1]["id"] if entries else after_id
|
||||
return jsonify(
|
||||
{
|
||||
"entries": entries,
|
||||
"latest_id": latest_id,
|
||||
"has_more": len(entries) == MAX_FEED_MESSAGES,
|
||||
}
|
||||
)
|
||||
|
||||
query = f"""
|
||||
SELECT *
|
||||
FROM (
|
||||
{base_select}
|
||||
WHERE m.parent_message_id IS NULL
|
||||
AND {access_clause}
|
||||
ORDER BY m.id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
query_params = [*params, MAX_FEED_MESSAGES]
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(query, tuple(query_params)).fetchall()
|
||||
|
||||
entries = [build_feed_payload(row, user) for row in rows]
|
||||
latest_id = entries[-1]["id"] if entries else 0
|
||||
return jsonify({"entries": entries, "latest_id": latest_id, "has_more": False})
|
||||
1179
routes/messages.py
Normal file
1179
routes/messages.py
Normal file
File diff suppressed because it is too large
Load Diff
336
routes/presence.py
Normal file
336
routes/presence.py
Normal file
@ -0,0 +1,336 @@
|
||||
import time
|
||||
|
||||
from .common import (
|
||||
TYPING_TTL_SECONDS,
|
||||
PRESENCE_TTL_SECONDS,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
current_user,
|
||||
require_user,
|
||||
require_writable_user,
|
||||
safe_json,
|
||||
jsonify,
|
||||
get_db,
|
||||
db_write,
|
||||
get_room_by_slug,
|
||||
can_access_room,
|
||||
can_write_room,
|
||||
get_channel_by_slug,
|
||||
can_access_channel,
|
||||
can_write_channel,
|
||||
validate_dm_access,
|
||||
update_presence,
|
||||
room_presence_count,
|
||||
channel_presence_count,
|
||||
dm_presence_count,
|
||||
)
|
||||
|
||||
|
||||
def register_presence_routes(app):
|
||||
@app.route("/api/presence", methods=["POST"])
|
||||
def api_presence_ping():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
view_name = str(data.get("view", "")).strip()[:32] or None
|
||||
room_id = data.get("room_id")
|
||||
channel_id = data.get("channel_id")
|
||||
dm_id = data.get("dm_id")
|
||||
|
||||
try:
|
||||
room_id = int(room_id) if room_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
room_id = None
|
||||
|
||||
try:
|
||||
channel_id = int(channel_id) if channel_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
channel_id = None
|
||||
|
||||
try:
|
||||
dm_id = int(dm_id) if dm_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
dm_id = None
|
||||
|
||||
update_presence(db_write, user["id"], view_name, room_id, channel_id, dm_id)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/presence")
|
||||
def api_room_presence(slug: str):
|
||||
room = get_room_by_slug(slug)
|
||||
user = current_user()
|
||||
if not room or not can_access_room(room, user):
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"room_id": room["id"],
|
||||
"online_count": room_presence_count(PRESENCE_TTL_SECONDS, room["id"]),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/presence")
|
||||
def api_channel_presence(team_slug: str, channel_slug: str):
|
||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
||||
user = current_user()
|
||||
if not channel or not can_access_channel(channel, user):
|
||||
return jsonify({"error": "Channel not found."}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"channel_id": channel["id"],
|
||||
"online_count": channel_presence_count(PRESENCE_TTL_SECONDS, channel["id"]),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/dms/<int:dm_id>/presence")
|
||||
def api_dm_presence(dm_id: int):
|
||||
user = current_user()
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
return jsonify(
|
||||
{
|
||||
"dm_id": dm_id,
|
||||
"online_count": dm_presence_count(PRESENCE_TTL_SECONDS, dm_id),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/typing", methods=["POST"])
|
||||
def set_typing(slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("typing")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room or not can_write_room(room, user):
|
||||
return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
typing = bool(data.get("typing"))
|
||||
|
||||
if typing:
|
||||
db_write(
|
||||
"""
|
||||
INSERT INTO typing_events (
|
||||
room_id,
|
||||
user_id,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?)
|
||||
ON CONFLICT(room_id, user_id)
|
||||
DO UPDATE SET updated_at = excluded.updated_at
|
||||
""",
|
||||
(room["id"], user["id"], time.time()),
|
||||
)
|
||||
else:
|
||||
db_write(
|
||||
"DELETE FROM typing_events WHERE room_id = ? AND user_id = ?",
|
||||
(room["id"], user["id"]),
|
||||
)
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/typing")
|
||||
def get_typing(slug: str):
|
||||
room = get_room_by_slug(slug)
|
||||
user = current_user()
|
||||
|
||||
if not room or not can_access_room(room, user):
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
cutoff = time.time() - TYPING_TTL_SECONDS
|
||||
with get_db() as db:
|
||||
db.execute("DELETE FROM typing_events WHERE updated_at < ?", (cutoff,))
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username
|
||||
FROM typing_events AS t
|
||||
JOIN users AS u
|
||||
ON u.id = t.user_id
|
||||
WHERE t.room_id = ?
|
||||
AND t.user_id != ?
|
||||
AND t.updated_at >= ?
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
""",
|
||||
(room["id"], user["id"] if user else -1, cutoff),
|
||||
).fetchall()
|
||||
db.commit()
|
||||
|
||||
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
|
||||
|
||||
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/typing", methods=["POST"])
|
||||
def api_set_channel_typing(team_slug: str, channel_slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("typing")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
||||
if not channel:
|
||||
return jsonify({"error": "Channel not found."}), 404
|
||||
|
||||
if not can_write_channel(channel, user):
|
||||
return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
typing = bool(data.get("typing"))
|
||||
|
||||
if typing:
|
||||
db_write(
|
||||
"""
|
||||
INSERT INTO channel_typing_events (
|
||||
channel_id,
|
||||
user_id,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?)
|
||||
ON CONFLICT(channel_id, user_id)
|
||||
DO UPDATE SET updated_at = excluded.updated_at
|
||||
""",
|
||||
(channel["id"], user["id"], time.time()),
|
||||
)
|
||||
else:
|
||||
db_write(
|
||||
"DELETE FROM channel_typing_events WHERE channel_id = ? AND user_id = ?",
|
||||
(channel["id"], user["id"]),
|
||||
)
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/typing")
|
||||
def api_get_channel_typing(team_slug: str, channel_slug: str):
|
||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
||||
if not channel:
|
||||
return jsonify({"error": "Channel not found."}), 404
|
||||
|
||||
user = current_user()
|
||||
if not can_access_channel(channel, user):
|
||||
if not user:
|
||||
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
||||
return jsonify({"error": "You do not have access to this channel.", "code": "forbidden"}), 403
|
||||
|
||||
cutoff = time.time() - TYPING_TTL_SECONDS
|
||||
with get_db() as db:
|
||||
db.execute("DELETE FROM channel_typing_events WHERE updated_at < ?", (cutoff,))
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username
|
||||
FROM channel_typing_events AS t
|
||||
JOIN users AS u
|
||||
ON u.id = t.user_id
|
||||
WHERE t.channel_id = ?
|
||||
AND t.user_id != ?
|
||||
AND t.updated_at >= ?
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
""",
|
||||
(channel["id"], user["id"] if user else -1, cutoff),
|
||||
).fetchall()
|
||||
db.commit()
|
||||
|
||||
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
|
||||
|
||||
@app.route("/api/dms/<int:dm_id>/typing", methods=["POST"])
|
||||
def api_set_dm_typing(dm_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("typing")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
typing = bool(data.get("typing"))
|
||||
|
||||
if typing:
|
||||
db_write(
|
||||
"""
|
||||
INSERT INTO dm_typing_events (
|
||||
direct_conversation_id,
|
||||
user_id,
|
||||
updated_at
|
||||
) VALUES (?, ?, ?)
|
||||
ON CONFLICT(direct_conversation_id, user_id)
|
||||
DO UPDATE SET updated_at = excluded.updated_at
|
||||
""",
|
||||
(dm_id, user["id"], time.time()),
|
||||
)
|
||||
else:
|
||||
db_write(
|
||||
"""
|
||||
DELETE FROM dm_typing_events
|
||||
WHERE direct_conversation_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(dm_id, user["id"]),
|
||||
)
|
||||
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/dms/<int:dm_id>/typing")
|
||||
def api_get_dm_typing(dm_id: int):
|
||||
user = current_user()
|
||||
if not validate_dm_access(dm_id, user):
|
||||
return jsonify({"error": "DM not found."}), 404
|
||||
|
||||
cutoff = time.time() - TYPING_TTL_SECONDS
|
||||
with get_db() as db:
|
||||
db.execute("DELETE FROM dm_typing_events WHERE updated_at < ?", (cutoff,))
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username
|
||||
FROM dm_typing_events AS t
|
||||
JOIN users AS u
|
||||
ON u.id = t.user_id
|
||||
WHERE t.direct_conversation_id = ?
|
||||
AND t.user_id != ?
|
||||
AND t.updated_at >= ?
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
""",
|
||||
(dm_id, user["id"], cutoff),
|
||||
).fetchall()
|
||||
db.commit()
|
||||
|
||||
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
|
||||
518
routes/profiles.py
Normal file
518
routes/profiles.py
Normal file
@ -0,0 +1,518 @@
|
||||
from .common import (
|
||||
MAX_CUSTOM_CSS_LENGTH,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
MAX_LOCATION_LENGTH,
|
||||
MAX_MESSAGE_LENGTH,
|
||||
MAX_PROFILE_BADGE_LENGTH,
|
||||
MAX_PROFILE_BIO_LENGTH,
|
||||
MAX_WEBSITE_LENGTH,
|
||||
DM_LAYOUT_MODES,
|
||||
LAST_ACTIVE_VISIBILITIES,
|
||||
PRESENCE_MODES,
|
||||
PROFILE_BIO_MODES,
|
||||
PROFILE_VISIBILITIES,
|
||||
THEME_MODES,
|
||||
now_iso,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
current_user,
|
||||
require_user,
|
||||
require_writable_user,
|
||||
normalize_username,
|
||||
audit,
|
||||
user_payload,
|
||||
safe_json,
|
||||
jsonify,
|
||||
get_db,
|
||||
db_write,
|
||||
profile_payload,
|
||||
can_view_profile_record,
|
||||
valid_hex_color,
|
||||
normalized_hex_color,
|
||||
)
|
||||
|
||||
def register_profile_routes(app):
|
||||
@app.route("/api/profile")
|
||||
def api_get_my_profile():
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
user_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
restricted_until,
|
||||
created_at,
|
||||
last_login_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
profile_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
user_id,
|
||||
display_name,
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
updated_at
|
||||
FROM user_profiles
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
settings_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
name_color,
|
||||
presence_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
dm_layout_mode,
|
||||
show_presence_badge
|
||||
FROM user_settings
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)})
|
||||
|
||||
@app.route("/api/profile", methods=["PATCH"])
|
||||
def api_update_my_profile():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
limited = rate_limited("profile")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
display_name = str(data.get("display_name", "")).strip()
|
||||
badge_text = str(data.get("badge_text", "")).strip()
|
||||
bio = str(data.get("bio", "")).strip()
|
||||
bio_mode = str(data.get("bio_mode", "txt")).strip().lower()
|
||||
website = str(data.get("website", "")).strip()
|
||||
location = str(data.get("location", "")).strip()
|
||||
status_message = str(data.get("status_message", "")).strip()
|
||||
|
||||
banner_attachment_id = data.get("banner_attachment_id")
|
||||
if banner_attachment_id not in (None, ""):
|
||||
try:
|
||||
banner_attachment_id = int(banner_attachment_id)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "Invalid banner attachment."}), 400
|
||||
else:
|
||||
banner_attachment_id = None
|
||||
|
||||
if len(display_name) > MAX_DISPLAY_NAME_LENGTH:
|
||||
return jsonify({"error": "Display name is too long."}), 400
|
||||
if len(badge_text) > MAX_PROFILE_BADGE_LENGTH:
|
||||
return jsonify({"error": "Badge text is too long."}), 400
|
||||
if len(bio) > MAX_PROFILE_BIO_LENGTH:
|
||||
return jsonify({"error": "Bio is too long."}), 400
|
||||
if bio_mode not in PROFILE_BIO_MODES:
|
||||
return jsonify({"error": "Invalid bio mode."}), 400
|
||||
if len(website) > MAX_WEBSITE_LENGTH:
|
||||
return jsonify({"error": "Website is too long."}), 400
|
||||
if len(location) > MAX_LOCATION_LENGTH:
|
||||
return jsonify({"error": "Location is too long."}), 400
|
||||
if len(status_message) > 160:
|
||||
return jsonify({"error": "Status message is too long."}), 400
|
||||
|
||||
if banner_attachment_id is not None:
|
||||
with get_db() as db:
|
||||
attachment = db.execute(
|
||||
"""
|
||||
SELECT id, uploader_user_id
|
||||
FROM attachments
|
||||
WHERE id = ?
|
||||
""",
|
||||
(banner_attachment_id,),
|
||||
).fetchone()
|
||||
if not attachment:
|
||||
return jsonify({"error": "Banner attachment not found."}), 404
|
||||
if attachment["uploader_user_id"] != user["id"]:
|
||||
return jsonify({"error": "You can only use your own uploaded image."}), 403
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE user_profiles
|
||||
SET
|
||||
display_name = ?,
|
||||
badge_text = ?,
|
||||
bio = ?,
|
||||
bio_mode = ?,
|
||||
website = ?,
|
||||
location = ?,
|
||||
status_message = ?,
|
||||
banner_attachment_id = ?,
|
||||
updated_at = ?
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(
|
||||
display_name or user["username"],
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
now_iso(),
|
||||
user["id"],
|
||||
),
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
user_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
restricted_until,
|
||||
created_at,
|
||||
last_login_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
profile_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
user_id,
|
||||
display_name,
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
updated_at
|
||||
FROM user_profiles
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
settings_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
name_color,
|
||||
presence_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
dm_layout_mode,
|
||||
show_presence_badge
|
||||
FROM user_settings
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
audit("profile_updated", user["id"], "user", str(user["id"]))
|
||||
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)})
|
||||
|
||||
@app.route("/api/profiles/<string:username>")
|
||||
def api_get_public_profile(username: str):
|
||||
normalized = normalize_username(username)
|
||||
viewer = current_user()
|
||||
|
||||
with get_db() as db:
|
||||
user_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
status,
|
||||
restricted_until,
|
||||
created_at,
|
||||
last_login_at
|
||||
FROM users
|
||||
WHERE username = ?
|
||||
""",
|
||||
(normalized,),
|
||||
).fetchone()
|
||||
|
||||
if not user_row:
|
||||
return jsonify({"error": "Profile not found."}), 404
|
||||
|
||||
profile_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
user_id,
|
||||
display_name,
|
||||
badge_text,
|
||||
bio,
|
||||
bio_mode,
|
||||
website,
|
||||
location,
|
||||
status_message,
|
||||
banner_attachment_id,
|
||||
updated_at
|
||||
FROM user_profiles
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_row["id"],),
|
||||
).fetchone()
|
||||
|
||||
settings_row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
name_color,
|
||||
presence_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
dm_layout_mode,
|
||||
show_presence_badge
|
||||
FROM user_settings
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user_row["id"],),
|
||||
).fetchone()
|
||||
|
||||
if not can_view_profile_record(settings_row, viewer, user_row["id"]):
|
||||
if not viewer:
|
||||
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
||||
return jsonify({"error": "You do not have access to this profile.", "code": "forbidden"}), 403
|
||||
|
||||
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, viewer)})
|
||||
|
||||
@app.route("/api/settings")
|
||||
def api_get_settings():
|
||||
user, error = require_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
accent_color,
|
||||
name_color,
|
||||
custom_css,
|
||||
theme_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
presence_mode,
|
||||
dm_layout_mode,
|
||||
show_presence_badge,
|
||||
updated_at
|
||||
FROM user_settings
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"settings": {
|
||||
"accent_color": row["accent_color"],
|
||||
"name_color": row["name_color"],
|
||||
"custom_css": row["custom_css"],
|
||||
"theme_mode": row["theme_mode"],
|
||||
"profile_background_color": row["profile_background_color"],
|
||||
"profile_banner_color": row["profile_banner_color"],
|
||||
"profile_visibility": row["profile_visibility"],
|
||||
"last_active_visibility": row["last_active_visibility"],
|
||||
"presence_mode": row["presence_mode"],
|
||||
"dm_layout_mode": row["dm_layout_mode"],
|
||||
"show_presence_badge": bool(row["show_presence_badge"]),
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings", methods=["PATCH"])
|
||||
def api_update_settings():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
limited = rate_limited("settings")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
accent_color = data.get("accent_color", "#f7dce8")
|
||||
name_color = data.get("name_color")
|
||||
custom_css = str(data.get("custom_css", ""))
|
||||
theme_mode = str(data.get("theme_mode", "dark")).strip().lower()
|
||||
profile_background_color = data.get("profile_background_color")
|
||||
profile_banner_color = data.get("profile_banner_color")
|
||||
profile_visibility = str(data.get("profile_visibility", "public")).strip().lower()
|
||||
last_active_visibility = str(data.get("last_active_visibility", "everyone")).strip().lower()
|
||||
presence_mode = str(data.get("presence_mode", "online")).strip().lower()
|
||||
dm_layout_mode = str(data.get("dm_layout_mode", "cards")).strip().lower()
|
||||
show_presence_badge_raw = data.get("show_presence_badge", True)
|
||||
|
||||
if not valid_hex_color(accent_color):
|
||||
return jsonify({"error": "Invalid accent color."}), 400
|
||||
if name_color not in (None, "") and not valid_hex_color(name_color):
|
||||
return jsonify({"error": "Invalid name color."}), 400
|
||||
if profile_background_color not in (None, "") and not valid_hex_color(profile_background_color):
|
||||
return jsonify({"error": "Invalid profile background color."}), 400
|
||||
if profile_banner_color not in (None, "") and not valid_hex_color(profile_banner_color):
|
||||
return jsonify({"error": "Invalid profile banner color."}), 400
|
||||
if len(custom_css) > MAX_CUSTOM_CSS_LENGTH:
|
||||
return jsonify({"error": "Custom CSS is too long."}), 400
|
||||
if theme_mode not in THEME_MODES:
|
||||
return jsonify({"error": "Invalid theme mode."}), 400
|
||||
if profile_visibility not in PROFILE_VISIBILITIES:
|
||||
return jsonify({"error": "Invalid profile visibility."}), 400
|
||||
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
|
||||
return jsonify({"error": "Invalid last active visibility."}), 400
|
||||
if presence_mode not in PRESENCE_MODES:
|
||||
return jsonify({"error": "Invalid presence mode."}), 400
|
||||
if dm_layout_mode not in DM_LAYOUT_MODES:
|
||||
return jsonify({"error": "Invalid dm layout mode."}), 400
|
||||
|
||||
show_presence_badge = 1 if bool(show_presence_badge_raw) else 0
|
||||
|
||||
db_write(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET
|
||||
accent_color = ?,
|
||||
name_color = ?,
|
||||
custom_css = ?,
|
||||
theme_mode = ?,
|
||||
profile_background_color = ?,
|
||||
profile_banner_color = ?,
|
||||
profile_visibility = ?,
|
||||
last_active_visibility = ?,
|
||||
presence_mode = ?,
|
||||
dm_layout_mode = ?,
|
||||
show_presence_badge = ?,
|
||||
updated_at = ?
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(
|
||||
str(accent_color).lower(),
|
||||
normalized_hex_color(name_color) if name_color else None,
|
||||
custom_css,
|
||||
theme_mode,
|
||||
normalized_hex_color(profile_background_color) if profile_background_color else None,
|
||||
normalized_hex_color(profile_banner_color) if profile_banner_color else None,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
presence_mode,
|
||||
dm_layout_mode,
|
||||
show_presence_badge,
|
||||
now_iso(),
|
||||
user["id"],
|
||||
),
|
||||
)
|
||||
|
||||
with get_db() as db:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
accent_color,
|
||||
name_color,
|
||||
custom_css,
|
||||
theme_mode,
|
||||
profile_background_color,
|
||||
profile_banner_color,
|
||||
profile_visibility,
|
||||
last_active_visibility,
|
||||
presence_mode,
|
||||
dm_layout_mode,
|
||||
show_presence_badge,
|
||||
updated_at
|
||||
FROM user_settings
|
||||
WHERE user_id = ?
|
||||
""",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
|
||||
audit("settings_updated", user["id"], "user", str(user["id"]))
|
||||
return jsonify(
|
||||
{
|
||||
"settings": {
|
||||
**dict(row),
|
||||
"show_presence_badge": bool(row["show_presence_badge"]),
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/scheduled-messages", methods=["POST"])
|
||||
def api_create_scheduled_message():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
text = str(data.get("text", "")).strip()
|
||||
deliver_at = str(data.get("deliver_at", "")).strip()
|
||||
scope_kind = str(data.get("scope_kind", "")).strip()
|
||||
scope_id = data.get("scope_id")
|
||||
|
||||
if not text:
|
||||
return jsonify({"error": "Scheduled message text is required."}), 400
|
||||
if len(text) > MAX_MESSAGE_LENGTH:
|
||||
return jsonify({"error": "Scheduled message is too long."}), 400
|
||||
if not deliver_at:
|
||||
return jsonify({"error": "deliver_at is required."}), 400
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "stubbed",
|
||||
"scheduled_message": {
|
||||
"id": 0,
|
||||
"text": text,
|
||||
"deliver_at": deliver_at,
|
||||
"scope_kind": scope_kind,
|
||||
"scope_id": scope_id,
|
||||
}
|
||||
}
|
||||
), 201
|
||||
526
routes/rooms.py
Normal file
526
routes/rooms.py
Normal file
@ -0,0 +1,526 @@
|
||||
import re
|
||||
|
||||
from .common import (
|
||||
INVITE_TTL_HOURS,
|
||||
ROOM_SLUG_RE,
|
||||
ROOM_VISIBILITIES,
|
||||
now_iso,
|
||||
parse_iso,
|
||||
utc_now,
|
||||
csrf_valid,
|
||||
rate_limited,
|
||||
current_user,
|
||||
require_admin,
|
||||
require_writable_user,
|
||||
audit,
|
||||
hash_identifier,
|
||||
normalize_username,
|
||||
safe_json,
|
||||
jsonify,
|
||||
get_db,
|
||||
db_write,
|
||||
get_room_by_slug,
|
||||
room_payload,
|
||||
can_access_room,
|
||||
can_manage_room,
|
||||
get_channel_by_legacy_room_id,
|
||||
)
|
||||
|
||||
|
||||
def register_room_routes(app):
|
||||
@app.route("/api/rooms")
|
||||
def list_rooms():
|
||||
user = current_user()
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
created_by_user_id,
|
||||
is_active
|
||||
FROM rooms
|
||||
WHERE is_active = 1
|
||||
ORDER BY
|
||||
CASE WHEN slug = 'general' THEN 0 ELSE 1 END,
|
||||
name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
rooms = [room_payload(room, user) for room in rows if can_access_room(room, user)]
|
||||
return jsonify({"rooms": rooms, "authenticated": bool(user)})
|
||||
|
||||
@app.route("/api/rooms", methods=["POST"])
|
||||
def create_room():
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("admin")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
name = str(data.get("name", "")).strip()
|
||||
description = str(data.get("description", "")).strip()[:300]
|
||||
visibility = data.get("visibility", "public")
|
||||
|
||||
if not name or len(name) > 60:
|
||||
return jsonify({"error": "Room name must be 1 to 60 characters."}), 400
|
||||
if visibility not in ROOM_VISIBILITIES:
|
||||
return jsonify({"error": "Invalid room visibility."}), 400
|
||||
|
||||
base_slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")
|
||||
base_slug = base_slug[:48].strip("-")
|
||||
|
||||
if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug):
|
||||
return jsonify({"error": "Room name cannot create a valid room slug."}), 400
|
||||
|
||||
with get_db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
slug = base_slug
|
||||
suffix = 2
|
||||
while db.execute("SELECT 1 FROM rooms WHERE slug = ?", (slug,)).fetchone():
|
||||
slug = f"{base_slug[:44]}-{suffix}"
|
||||
suffix += 1
|
||||
|
||||
cursor = db.execute(
|
||||
"""
|
||||
INSERT INTO rooms (
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
visibility,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
""",
|
||||
(slug, name, description, visibility, admin["id"], now_iso()),
|
||||
)
|
||||
room_id = cursor.lastrowid
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO room_members (
|
||||
room_id,
|
||||
user_id,
|
||||
membership_role,
|
||||
added_at
|
||||
) VALUES (?, ?, 'owner', ?)
|
||||
""",
|
||||
(room_id, admin["id"], now_iso()),
|
||||
)
|
||||
|
||||
general_team = db.execute(
|
||||
"SELECT id FROM teams WHERE slug = 'general' AND is_active = 1"
|
||||
).fetchone()
|
||||
|
||||
if general_team:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO channels (
|
||||
team_id,
|
||||
legacy_room_id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
||||
""",
|
||||
(
|
||||
general_team["id"],
|
||||
room_id,
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
admin["id"],
|
||||
now_iso(),
|
||||
),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}")
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"room": {
|
||||
"id": room_id,
|
||||
"slug": slug,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"visibility": visibility,
|
||||
"can_read": True,
|
||||
"can_write": True,
|
||||
"can_manage": True,
|
||||
}
|
||||
}
|
||||
), 201
|
||||
|
||||
@app.route("/api/rooms/<string:slug>", methods=["PATCH"])
|
||||
def update_room(slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("admin")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
if room["slug"] == "general" and user["role"] != "admin":
|
||||
return jsonify({"error": "Only an administrator can modify the default room."}), 403
|
||||
|
||||
if not can_manage_room(room, user):
|
||||
return jsonify({"error": "Room owner or administrator access required.", "code": "forbidden"}), 403
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
name = str(data.get("name", room["name"])).strip()
|
||||
description = str(data.get("description", room["description"] or "")).strip()[:300]
|
||||
visibility = data.get("visibility", room["visibility"])
|
||||
|
||||
if not name or len(name) > 60:
|
||||
return jsonify({"error": "Room name must be 1 to 60 characters."}), 400
|
||||
if visibility not in ROOM_VISIBILITIES:
|
||||
return jsonify({"error": "Invalid room visibility."}), 400
|
||||
|
||||
db_write(
|
||||
"UPDATE rooms SET name = ?, description = ?, visibility = ? WHERE id = ?",
|
||||
(name, description, visibility, room["id"]),
|
||||
)
|
||||
db_write(
|
||||
"UPDATE channels SET name = ?, description = ? WHERE legacy_room_id = ?",
|
||||
(name, description, room["id"]),
|
||||
)
|
||||
|
||||
audit("room_updated", user["id"], "room", str(room["id"]), f"name={name};visibility={visibility}")
|
||||
return jsonify({"status": "updated"})
|
||||
|
||||
@app.route("/api/rooms/<string:slug>", methods=["DELETE"])
|
||||
def delete_room(slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("admin")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
if room["slug"] == "general":
|
||||
return jsonify({"error": "The default room cannot be deleted."}), 400
|
||||
|
||||
db_write("DELETE FROM rooms WHERE id = ?", (room["id"],))
|
||||
audit("room_deleted", admin["id"], "room", str(room["id"]), room["slug"])
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/messages", methods=["DELETE"])
|
||||
def clear_room_messages(slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
moderator, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
limited = rate_limited("admin")
|
||||
if limited:
|
||||
return limited
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
cursor = db_write(
|
||||
"""
|
||||
DELETE FROM messages
|
||||
WHERE room_id = ?
|
||||
AND direct_conversation_id IS NULL
|
||||
""",
|
||||
(room["id"],),
|
||||
)
|
||||
|
||||
audit("room_messages_cleared", moderator["id"], "room", str(room["id"]), str(cursor.rowcount))
|
||||
return jsonify({"status": "cleared", "deleted_messages": cursor.rowcount})
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/members")
|
||||
def list_room_members(slug: str):
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
if room["visibility"] != "private":
|
||||
return jsonify({"error": "This room is public."}), 400
|
||||
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.role,
|
||||
rm.membership_role,
|
||||
rm.added_at
|
||||
FROM room_members AS rm
|
||||
JOIN users AS u
|
||||
ON u.id = rm.user_id
|
||||
WHERE rm.room_id = ?
|
||||
ORDER BY
|
||||
CASE rm.membership_role
|
||||
WHEN 'owner' THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
u.username COLLATE NOCASE
|
||||
""",
|
||||
(room["id"],),
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"room": room_payload(room, admin),
|
||||
"members": [
|
||||
{
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"membership_role": row["membership_role"],
|
||||
"added_at": row["added_at"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/members", methods=["POST"])
|
||||
def add_room_member(slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
if room["visibility"] != "private":
|
||||
return jsonify({"error": "Members can only be managed for private rooms."}), 400
|
||||
|
||||
try:
|
||||
data = safe_json()
|
||||
except ValueError as error:
|
||||
return jsonify({"error": str(error)}), 400
|
||||
|
||||
username = normalize_username(data.get("username"))
|
||||
|
||||
with get_db() as db:
|
||||
target = db.execute(
|
||||
"SELECT id, username FROM users WHERE username = ?",
|
||||
(username,),
|
||||
).fetchone()
|
||||
|
||||
if not target:
|
||||
return jsonify({"error": "User not found."}), 404
|
||||
|
||||
db_write(
|
||||
"""
|
||||
INSERT OR IGNORE INTO room_members (
|
||||
room_id,
|
||||
user_id,
|
||||
membership_role,
|
||||
added_at
|
||||
) VALUES (?, ?, 'member', ?)
|
||||
""",
|
||||
(room["id"], target["id"], now_iso()),
|
||||
)
|
||||
|
||||
audit("room_member_added", admin["id"], "room", str(room["id"]), target["username"])
|
||||
return jsonify({"status": "added"}), 201
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/members/<int:user_id>", methods=["DELETE"])
|
||||
def remove_room_member(slug: str, user_id: int):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
with get_db() as db:
|
||||
member = db.execute(
|
||||
"""
|
||||
SELECT membership_role
|
||||
FROM room_members
|
||||
WHERE room_id = ?
|
||||
AND user_id = ?
|
||||
""",
|
||||
(room["id"], user_id),
|
||||
).fetchone()
|
||||
|
||||
if not member:
|
||||
return jsonify({"error": "Room member not found."}), 404
|
||||
|
||||
if member["membership_role"] == "owner":
|
||||
return jsonify({"error": "The room owner cannot be removed."}), 400
|
||||
|
||||
db_write(
|
||||
"DELETE FROM room_members WHERE room_id = ? AND user_id = ?",
|
||||
(room["id"], user_id),
|
||||
)
|
||||
|
||||
audit("room_member_removed", admin["id"], "room", str(room["id"]), str(user_id))
|
||||
return jsonify({"status": "removed"})
|
||||
|
||||
@app.route("/api/rooms/<string:slug>/invites", methods=["POST"])
|
||||
def create_room_invite(slug: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
admin, error = require_admin()
|
||||
if error:
|
||||
return error
|
||||
|
||||
room = get_room_by_slug(slug)
|
||||
if not room:
|
||||
return jsonify({"error": "Room not found."}), 404
|
||||
|
||||
if room["visibility"] != "private":
|
||||
return jsonify({"error": "Invite links are only available for private rooms."}), 400
|
||||
|
||||
token = app.secrets.token_urlsafe(32)
|
||||
expires_at = (utc_now() + app.timedelta(hours=INVITE_TTL_HOURS)).isoformat()
|
||||
|
||||
db_write(
|
||||
"""
|
||||
INSERT INTO room_invites (
|
||||
room_id,
|
||||
token_hash,
|
||||
created_by_user_id,
|
||||
created_at,
|
||||
expires_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(room["id"], hash_identifier(token), admin["id"], now_iso(), expires_at),
|
||||
)
|
||||
|
||||
audit("room_invite_created", admin["id"], "room", str(room["id"]))
|
||||
return jsonify({"invite": {"url": f"/?invite={token}", "expires_at": expires_at}}), 201
|
||||
|
||||
@app.route("/api/invites/<string:token>/accept", methods=["POST"])
|
||||
def accept_room_invite(token: str):
|
||||
if not csrf_valid():
|
||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
||||
|
||||
user, error = require_writable_user()
|
||||
if error:
|
||||
return error
|
||||
|
||||
token_hash = hash_identifier(token)
|
||||
|
||||
with get_db() as db:
|
||||
invite = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
ri.id,
|
||||
ri.room_id,
|
||||
ri.expires_at,
|
||||
ri.used_at,
|
||||
r.slug,
|
||||
r.name,
|
||||
r.description,
|
||||
r.visibility,
|
||||
r.created_by_user_id,
|
||||
r.is_active
|
||||
FROM room_invites AS ri
|
||||
JOIN rooms AS r
|
||||
ON r.id = ri.room_id
|
||||
WHERE ri.token_hash = ?
|
||||
""",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
|
||||
if not invite:
|
||||
return jsonify({"error": "Invite link is invalid."}), 404
|
||||
|
||||
if invite["used_at"] or parse_iso(invite["expires_at"]) <= utc_now():
|
||||
return jsonify({"error": "Invite link has expired or was already used."}), 400
|
||||
|
||||
if invite["visibility"] != "private" or not invite["is_active"]:
|
||||
return jsonify({"error": "This room is no longer available."}), 404
|
||||
|
||||
with get_db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
fresh = db.execute(
|
||||
"SELECT id, used_at, expires_at FROM room_invites WHERE id = ?",
|
||||
(invite["id"],),
|
||||
).fetchone()
|
||||
|
||||
if not fresh or fresh["used_at"] or parse_iso(fresh["expires_at"]) <= utc_now():
|
||||
db.rollback()
|
||||
return jsonify({"error": "Invite link has expired or was already used."}), 400
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO room_members (
|
||||
room_id,
|
||||
user_id,
|
||||
membership_role,
|
||||
added_at
|
||||
) VALUES (?, ?, 'member', ?)
|
||||
""",
|
||||
(invite["room_id"], user["id"], now_iso()),
|
||||
)
|
||||
db.execute(
|
||||
"""
|
||||
UPDATE room_invites
|
||||
SET
|
||||
used_at = ?,
|
||||
used_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(now_iso(), user["id"], invite["id"]),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
audit("room_invite_accepted", user["id"], "room", str(invite["room_id"]))
|
||||
room = get_room_by_slug(invite["slug"])
|
||||
return jsonify({"status": "joined", "room": room_payload(room, user)})
|
||||
1203
routes/teams.py
Normal file
1203
routes/teams.py
Normal file
File diff suppressed because it is too large
Load Diff
97
sanitizer.py
97
sanitizer.py
@ -1,97 +0,0 @@
|
||||
import html
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
_ALLOWED_TAGS = {
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"p",
|
||||
"br",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"blockquote",
|
||||
"code",
|
||||
"pre",
|
||||
"a",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
}
|
||||
|
||||
_ALLOWED_ATTRS = {
|
||||
"a": {"href", "title", "target", "rel"},
|
||||
}
|
||||
|
||||
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
|
||||
|
||||
|
||||
def _escape_attr(value: str) -> str:
|
||||
return html.escape(value, quote=True)
|
||||
|
||||
|
||||
def sanitize_html_fragment(raw: str) -> str:
|
||||
class SafeHtmlParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
tag = tag.lower()
|
||||
if tag not in _ALLOWED_TAGS:
|
||||
return
|
||||
|
||||
allowed = _ALLOWED_ATTRS.get(tag, set())
|
||||
clean_attrs = []
|
||||
|
||||
for key, value in attrs:
|
||||
key = (key or "").lower()
|
||||
value = value or ""
|
||||
|
||||
if key not in allowed:
|
||||
continue
|
||||
|
||||
if tag == "a" and key == "href":
|
||||
lowered = value.strip().lower()
|
||||
if not lowered.startswith(_ALLOWED_SCHEMES):
|
||||
continue
|
||||
|
||||
clean_attrs.append((key, value))
|
||||
|
||||
if tag == "a":
|
||||
attr_keys = {key for key, _ in clean_attrs}
|
||||
if "target" not in attr_keys:
|
||||
clean_attrs.append(("target", "_blank"))
|
||||
if "rel" not in attr_keys:
|
||||
clean_attrs.append(("rel", "noopener noreferrer"))
|
||||
|
||||
attr_text = "".join(
|
||||
f' {key}="{_escape_attr(value)}"'
|
||||
for key, value in clean_attrs
|
||||
)
|
||||
self.parts.append(f"<{tag}{attr_text}>")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
tag = tag.lower()
|
||||
if tag in _ALLOWED_TAGS and tag not in {"br"}:
|
||||
self.parts.append(f"</{tag}>")
|
||||
|
||||
def handle_data(self, data):
|
||||
self.parts.append(html.escape(data))
|
||||
|
||||
def handle_entityref(self, name):
|
||||
self.parts.append(f"&{name};")
|
||||
|
||||
def handle_charref(self, name):
|
||||
self.parts.append(f"&#{name};")
|
||||
|
||||
parser = SafeHtmlParser()
|
||||
parser.feed(raw or "")
|
||||
parser.close()
|
||||
return "".join(parser.parts)
|
||||
@ -1,2 +0,0 @@
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=StrongPass123!
|
||||
@ -41,6 +41,7 @@ Glow.els = {
|
||||
themeButton: document.getElementById("themeButton"),
|
||||
adminUsers: document.getElementById("adminUsers"),
|
||||
adminRooms: document.getElementById("adminRooms"),
|
||||
adminTeamsChannels: document.getElementById("adminTeamsChannels"),
|
||||
statusToast: document.getElementById("statusToast"),
|
||||
blockedOverlay: document.getElementById("blockedOverlay"),
|
||||
blockedMessage: document.getElementById("blockedMessage"),
|
||||
@ -87,13 +88,46 @@ Glow.els = {
|
||||
callOverlay: document.getElementById("callOverlay"),
|
||||
callStatus: document.getElementById("callStatus"),
|
||||
callPeerLabel: document.getElementById("callPeerLabel"),
|
||||
callParticipants: document.getElementById("callParticipants"),
|
||||
callPendingInvites: document.getElementById("callPendingInvites"),
|
||||
remoteAudioContainer: document.getElementById("remoteAudioContainer"),
|
||||
callStatusChip: document.getElementById("callStatusChip"),
|
||||
startCallButton: document.getElementById("startCallButton"),
|
||||
answerCallButton: document.getElementById("answerCallButton"),
|
||||
retryCallButton: document.getElementById("retryCallButton"),
|
||||
addToCallButton: document.getElementById("addToCallButton"),
|
||||
muteCallButton: document.getElementById("muteCallButton"),
|
||||
hangupCallButton: document.getElementById("hangupCallButton"),
|
||||
endCallButton: document.getElementById("endCallButton"),
|
||||
closeCallPanelButton: document.getElementById("closeCallPanelButton"),
|
||||
remoteAudio: document.getElementById("remoteAudio")
|
||||
|
||||
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 = {
|
||||
@ -131,6 +165,9 @@ Glow.state = {
|
||||
pageTypeFilter: "",
|
||||
activeProfile: null,
|
||||
settings: null,
|
||||
settingsSaved: null,
|
||||
settingsDraft: null,
|
||||
settingsDirty: false,
|
||||
pollTimer: null,
|
||||
initialized: false,
|
||||
toastTimer: null,
|
||||
@ -150,6 +187,33 @@ Glow.state = {
|
||||
originalTitle: document.title,
|
||||
dialogResolver: 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: {
|
||||
roomOnlineCount: 0,
|
||||
channelOnlineCount: 0,
|
||||
@ -164,10 +228,38 @@ Glow.state = {
|
||||
selfPeerId: null,
|
||||
peerConnections: new Map(),
|
||||
remoteStreams: new Map(),
|
||||
participantAudioEls: new Map(),
|
||||
peers: [],
|
||||
reconnectTimer: null,
|
||||
invitePollTimer: null,
|
||||
participantPollTimer: null,
|
||||
heartbeatTimer: null,
|
||||
invites: [],
|
||||
invitePollTimer: null
|
||||
reconnecting: false,
|
||||
joining: false,
|
||||
leaving: false,
|
||||
permissions: {
|
||||
can_manage: false,
|
||||
can_end: false,
|
||||
can_invite: false
|
||||
},
|
||||
backendParticipants: [],
|
||||
scope: null,
|
||||
statusText: "Idle",
|
||||
metrics: {
|
||||
packetLoss: null,
|
||||
uptime: null,
|
||||
latency: null,
|
||||
elapsedSeconds: 0,
|
||||
userCount: 0
|
||||
},
|
||||
drag: {
|
||||
active: false,
|
||||
x: null,
|
||||
y: null,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -245,6 +337,38 @@ Glow.resetAccentColor = () => {
|
||||
Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR);
|
||||
};
|
||||
|
||||
Glow.loadDmLayoutPreference = () => {
|
||||
const stored = localStorage.getItem("glow_dm_layout_mode");
|
||||
return stored === "compact" ? "compact" : "cards";
|
||||
};
|
||||
|
||||
Glow.saveDmLayoutPreference = mode => {
|
||||
const normalized = mode === "compact" ? "compact" : "cards";
|
||||
Glow.state.dmLayoutMode = normalized;
|
||||
localStorage.setItem("glow_dm_layout_mode", normalized);
|
||||
};
|
||||
|
||||
Glow.isSettingsDirty = () => Boolean(Glow.state.settingsDirty);
|
||||
|
||||
Glow.markSettingsDirty = () => {
|
||||
Glow.state.settingsDirty = true;
|
||||
Glow.syncSettingsSavePanel?.();
|
||||
};
|
||||
|
||||
Glow.clearSettingsDirty = () => {
|
||||
Glow.state.settingsDirty = false;
|
||||
Glow.syncSettingsSavePanel?.();
|
||||
};
|
||||
|
||||
Glow.getTimestampVisibilityMode = () => {
|
||||
return Glow.state.activeDm ? "hover" : "always";
|
||||
};
|
||||
|
||||
Glow.shouldShowMessageTimestamp = (_message = null, context = null) => {
|
||||
const view = context || Glow.getTimestampVisibilityMode();
|
||||
return view !== "hover";
|
||||
};
|
||||
|
||||
Glow.roleDefaultNameColor = role => {
|
||||
switch (String(role || "").toLowerCase()) {
|
||||
case "admin":
|
||||
@ -396,6 +520,55 @@ Glow.normalizeSelectedFiles = fileList => {
|
||||
return [...fileList].filter(Boolean);
|
||||
};
|
||||
|
||||
Glow.websiteBrandMeta = value => {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return {
|
||||
host: "",
|
||||
brand: "web",
|
||||
label: "website",
|
||||
iconText: "â—‹"
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
||||
|
||||
const mappings = [
|
||||
{ match: /github\.com$/, brand: "github", label: "GitHub", iconText: "gh" },
|
||||
{ match: /youtube\.com$|youtu\.be$/, brand: "youtube", label: "YouTube", iconText: "yt" },
|
||||
{ match: /spotify\.com$/, brand: "spotify", label: "Spotify", iconText: "sp" },
|
||||
{ match: /soundcloud\.com$/, brand: "soundcloud", label: "SoundCloud", iconText: "sc" },
|
||||
{ match: /twitter\.com$|x\.com$/, brand: "x", label: "X", iconText: "x" }
|
||||
];
|
||||
|
||||
for (const item of mappings) {
|
||||
if (item.match.test(host)) {
|
||||
return {
|
||||
host,
|
||||
brand: item.brand,
|
||||
label: item.label,
|
||||
iconText: item.iconText
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
brand: "web",
|
||||
label: "website",
|
||||
iconText: "â—‹"
|
||||
};
|
||||
} catch (_) {
|
||||
return {
|
||||
host: "",
|
||||
brand: "web",
|
||||
label: "website",
|
||||
iconText: "â—‹"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openDialog = ({
|
||||
title,
|
||||
body,
|
||||
|
||||
@ -37,6 +37,9 @@ Glow.handleUnauthorized = () => {
|
||||
Glow.state.pageTypeFilter = "";
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.state.settings = null;
|
||||
Glow.state.settingsSaved = null;
|
||||
Glow.state.settingsDraft = null;
|
||||
Glow.state.settingsDirty = false;
|
||||
Glow.state.selectedAttachment = [];
|
||||
Glow.state.selectedThreadAttachment = [];
|
||||
Glow.state.typingUsers = [];
|
||||
@ -51,14 +54,43 @@ Glow.handleUnauthorized = () => {
|
||||
Glow.state.dmRequestId += 1;
|
||||
Glow.state.feedRequestId += 1;
|
||||
Glow.state.threadRequestId += 1;
|
||||
Glow.state.calls.activeCall = null;
|
||||
Glow.state.calls.signalLastSeenId = 0;
|
||||
Glow.state.calls.incomingCall = null;
|
||||
Glow.state.calls.pendingOffer = null;
|
||||
|
||||
clearTimeout(Glow.state.pollTimer);
|
||||
clearTimeout(Glow.state.typingTimer);
|
||||
clearTimeout(Glow.state.calls.pollTimer);
|
||||
clearTimeout(Glow.state.calls.reconnectTimer);
|
||||
clearTimeout(Glow.state.calls.invitePollTimer);
|
||||
clearTimeout(Glow.state.calls.participantPollTimer);
|
||||
clearTimeout(Glow.state.calls.heartbeatTimer);
|
||||
|
||||
Glow.state.calls.activeCall = null;
|
||||
Glow.state.calls.ws = null;
|
||||
Glow.state.calls.session = null;
|
||||
Glow.state.calls.localStream = null;
|
||||
Glow.state.calls.muted = false;
|
||||
Glow.state.calls.selfPeerId = null;
|
||||
Glow.state.calls.peerConnections = new Map();
|
||||
Glow.state.calls.remoteStreams = new Map();
|
||||
Glow.state.calls.participantAudioEls = new Map();
|
||||
Glow.state.calls.peers = [];
|
||||
Glow.state.calls.reconnecting = false;
|
||||
Glow.state.calls.joining = false;
|
||||
Glow.state.calls.leaving = false;
|
||||
Glow.state.calls.invites = [];
|
||||
Glow.state.calls.backendParticipants = [];
|
||||
Glow.state.calls.scope = null;
|
||||
Glow.state.calls.statusText = "Idle";
|
||||
Glow.state.calls.metrics = {
|
||||
packetLoss: null,
|
||||
uptime: null,
|
||||
latency: null,
|
||||
elapsedSeconds: 0,
|
||||
userCount: 0
|
||||
};
|
||||
Glow.state.calls.permissions = {
|
||||
can_manage: false,
|
||||
can_end: false,
|
||||
can_invite: false
|
||||
};
|
||||
|
||||
Glow.clearMessages?.();
|
||||
Glow.clearFeed?.();
|
||||
@ -67,6 +99,7 @@ Glow.handleUnauthorized = () => {
|
||||
Glow.closeReactionPopup?.();
|
||||
Glow.closeMediaModal?.();
|
||||
Glow.endLocalCallUi?.();
|
||||
Glow.profileResetCropState?.();
|
||||
|
||||
Glow.setSelectedAttachment?.([]);
|
||||
Glow.setSelectedThreadAttachment?.([]);
|
||||
@ -374,32 +407,123 @@ Glow.apiSetDmTyping = (dmId, typing) =>
|
||||
body: JSON.stringify({ typing })
|
||||
});
|
||||
|
||||
/* calls */
|
||||
Glow.apiCreateCall = dmId =>
|
||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, {
|
||||
/* group calls */
|
||||
Glow.apiGetGroupCall = sessionId =>
|
||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}`);
|
||||
|
||||
Glow.apiJoinGroupCall = sessionId =>
|
||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/join`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiGetCall = callId =>
|
||||
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`);
|
||||
Glow.apiHeartbeatGroupCall = sessionId =>
|
||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiPostCallSignal = (callId, payload) =>
|
||||
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, {
|
||||
Glow.apiLeaveGroupCall = sessionId =>
|
||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/leave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiEndGroupCall = sessionId =>
|
||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/end`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiGetGroupCallParticipants = sessionId =>
|
||||
Glow.apiRequest(
|
||||
`/api/group-calls/${encodeURIComponent(sessionId)}/participants`
|
||||
);
|
||||
|
||||
Glow.apiInviteToGroupCall = payload =>
|
||||
Glow.apiRequest("/api/group-calls/invite", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
Glow.apiGetCallSignals = (callId, afterId = 0) =>
|
||||
Glow.apiListGroupCallInvites = () =>
|
||||
Glow.apiRequest("/api/group-calls/invites");
|
||||
|
||||
Glow.apiAcceptGroupCallInvite = inviteId =>
|
||||
Glow.apiRequest(
|
||||
`/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}`
|
||||
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/accept`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
);
|
||||
|
||||
Glow.apiEndCall = callId =>
|
||||
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, {
|
||||
Glow.apiDismissGroupCallInvite = inviteId =>
|
||||
Glow.apiRequest(
|
||||
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/dismiss`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
);
|
||||
|
||||
/* optional / not yet guaranteed on all backends */
|
||||
Glow.apiBookmarkMessage = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiUnbookmarkMessage = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
Glow.apiPinMessage = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiUnpinMessage = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
Glow.apiListPins = (scopeKind, scopeId) =>
|
||||
Glow.apiRequest(
|
||||
`/api/pins?scope_kind=${encodeURIComponent(scopeKind)}&scope_id=${encodeURIComponent(scopeId)}`
|
||||
);
|
||||
|
||||
Glow.apiListBookmarks = () =>
|
||||
Glow.apiRequest("/api/bookmarks");
|
||||
|
||||
Glow.apiCreateMessageShare = messageId =>
|
||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiCreateAttachmentShare = attachmentId =>
|
||||
Glow.apiRequest(`/api/attachments/${encodeURIComponent(attachmentId)}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
Glow.apiCreateScheduledMessage = payload =>
|
||||
Glow.apiRequest("/api/scheduled-messages", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,58 +6,99 @@ window.Glow = window.Glow || {};
|
||||
Glow.closeAdminPanel = () => {
|
||||
Glow.els.adminPanel.classList.remove("open");
|
||||
Glow.els.adminPanel.setAttribute("aria-hidden", "true");
|
||||
Glow.els.adminFab.focus();
|
||||
Glow.els.adminFab?.focus();
|
||||
};
|
||||
|
||||
Glow.setAdminTab = tab => {
|
||||
Glow.state.adminTab = ["rooms", "users", "teams"].includes(tab) ? tab : "rooms";
|
||||
|
||||
const isRooms = Glow.state.adminTab === "rooms";
|
||||
const isUsers = Glow.state.adminTab === "users";
|
||||
const isTeams = Glow.state.adminTab === "teams";
|
||||
|
||||
Glow.els.adminTabRooms?.classList.toggle("active", isRooms);
|
||||
Glow.els.adminTabUsers?.classList.toggle("active", isUsers);
|
||||
Glow.els.adminTabTeams?.classList.toggle("active", isTeams);
|
||||
|
||||
Glow.els.adminTabRooms?.setAttribute("aria-selected", String(isRooms));
|
||||
Glow.els.adminTabUsers?.setAttribute("aria-selected", String(isUsers));
|
||||
Glow.els.adminTabTeams?.setAttribute("aria-selected", String(isTeams));
|
||||
|
||||
Glow.els.adminRoomsPanel?.classList.toggle("hidden", !isRooms);
|
||||
Glow.els.adminUsersPanel?.classList.toggle("hidden", !isUsers);
|
||||
Glow.els.adminTeamsPanel?.classList.toggle("hidden", !isTeams);
|
||||
};
|
||||
|
||||
Glow.buildAdminRow = ({ title, subtitle, actions = [], className = "" }) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = className || "admin-room";
|
||||
|
||||
const details = document.createElement("div");
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className =
|
||||
className.includes("user")
|
||||
? "admin-user-name"
|
||||
: className.includes("team")
|
||||
? "admin-team-name"
|
||||
: "admin-room-name";
|
||||
name.textContent = title;
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className =
|
||||
className.includes("user")
|
||||
? "admin-user-info"
|
||||
: className.includes("team")
|
||||
? "admin-team-info"
|
||||
: "admin-room-info";
|
||||
info.textContent = subtitle || "";
|
||||
|
||||
details.append(name, info);
|
||||
|
||||
const actionsWrap = document.createElement("div");
|
||||
actionsWrap.className =
|
||||
className.includes("user")
|
||||
? "admin-user-actions"
|
||||
: className.includes("team")
|
||||
? "admin-team-actions"
|
||||
: "admin-room-actions";
|
||||
|
||||
for (const action of actions) {
|
||||
actionsWrap.append(action);
|
||||
}
|
||||
|
||||
row.append(details, actionsWrap);
|
||||
return row;
|
||||
};
|
||||
|
||||
Glow.renderAdminUsers = users => {
|
||||
Glow.els.adminUsers.replaceChildren();
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = "Users";
|
||||
heading.textContent = "users";
|
||||
Glow.els.adminUsers.append(heading);
|
||||
|
||||
if (!users.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-empty";
|
||||
empty.textContent = "No users found.";
|
||||
empty.textContent = "no users found.";
|
||||
Glow.els.adminUsers.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-user";
|
||||
|
||||
const details = document.createElement("div");
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "admin-user-name";
|
||||
name.textContent = `${user.username} · ${user.role}`;
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "admin-user-info";
|
||||
info.textContent =
|
||||
`${user.status} · ${user.message_count} message(s)` +
|
||||
(user.restricted_until
|
||||
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
|
||||
: "");
|
||||
|
||||
details.append(name, info);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "admin-user-actions";
|
||||
|
||||
const current =
|
||||
Glow.state.currentUser &&
|
||||
Number(Glow.state.currentUser.id) === Number(user.id);
|
||||
|
||||
const actions = [];
|
||||
|
||||
if (!current && Glow.isAdmin()) {
|
||||
const role = document.createElement("button");
|
||||
role.className = "small-button";
|
||||
role.type = "button";
|
||||
role.textContent =
|
||||
user.role === "moderator" ? "Make user" : "Make moderator";
|
||||
|
||||
user.role === "moderator" ? "make user" : "make moderator";
|
||||
role.addEventListener("click", () => {
|
||||
Glow.updateAdminUser(user.id, {
|
||||
role: user.role === "moderator" ? "user" : "moderator"
|
||||
@ -68,8 +109,7 @@ Glow.renderAdminUsers = users => {
|
||||
restrict.className = "small-button";
|
||||
restrict.type = "button";
|
||||
restrict.textContent =
|
||||
user.status === "restricted" ? "Unrestrict" : "Restrict";
|
||||
|
||||
user.status === "restricted" ? "unrestrict" : "restrict";
|
||||
restrict.addEventListener("click", () => {
|
||||
Glow.toggleRestriction(user);
|
||||
});
|
||||
@ -78,17 +118,26 @@ Glow.renderAdminUsers = users => {
|
||||
blacklist.className = "small-button danger";
|
||||
blacklist.type = "button";
|
||||
blacklist.textContent =
|
||||
user.status === "blacklisted" ? "Unblacklist" : "Blacklist";
|
||||
|
||||
user.status === "blacklisted" ? "unblacklist" : "blacklist";
|
||||
blacklist.addEventListener("click", () => {
|
||||
Glow.toggleBlacklist(user);
|
||||
});
|
||||
|
||||
actions.append(role, restrict, blacklist);
|
||||
actions.push(role, restrict, blacklist);
|
||||
}
|
||||
|
||||
row.append(details, actions);
|
||||
Glow.els.adminUsers.append(row);
|
||||
Glow.els.adminUsers.append(
|
||||
Glow.buildAdminRow({
|
||||
title: `${user.username} · ${user.role}`,
|
||||
subtitle:
|
||||
`${user.status} · ${user.message_count} message(s)` +
|
||||
(user.restricted_until
|
||||
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
|
||||
: ""),
|
||||
actions,
|
||||
className: "admin-user"
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -96,52 +145,33 @@ Glow.renderAdminRooms = rooms => {
|
||||
Glow.els.adminRooms.replaceChildren();
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = "Rooms";
|
||||
heading.textContent = "rooms";
|
||||
Glow.els.adminRooms.append(heading);
|
||||
|
||||
if (!rooms.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-empty";
|
||||
empty.textContent = "No rooms found.";
|
||||
empty.textContent = "no rooms found.";
|
||||
Glow.els.adminRooms.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "admin-room";
|
||||
|
||||
const details = document.createElement("div");
|
||||
|
||||
const name = document.createElement("div");
|
||||
name.className = "admin-room-name";
|
||||
name.textContent = `${room.name} · ${room.visibility}`;
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "admin-room-info";
|
||||
info.textContent =
|
||||
`/${room.slug}` +
|
||||
(room.description ? ` · ${room.description}` : "");
|
||||
|
||||
details.append(name, info);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "admin-room-actions";
|
||||
const actions = [];
|
||||
|
||||
if (Glow.isAdmin()) {
|
||||
const edit = document.createElement("button");
|
||||
edit.className = "small-button";
|
||||
edit.type = "button";
|
||||
edit.textContent = "Edit";
|
||||
edit.textContent = "edit";
|
||||
edit.addEventListener("click", () => Glow.editRoom(room));
|
||||
|
||||
actions.append(edit);
|
||||
actions.push(edit);
|
||||
|
||||
if (room.visibility === "private") {
|
||||
const members = document.createElement("button");
|
||||
members.className = "small-button";
|
||||
members.type = "button";
|
||||
members.textContent = "Members";
|
||||
members.textContent = "members";
|
||||
members.addEventListener("click", () => {
|
||||
Glow.manageRoomMembers(room);
|
||||
});
|
||||
@ -149,27 +179,139 @@ Glow.renderAdminRooms = rooms => {
|
||||
const invite = document.createElement("button");
|
||||
invite.className = "small-button";
|
||||
invite.type = "button";
|
||||
invite.textContent = "Invite link";
|
||||
invite.textContent = "invite link";
|
||||
invite.addEventListener("click", () => {
|
||||
Glow.createInviteLink(room);
|
||||
});
|
||||
|
||||
actions.append(members, invite);
|
||||
actions.push(members, invite);
|
||||
}
|
||||
|
||||
if (room.slug !== "general") {
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "small-button danger";
|
||||
remove.type = "button";
|
||||
remove.textContent = "Delete";
|
||||
remove.textContent = "delete";
|
||||
remove.addEventListener("click", () => Glow.deleteRoom(room));
|
||||
|
||||
actions.append(remove);
|
||||
actions.push(remove);
|
||||
}
|
||||
}
|
||||
|
||||
row.append(details, actions);
|
||||
Glow.els.adminRooms.append(row);
|
||||
Glow.els.adminRooms.append(
|
||||
Glow.buildAdminRow({
|
||||
title: `${room.name} · ${room.visibility}`,
|
||||
subtitle: `/${room.slug}` + (room.description ? ` · ${room.description}` : ""),
|
||||
actions,
|
||||
className: "admin-room"
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Glow.renderAdminTeamsChannels = () => {
|
||||
const box = Glow.els.adminTeamsChannels;
|
||||
if (!box) return;
|
||||
box.replaceChildren();
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = "teams / channels";
|
||||
box.append(heading);
|
||||
|
||||
const team = Glow.state.activeTeam;
|
||||
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
|
||||
|
||||
if (!team) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-empty";
|
||||
empty.textContent = "select a team to manage team or channel actions.";
|
||||
box.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const teamActions = [];
|
||||
if (team.can_manage) {
|
||||
const editTeam = document.createElement("button");
|
||||
editTeam.className = "small-button";
|
||||
editTeam.type = "button";
|
||||
editTeam.textContent = "edit team";
|
||||
editTeam.addEventListener("click", () => Glow.openEditTeamDialog?.(team));
|
||||
|
||||
const members = document.createElement("button");
|
||||
members.className = "small-button";
|
||||
members.type = "button";
|
||||
members.textContent = "members";
|
||||
members.addEventListener("click", () => Glow.manageTeamMembers?.(team));
|
||||
|
||||
const invite = document.createElement("button");
|
||||
invite.className = "small-button";
|
||||
invite.type = "button";
|
||||
invite.textContent = "invite";
|
||||
invite.addEventListener("click", () => Glow.createTeamInviteLink?.(team));
|
||||
|
||||
const addChannel = document.createElement("button");
|
||||
addChannel.className = "small-button";
|
||||
addChannel.type = "button";
|
||||
addChannel.textContent = "add channel";
|
||||
addChannel.addEventListener("click", () => Glow.openCreateChannelDialog?.(team));
|
||||
|
||||
teamActions.push(editTeam, members, invite, addChannel);
|
||||
}
|
||||
|
||||
box.append(
|
||||
Glow.buildAdminRow({
|
||||
title: team.name,
|
||||
subtitle: team.description || `/${team.slug}`,
|
||||
actions: teamActions,
|
||||
className: "admin-team-row"
|
||||
})
|
||||
);
|
||||
|
||||
const channels = Array.isArray(Glow.state.channels) ? Glow.state.channels : [];
|
||||
if (!channels.length) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "admin-empty";
|
||||
empty.textContent = "no channels available for this team.";
|
||||
box.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of channels) {
|
||||
const actions = [];
|
||||
const isActive = channel && item.slug === channel.slug;
|
||||
|
||||
if (team.can_manage) {
|
||||
const edit = document.createElement("button");
|
||||
edit.className = "small-button";
|
||||
edit.type = "button";
|
||||
edit.textContent = "edit";
|
||||
edit.addEventListener("click", () => {
|
||||
Glow.openEditChannelDialog?.(team, item);
|
||||
});
|
||||
actions.push(edit);
|
||||
|
||||
if (!item.legacy_room_id) {
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "small-button danger";
|
||||
remove.type = "button";
|
||||
remove.textContent = "delete";
|
||||
remove.addEventListener("click", async () => {
|
||||
const previous = Glow.state.activeChannel;
|
||||
Glow.state.activeChannel = item;
|
||||
await Glow.deleteCurrentChannel?.(team);
|
||||
Glow.state.activeChannel = previous;
|
||||
});
|
||||
actions.push(remove);
|
||||
}
|
||||
}
|
||||
|
||||
box.append(
|
||||
Glow.buildAdminRow({
|
||||
title: `${isActive ? "• " : ""}# ${item.name}`,
|
||||
subtitle: item.description || `/${item.slug}`,
|
||||
actions,
|
||||
className: "admin-team-row"
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -179,8 +321,15 @@ Glow.openAdminPanel = async () => {
|
||||
Glow.els.adminPanel.classList.add("open");
|
||||
Glow.els.adminPanel.setAttribute("aria-hidden", "false");
|
||||
|
||||
Glow.els.adminUsers.textContent = "Loading users…";
|
||||
Glow.els.adminRooms.textContent = "Loading rooms…";
|
||||
if (Glow.els.adminUsers) {
|
||||
Glow.els.adminUsers.textContent = "loading users…";
|
||||
}
|
||||
if (Glow.els.adminRooms) {
|
||||
Glow.els.adminRooms.textContent = "loading rooms…";
|
||||
}
|
||||
if (Glow.els.adminTeamsChannels) {
|
||||
Glow.els.adminTeamsChannels.textContent = "loading teams/channels…";
|
||||
}
|
||||
|
||||
try {
|
||||
const [usersData, roomsData] = await Promise.all([
|
||||
@ -190,134 +339,15 @@ Glow.openAdminPanel = async () => {
|
||||
|
||||
Glow.renderAdminUsers(usersData.users || []);
|
||||
Glow.renderAdminRooms(roomsData.rooms || []);
|
||||
Glow.updateAdminPreferenceButtons();
|
||||
Glow.renderAdminTeamsChannels();
|
||||
Glow.setAdminTab(Glow.state.adminTab || "rooms");
|
||||
|
||||
setTimeout(() => Glow.els.closeAdminButton.focus(), 0);
|
||||
setTimeout(() => Glow.els.closeAdminButton?.focus(), 0);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not load moderation data.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.updateThemeButton = () => {
|
||||
if (!Glow.els.themeButton) return;
|
||||
Glow.els.themeButton.textContent = "Accent color";
|
||||
};
|
||||
|
||||
Glow.updateAdminPreferenceButtons = () => {
|
||||
Glow.els.typingToggleButton.textContent =
|
||||
`Typing indicators: ${Glow.state.typingIndicatorsEnabled ? "on" : "off"}`;
|
||||
|
||||
Glow.els.notificationsToggleButton.textContent =
|
||||
`Notifications: ${Glow.state.notificationsEnabled ? "on" : "off"}`;
|
||||
|
||||
Glow.updateThemeButton();
|
||||
};
|
||||
|
||||
Glow.openAccentColorPicker = async () => {
|
||||
const container = document.createElement("div");
|
||||
container.className = "accent-picker";
|
||||
|
||||
const description = document.createElement("p");
|
||||
description.className = "accent-picker-description";
|
||||
description.textContent =
|
||||
"Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings.";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "accent-picker-label";
|
||||
label.textContent = "Accent color";
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "accent-picker-row";
|
||||
|
||||
const colorInput = document.createElement("input");
|
||||
colorInput.className = "accent-color-input";
|
||||
colorInput.type = "color";
|
||||
colorInput.value =
|
||||
Glow.normalizeAccentColor(Glow.state.accentColor) ||
|
||||
Glow.DEFAULT_ACCENT_COLOR;
|
||||
colorInput.setAttribute("aria-label", "Choose accent color");
|
||||
|
||||
const hexInput = document.createElement("input");
|
||||
hexInput.className = "field accent-hex-input";
|
||||
hexInput.type = "text";
|
||||
hexInput.value = colorInput.value;
|
||||
hexInput.maxLength = 7;
|
||||
hexInput.autocomplete = "off";
|
||||
hexInput.autocapitalize = "off";
|
||||
hexInput.spellcheck = false;
|
||||
hexInput.placeholder = "#f7dce8";
|
||||
hexInput.setAttribute(
|
||||
"aria-label",
|
||||
"Accent color hexadecimal value"
|
||||
);
|
||||
|
||||
const preview = document.createElement("span");
|
||||
preview.className = "accent-preview";
|
||||
preview.style.background = colorInput.value;
|
||||
preview.setAttribute("aria-hidden", "true");
|
||||
|
||||
const error = document.createElement("div");
|
||||
error.className = "accent-picker-error";
|
||||
error.setAttribute("role", "status");
|
||||
|
||||
const updatePreview = value => {
|
||||
const normalized = Glow.normalizeAccentColor(value);
|
||||
|
||||
if (!normalized) {
|
||||
preview.style.background = "transparent";
|
||||
error.textContent =
|
||||
"Use a six-digit hex color, such as #7dd3fc.";
|
||||
return false;
|
||||
}
|
||||
|
||||
preview.style.background = normalized;
|
||||
error.textContent = "";
|
||||
|
||||
if (colorInput.value !== normalized) {
|
||||
colorInput.value = normalized;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
colorInput.addEventListener("input", () => {
|
||||
const color = Glow.normalizeAccentColor(colorInput.value);
|
||||
if (!color) return;
|
||||
hexInput.value = color;
|
||||
updatePreview(color);
|
||||
});
|
||||
|
||||
hexInput.addEventListener("input", () => {
|
||||
updatePreview(hexInput.value.trim());
|
||||
});
|
||||
|
||||
row.append(colorInput, hexInput, preview);
|
||||
label.append(row);
|
||||
container.append(description, label, error);
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: "Accent color",
|
||||
body: container,
|
||||
confirmText: "Save color",
|
||||
cancelText: "Cancel"
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
const accent = Glow.normalizeAccentColor(hexInput.value);
|
||||
|
||||
if (!accent) {
|
||||
Glow.showToast(
|
||||
"Choose a valid six-digit hexadecimal color.",
|
||||
"error"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.applyAccentColor(accent);
|
||||
Glow.showToast("Accent color updated.");
|
||||
};
|
||||
|
||||
Glow.toggleRestriction = async user => {
|
||||
if (user.status === "restricted") {
|
||||
return Glow.updateAdminUser(user.id, {
|
||||
@ -327,14 +357,14 @@ Glow.toggleRestriction = async user => {
|
||||
}
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `Restrict ${user.username}`,
|
||||
body: "Enter the restriction duration in minutes.",
|
||||
confirmText: "Restrict",
|
||||
title: `restrict ${user.username}`,
|
||||
body: "enter the restriction duration in minutes.",
|
||||
confirmText: "restrict",
|
||||
danger: true,
|
||||
fields: [
|
||||
{
|
||||
name: "minutes",
|
||||
label: "Duration in minutes",
|
||||
label: "duration in minutes",
|
||||
type: "number",
|
||||
value: "60",
|
||||
maxLength: 5
|
||||
@ -345,20 +375,14 @@ Glow.toggleRestriction = async user => {
|
||||
if (!values) return;
|
||||
|
||||
const minutes = Number(values.minutes);
|
||||
|
||||
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) {
|
||||
Glow.showToast(
|
||||
"Enter a duration between 1 minute and 30 days.",
|
||||
"error"
|
||||
);
|
||||
Glow.showToast("enter a duration between 1 minute and 30 days.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
await Glow.updateAdminUser(user.id, {
|
||||
status: "restricted",
|
||||
restricted_until: new Date(
|
||||
Date.now() + minutes * 60 * 1000
|
||||
).toISOString()
|
||||
restricted_until: new Date(Date.now() + minutes * 60 * 1000).toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
@ -367,12 +391,12 @@ Glow.toggleBlacklist = async user => {
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: blacklisting
|
||||
? `Blacklist ${user.username}?`
|
||||
: `Unblacklist ${user.username}?`,
|
||||
? `blacklist ${user.username}?`
|
||||
: `unblacklist ${user.username}?`,
|
||||
body: blacklisting
|
||||
? "The account will be denied access immediately."
|
||||
: "The account will be restored to active status.",
|
||||
confirmText: blacklisting ? "Blacklist" : "Unblacklist",
|
||||
? "the account will be denied access immediately."
|
||||
: "the account will be restored to active status.",
|
||||
confirmText: blacklisting ? "blacklist" : "unblacklist",
|
||||
danger: blacklisting
|
||||
});
|
||||
|
||||
@ -391,36 +415,36 @@ Glow.updateAdminUser = async (userId, changes) => {
|
||||
body: JSON.stringify(changes)
|
||||
});
|
||||
|
||||
Glow.showToast("User updated.");
|
||||
Glow.showToast("user updated.");
|
||||
await Glow.openAdminPanel();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not update user.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.createRoom = async () => {
|
||||
const values = await Glow.openDialog({
|
||||
title: "Create room",
|
||||
body: "Choose a room name, description, and visibility.",
|
||||
confirmText: "Create",
|
||||
title: "create room",
|
||||
body: "choose a room name, description, and visibility.",
|
||||
confirmText: "create",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "Room name",
|
||||
placeholder: "Project chat",
|
||||
label: "room name",
|
||||
placeholder: "project chat",
|
||||
maxLength: 60
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
placeholder: "Optional room description",
|
||||
label: "description",
|
||||
placeholder: "optional room description",
|
||||
multiline: true,
|
||||
rows: 3,
|
||||
maxLength: 300
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
label: "Visibility: public or private",
|
||||
label: "visibility: public or private",
|
||||
value: "public",
|
||||
maxLength: 7
|
||||
}
|
||||
@ -429,46 +453,41 @@ Glow.createRoom = async () => {
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const visibility = values.visibility.toLowerCase();
|
||||
|
||||
const visibility = String(values.visibility || "").toLowerCase();
|
||||
if (!["public", "private"].includes(visibility)) {
|
||||
Glow.showToast("Visibility must be public or private.", "error");
|
||||
Glow.showToast("visibility must be public or private.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Glow.apiRequest("/api/rooms", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
visibility
|
||||
})
|
||||
await Glow.apiCreateRoom({
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
visibility
|
||||
});
|
||||
|
||||
Glow.showToast("Room created.");
|
||||
Glow.showToast("room created.");
|
||||
await Glow.loadRooms();
|
||||
await Glow.openAdminPanel();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not create room.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.editRoom = async room => {
|
||||
const values = await Glow.openDialog({
|
||||
title: `Edit ${room.name}`,
|
||||
confirmText: "Save",
|
||||
title: `edit ${room.name}`,
|
||||
confirmText: "save",
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
label: "Room name",
|
||||
label: "room name",
|
||||
value: room.name,
|
||||
maxLength: 60
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
label: "Description",
|
||||
label: "description",
|
||||
value: room.description || "",
|
||||
multiline: true,
|
||||
rows: 3,
|
||||
@ -476,7 +495,7 @@ Glow.editRoom = async room => {
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
label: "Visibility: public or private",
|
||||
label: "visibility: public or private",
|
||||
value: room.visibility,
|
||||
maxLength: 7
|
||||
}
|
||||
@ -485,10 +504,9 @@ Glow.editRoom = async room => {
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const visibility = values.visibility.toLowerCase();
|
||||
|
||||
const visibility = String(values.visibility || "").toLowerCase();
|
||||
if (!["public", "private"].includes(visibility)) {
|
||||
Glow.showToast("Visibility must be public or private.", "error");
|
||||
Glow.showToast("visibility must be public or private.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -501,13 +519,8 @@ Glow.editRoom = async room => {
|
||||
|
||||
Glow.updateRoom = async (slug, changes) => {
|
||||
try {
|
||||
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(changes)
|
||||
});
|
||||
|
||||
Glow.showToast("Room updated.");
|
||||
await Glow.apiUpdateRoom(slug, changes);
|
||||
Glow.showToast("room updated.");
|
||||
await Glow.loadRooms();
|
||||
|
||||
if (!Glow.state.activeDm) {
|
||||
@ -516,15 +529,15 @@ Glow.updateRoom = async (slug, changes) => {
|
||||
|
||||
await Glow.openAdminPanel();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not update room.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.deleteRoom = async room => {
|
||||
const result = await Glow.openDialog({
|
||||
title: `Delete ${room.name}?`,
|
||||
body: "This permanently deletes the room and every message in it.",
|
||||
confirmText: "Delete room",
|
||||
title: `delete ${room.name}?`,
|
||||
body: "this permanently deletes the room and every message in it.",
|
||||
confirmText: "delete room",
|
||||
danger: true
|
||||
});
|
||||
|
||||
@ -535,7 +548,7 @@ Glow.deleteRoom = async room => {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
Glow.showToast("Room deleted.");
|
||||
Glow.showToast("room deleted.");
|
||||
await Glow.loadRooms();
|
||||
|
||||
if (!Glow.state.activeDm) {
|
||||
@ -544,43 +557,39 @@ Glow.deleteRoom = async room => {
|
||||
|
||||
await Glow.openAdminPanel();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not delete room.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.clearActiveRoom = async () => {
|
||||
const room = Glow.state.activeRoom;
|
||||
|
||||
if (!room || Glow.state.activeDm) return;
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: `Clear ${room.name}?`,
|
||||
body: "All messages in this room will be permanently deleted.",
|
||||
confirmText: "Clear room",
|
||||
title: `clear ${room.name}?`,
|
||||
body: "all messages in this room will be permanently deleted.",
|
||||
confirmText: "clear room",
|
||||
danger: true
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
await Glow.apiRequest(
|
||||
`/api/rooms/${encodeURIComponent(room.slug)}/messages`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/messages`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
Glow.clearMessages();
|
||||
Glow.renderEmptyState();
|
||||
Glow.showToast("Room messages cleared.");
|
||||
Glow.showToast("room messages cleared.");
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not clear room.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.manageRoomMembers = async room => {
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/rooms/${encodeURIComponent(room.slug)}/members`
|
||||
);
|
||||
const data = await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`);
|
||||
|
||||
const container = document.createElement("div");
|
||||
container.className = "member-list";
|
||||
@ -595,7 +604,7 @@ Glow.manageRoomMembers = async room => {
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "small-button danger";
|
||||
remove.type = "button";
|
||||
remove.textContent = "Remove";
|
||||
remove.textContent = "remove";
|
||||
remove.disabled = member.membership_role === "owner";
|
||||
|
||||
remove.addEventListener("click", async () => {
|
||||
@ -604,12 +613,11 @@ Glow.manageRoomMembers = async room => {
|
||||
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
Glow.showToast("Member removed.");
|
||||
Glow.showToast("member removed.");
|
||||
Glow.closeDialog({});
|
||||
Glow.manageRoomMembers(room);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not remove member.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
@ -620,11 +628,11 @@ Glow.manageRoomMembers = async room => {
|
||||
const values = await Glow.openDialog({
|
||||
title: `${room.name} members`,
|
||||
body: container,
|
||||
confirmText: "Add member",
|
||||
confirmText: "add member",
|
||||
fields: [
|
||||
{
|
||||
name: "username",
|
||||
label: "Username to add",
|
||||
label: "username to add",
|
||||
placeholder: "username",
|
||||
maxLength: 30
|
||||
}
|
||||
@ -633,46 +641,37 @@ Glow.manageRoomMembers = async room => {
|
||||
|
||||
if (!values?.username) return;
|
||||
|
||||
await Glow.apiRequest(
|
||||
`/api/rooms/${encodeURIComponent(room.slug)}/members`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: values.username })
|
||||
}
|
||||
);
|
||||
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: values.username })
|
||||
});
|
||||
|
||||
Glow.showToast("Member added.");
|
||||
Glow.showToast("member added.");
|
||||
await Glow.manageRoomMembers(room);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not manage room members.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.createInviteLink = async room => {
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/rooms/${encodeURIComponent(room.slug)}/invites`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
);
|
||||
const data = await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/invites`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
|
||||
const url = new URL(
|
||||
data.invite.url,
|
||||
window.location.origin
|
||||
).toString();
|
||||
const url = new URL(data.invite.url, window.location.origin).toString();
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: "Private-room invite link",
|
||||
body: "Copy and share this link. It can be used once before it expires.",
|
||||
confirmText: "Copy link",
|
||||
title: "private-room invite link",
|
||||
body: "copy and share this link. it can be used once before it expires.",
|
||||
confirmText: "copy link",
|
||||
fields: [
|
||||
{
|
||||
name: "invite",
|
||||
label: "Invite link",
|
||||
label: "invite link",
|
||||
value: url,
|
||||
maxLength: 500
|
||||
}
|
||||
@ -681,9 +680,21 @@ Glow.createInviteLink = async room => {
|
||||
|
||||
if (values) {
|
||||
await navigator.clipboard.writeText(url);
|
||||
Glow.showToast("Invite link copied.");
|
||||
Glow.showToast("invite link copied.");
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message, "error");
|
||||
Glow.showToast(error.message || "could not create invite link.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.els.adminTabRooms?.addEventListener("click", () => {
|
||||
Glow.setAdminTab("rooms");
|
||||
});
|
||||
|
||||
Glow.els.adminTabUsers?.addEventListener("click", () => {
|
||||
Glow.setAdminTab("users");
|
||||
});
|
||||
|
||||
Glow.els.adminTabTeams?.addEventListener("click", () => {
|
||||
Glow.setAdminTab("teams");
|
||||
});
|
||||
@ -2,6 +2,44 @@
|
||||
"use strict";
|
||||
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 () => {
|
||||
const previousSlug = Glow.state.activeRoom?.slug;
|
||||
const data = await Glow.apiRequest("/api/rooms");
|
||||
@ -30,6 +68,100 @@ Glow.loadRooms = async () => {
|
||||
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 = () => {
|
||||
const view = Glow.state.activeView || "feed";
|
||||
const room = Glow.state.activeRoom;
|
||||
@ -53,6 +185,8 @@ Glow.updateViewUI = () => {
|
||||
Glow.els.composerWrap.classList.toggle("hidden", !chatActive);
|
||||
Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive);
|
||||
|
||||
Glow.syncSettingsSavePanel?.();
|
||||
|
||||
if (!chatActive) {
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
@ -87,6 +221,7 @@ Glow.updateViewUI = () => {
|
||||
: "Sign in or create an account to personalize your feed";
|
||||
Glow.els.roomSelect.disabled = !Glow.state.rooms.length;
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -96,6 +231,7 @@ Glow.updateViewUI = () => {
|
||||
? "Private conversations"
|
||||
: "Sign in to open private conversations";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -104,6 +240,7 @@ Glow.updateViewUI = () => {
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
dm ? "Direct message replies" : room?.name || "Conversation replies";
|
||||
Glow.updateThreadControls?.();
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -111,6 +248,7 @@ Glow.updateViewUI = () => {
|
||||
Glow.els.headerTitle.textContent = "Pages";
|
||||
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -118,6 +256,7 @@ Glow.updateViewUI = () => {
|
||||
Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page";
|
||||
Glow.els.headerSubtitle.textContent = "Shared knowledge and resources";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -128,6 +267,7 @@ Glow.updateViewUI = () => {
|
||||
"Profile";
|
||||
Glow.els.headerSubtitle.textContent = "User profile";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -135,6 +275,7 @@ Glow.updateViewUI = () => {
|
||||
Glow.els.headerTitle.textContent = "Settings";
|
||||
Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences";
|
||||
Glow.els.presenceLabel?.classList.add("hidden");
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -143,12 +284,14 @@ Glow.updateViewUI = () => {
|
||||
(dm.members || []).find(
|
||||
member => Number(member.id) !== Number(Glow.state.currentUser?.id)
|
||||
) || dm.members?.[0];
|
||||
|
||||
Glow.els.headerTitle.textContent =
|
||||
peer?.display_name || peer?.username || "Direct message";
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
peer?.username ? `@${peer.username}` : "Private conversation";
|
||||
Glow.refreshPresence?.();
|
||||
Glow.updateChatControls();
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -164,6 +307,7 @@ Glow.updateViewUI = () => {
|
||||
}
|
||||
|
||||
Glow.updateChatControls();
|
||||
Glow.calls?.renderHeaderCallChip?.();
|
||||
};
|
||||
|
||||
Glow.updateAuthUI = () => {
|
||||
@ -260,6 +404,7 @@ Glow.checkAuth = async () => {
|
||||
|
||||
Glow.authenticate = async endpoint => {
|
||||
Glow.clearAuthNotice();
|
||||
|
||||
const accessKey = Glow.els.accessKeyInput.value;
|
||||
const username = Glow.els.usernameInput.value.trim().toLowerCase();
|
||||
const password = Glow.els.passwordInput.value;
|
||||
@ -312,10 +457,28 @@ Glow.authenticate = async endpoint => {
|
||||
};
|
||||
|
||||
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 {
|
||||
await Glow.apiRequest("/api/logout", { method: "POST" });
|
||||
} 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.csrfToken = "";
|
||||
Glow.state.rooms = [];
|
||||
@ -332,6 +495,9 @@ Glow.logout = async () => {
|
||||
Glow.state.activePage = null;
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.state.settings = null;
|
||||
Glow.state.settingsSaved = null;
|
||||
Glow.state.settingsDraft = null;
|
||||
Glow.state.settingsDirty = false;
|
||||
|
||||
Glow.clearMessages();
|
||||
Glow.clearFeed();
|
||||
@ -342,6 +508,8 @@ Glow.logout = async () => {
|
||||
Glow.endLocalCallUi?.();
|
||||
|
||||
clearTimeout(Glow.state.calls?.invitePollTimer);
|
||||
clearTimeout(Glow.state.calls?.participantPollTimer);
|
||||
clearTimeout(Glow.state.calls?.heartbeatTimer);
|
||||
|
||||
Glow.updateAuthUI();
|
||||
|
||||
@ -387,11 +555,13 @@ Glow.toggleTypingIndicators = () => {
|
||||
"glow_typing_enabled",
|
||||
String(Glow.state.typingIndicatorsEnabled)
|
||||
);
|
||||
|
||||
if (!Glow.state.typingIndicatorsEnabled) {
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping();
|
||||
}
|
||||
|
||||
Glow.updateAdminPreferenceButtons?.();
|
||||
Glow.showToast(
|
||||
Glow.state.typingIndicatorsEnabled
|
||||
@ -408,7 +578,7 @@ Glow.pingPresence = async () => {
|
||||
};
|
||||
|
||||
if (Glow.state.activeDm?.id) {
|
||||
payload.direct_conversation_id = Glow.state.activeDm.id;
|
||||
payload.dm_id = Glow.state.activeDm.id;
|
||||
} else if (Glow.state.activeChannel?.id) {
|
||||
payload.channel_id = Glow.state.activeChannel.id;
|
||||
} else if (Glow.state.activeRoom?.id) {
|
||||
@ -457,6 +627,7 @@ Glow.refreshPresence = async () => {
|
||||
|
||||
Glow.schedulePoll = () => {
|
||||
clearTimeout(Glow.state.pollTimer);
|
||||
|
||||
if (Glow.els.blockedOverlay.classList.contains("hidden") === false) return;
|
||||
|
||||
Glow.state.pollTimer = setTimeout(async () => {
|
||||
@ -468,10 +639,8 @@ Glow.schedulePoll = () => {
|
||||
Glow.state.activeView === "dm"
|
||||
) {
|
||||
await Glow.pollMessages();
|
||||
await Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "thread") {
|
||||
await Glow.pollThread?.();
|
||||
await Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "dms") {
|
||||
await Glow.loadDms?.().catch(() => {});
|
||||
}
|
||||
@ -489,6 +658,7 @@ Glow.els.messageInput.addEventListener("input", () => {
|
||||
Glow.autoResizeComposer();
|
||||
Glow.updateMessageCount();
|
||||
clearTimeout(Glow.state.typingTimer);
|
||||
|
||||
if (Glow.els.messageInput.value.trim()) {
|
||||
Glow.sendTyping(true);
|
||||
Glow.state.typingTimer = setTimeout(() => {
|
||||
@ -505,6 +675,23 @@ Glow.els.threadInput?.addEventListener("input", () => {
|
||||
});
|
||||
|
||||
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) {
|
||||
event.preventDefault();
|
||||
Glow.sendMessage(event);
|
||||
@ -512,6 +699,23 @@ Glow.els.messageInput.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) {
|
||||
event.preventDefault();
|
||||
Glow.sendThreadReply?.(event);
|
||||
@ -520,10 +724,12 @@ Glow.els.threadInput?.addEventListener("keydown", event => {
|
||||
|
||||
Glow.els.attachmentInput.addEventListener("change", () => {
|
||||
const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files);
|
||||
|
||||
if (!files.length) {
|
||||
Glow.setSelectedAttachment([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
|
||||
if (invalid) {
|
||||
Glow.showToast("Attachments must be 5 MB or smaller.", "error");
|
||||
@ -531,15 +737,18 @@ Glow.els.attachmentInput.addEventListener("change", () => {
|
||||
Glow.setSelectedAttachment([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.setSelectedAttachment(files);
|
||||
});
|
||||
|
||||
Glow.els.threadAttachmentInput?.addEventListener("change", () => {
|
||||
const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files);
|
||||
|
||||
if (!files.length) {
|
||||
Glow.setSelectedThreadAttachment?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES);
|
||||
if (invalid) {
|
||||
Glow.showToast("Attachments must be 5 MB or smaller.", "error");
|
||||
@ -547,34 +756,49 @@ Glow.els.threadAttachmentInput?.addEventListener("change", () => {
|
||||
Glow.setSelectedThreadAttachment?.([]);
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.setSelectedThreadAttachment?.(files);
|
||||
});
|
||||
|
||||
Glow.els.feedButton.addEventListener("click", Glow.openFeed);
|
||||
|
||||
Glow.els.dmsButton?.addEventListener("click", async () => {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadDms?.();
|
||||
Glow.schedulePoll();
|
||||
Glow.els.feedButton.addEventListener("click", async () => {
|
||||
await Glow.navigateToView("feed", {
|
||||
before: async () => {
|
||||
Glow.state.activeDm = null;
|
||||
},
|
||||
after: async () => {
|
||||
await Glow.loadFeed();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Glow.els.pagesButton?.addEventListener("click", () => {
|
||||
Glow.openPages?.();
|
||||
Glow.els.dmsButton?.addEventListener("click", async () => {
|
||||
await Glow.navigateToView("dms", {
|
||||
after: async () => {
|
||||
await Glow.loadDms?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Glow.els.pagesButton?.addEventListener("click", async () => {
|
||||
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
|
||||
Glow.openPages?.();
|
||||
});
|
||||
});
|
||||
|
||||
Glow.els.backButton?.addEventListener("click", async () => {
|
||||
if (Glow.state.activeView === "thread") {
|
||||
await Glow.returnToChannel?.();
|
||||
} else if (Glow.state.activeView === "page") {
|
||||
await Glow.openPages?.();
|
||||
} else if (Glow.state.activeView === "profile" || Glow.state.activeView === "settings") {
|
||||
await Glow.openFeed?.();
|
||||
} else if (Glow.state.activeView === "dm") {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadDms?.();
|
||||
}
|
||||
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
|
||||
if (Glow.state.activeView === "thread") {
|
||||
await Glow.returnToChannel?.();
|
||||
} else if (Glow.state.activeView === "page") {
|
||||
await Glow.openPages?.();
|
||||
} else if (Glow.state.activeView === "profile" || Glow.state.activeView === "settings") {
|
||||
await Glow.openFeed?.();
|
||||
} else if (Glow.state.activeView === "dm") {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadDms?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Glow.els.loginButton.addEventListener("click", () => {
|
||||
@ -600,19 +824,21 @@ Glow.els.roomSelect.addEventListener("change", async () => {
|
||||
);
|
||||
if (!selected) return;
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeRoom = selected;
|
||||
Glow.state.activeView = "room";
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping();
|
||||
Glow.updateViewUI();
|
||||
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeRoom = selected;
|
||||
Glow.state.activeView = "room";
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping();
|
||||
Glow.updateViewUI();
|
||||
|
||||
await Glow.loadMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
Glow.schedulePoll();
|
||||
await Glow.loadMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
Glow.schedulePoll();
|
||||
});
|
||||
});
|
||||
|
||||
Glow.els.teamSelect?.addEventListener("change", () => {
|
||||
@ -625,14 +851,18 @@ Glow.els.adminFab.addEventListener("click", Glow.openAdminPanel);
|
||||
Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel);
|
||||
Glow.els.createRoomButton.addEventListener("click", Glow.createRoom);
|
||||
Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom);
|
||||
Glow.els.typingToggleButton.addEventListener("click", Glow.toggleTypingIndicators);
|
||||
Glow.els.notificationsToggleButton.addEventListener("click", Glow.toggleNotifications);
|
||||
Glow.els.typingToggleButton?.addEventListener("click", Glow.toggleTypingIndicators);
|
||||
Glow.els.notificationsToggleButton?.addEventListener("click", Glow.toggleNotifications);
|
||||
Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker);
|
||||
|
||||
Glow.els.settingsButton?.addEventListener("click", () => {
|
||||
Glow.toggleHeaderMenu?.();
|
||||
});
|
||||
|
||||
document.getElementById("callStatusChip")?.addEventListener("click", async () => {
|
||||
await Glow.openCallPanel?.();
|
||||
});
|
||||
|
||||
Glow.els.jumpToLatestButton.addEventListener("click", () => {
|
||||
Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight;
|
||||
Glow.resetUnreadMessages();
|
||||
@ -682,11 +912,14 @@ Glow.els.mediaOverlay?.addEventListener("click", event => {
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.closeCallPanelButton?.addEventListener("click", () => {
|
||||
Glow.els.callOverlay?.classList.add("hidden");
|
||||
document.getElementById("closeCallPanelButton")?.addEventListener("click", () => {
|
||||
Glow.calls.ensureState();
|
||||
Glow.state.calls.panelOpen = false;
|
||||
document.getElementById("callOverlay")?.classList.add("hidden");
|
||||
Glow.persistCallPanelPosition?.();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", event => {
|
||||
document.addEventListener("keydown", async event => {
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
if (!Glow.els.dialogOverlay.classList.contains("hidden")) {
|
||||
@ -694,6 +927,11 @@ document.addEventListener("keydown", event => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.els.imageCropOverlay?.classList.contains("hidden")) {
|
||||
Glow.profileResetCropState?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Glow.els.mediaOverlay?.classList.contains("hidden")) {
|
||||
Glow.closeMediaModal?.();
|
||||
return;
|
||||
@ -710,24 +948,33 @@ document.addEventListener("keydown", event => {
|
||||
}
|
||||
|
||||
if (!Glow.els.callOverlay?.classList.contains("hidden")) {
|
||||
Glow.calls.ensureState();
|
||||
Glow.state.calls.panelOpen = false;
|
||||
Glow.els.callOverlay.classList.add("hidden");
|
||||
Glow.persistCallPanelPosition?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeView === "thread") {
|
||||
Glow.returnToChannel?.();
|
||||
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
|
||||
Glow.returnToChannel?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeView === "page") {
|
||||
Glow.openPages?.();
|
||||
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
|
||||
Glow.openPages?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (Glow.state.activeView === "dm") {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
Glow.loadDms?.();
|
||||
await Glow.confirmDirtySettingsBeforeNavigate(async () => {
|
||||
Glow.state.activeView = "dms";
|
||||
Glow.updateViewUI();
|
||||
Glow.loadDms?.();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -752,10 +999,8 @@ document.addEventListener("visibilitychange", () => {
|
||||
) {
|
||||
Glow.pollMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "thread") {
|
||||
Glow.pollThread?.();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "dms") {
|
||||
Glow.loadDms?.();
|
||||
}
|
||||
@ -777,10 +1022,8 @@ window.addEventListener("online", () => {
|
||||
) {
|
||||
Glow.pollMessages();
|
||||
Glow.pollTyping();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "thread") {
|
||||
Glow.pollThread?.();
|
||||
Glow.pollCallSignals?.();
|
||||
} else if (Glow.state.activeView === "dms") {
|
||||
Glow.loadDms?.();
|
||||
}
|
||||
@ -815,9 +1058,43 @@ setInterval(() => {
|
||||
}
|
||||
}, 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.autoResizeComposer();
|
||||
Glow.updateMessageCount();
|
||||
Glow.autoResizeThreadComposer?.();
|
||||
Glow.updateThreadMessageCount?.();
|
||||
Glow.checkAuth();
|
||||
Glow.state.dmLayoutMode = Glow.loadDmLayoutPreference();
|
||||
Glow.checkAuth();
|
||||
|
||||
@ -99,14 +99,36 @@ Glow.openFeedEntry = async entry => {
|
||||
Glow.showToast("This conversation is no longer available.", "error");
|
||||
};
|
||||
|
||||
Glow.feedAttachmentSummary = entry => {
|
||||
const attachments = Array.isArray(entry.attachments)
|
||||
? entry.attachments
|
||||
: entry.attachment
|
||||
? [entry.attachment]
|
||||
: [];
|
||||
|
||||
if (!attachments.length) return "";
|
||||
|
||||
if (attachments.length > 1) {
|
||||
return `${attachments.length} attachments`;
|
||||
}
|
||||
|
||||
const item = attachments[0];
|
||||
const mime = String(item?.mime_type || "").toLowerCase();
|
||||
|
||||
if (mime.startsWith("image/")) return "image attachment";
|
||||
if (mime.startsWith("audio/")) return "audio attachment";
|
||||
if (mime.startsWith("video/")) return "video attachment";
|
||||
return "file attachment";
|
||||
};
|
||||
|
||||
Glow.buildFeedReactions = entry => {
|
||||
const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
|
||||
if (!reactions.length) return null;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "feed-entry-reactions";
|
||||
row.className = "feed-entry-reactions compact";
|
||||
|
||||
for (const reaction of reactions) {
|
||||
for (const reaction of reactions.slice(0, 6)) {
|
||||
const chip = document.createElement("button");
|
||||
chip.className = "reaction-chip";
|
||||
chip.type = "button";
|
||||
@ -136,97 +158,35 @@ Glow.buildFeedReactions = entry => {
|
||||
return row;
|
||||
};
|
||||
|
||||
Glow.buildFeedAttachmentBlock = entry => {
|
||||
const attachments = Array.isArray(entry.attachments)
|
||||
? entry.attachments
|
||||
: entry.attachment
|
||||
? [entry.attachment]
|
||||
: [];
|
||||
|
||||
if (!attachments.length) return null;
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "message-attachments-list";
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (String(attachment.mime_type || "").startsWith("image/")) {
|
||||
const imageButton = document.createElement("button");
|
||||
imageButton.className = "message-image-link";
|
||||
imageButton.type = "button";
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "message-image-preview";
|
||||
image.src = attachment.url;
|
||||
image.alt = attachment.original_name || "Image attachment";
|
||||
image.loading = "lazy";
|
||||
|
||||
imageButton.append(image);
|
||||
imageButton.addEventListener("click", () => {
|
||||
Glow.openMediaModal?.({
|
||||
url: attachment.url,
|
||||
name: attachment.original_name,
|
||||
mimeType: attachment.mime_type
|
||||
});
|
||||
});
|
||||
|
||||
wrap.append(imageButton);
|
||||
} else if (String(attachment.mime_type || "").startsWith("audio/")) {
|
||||
const audio = document.createElement("audio");
|
||||
audio.controls = true;
|
||||
audio.preload = "metadata";
|
||||
audio.src = attachment.url;
|
||||
wrap.append(audio);
|
||||
} else if (String(attachment.mime_type || "").startsWith("video/")) {
|
||||
const video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
video.src = attachment.url;
|
||||
video.className = "message-video-preview";
|
||||
wrap.append(video);
|
||||
}
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.className = "message-attachment";
|
||||
link.href = attachment.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.download = attachment.original_name;
|
||||
link.textContent = `Attachment: ${attachment.original_name}`;
|
||||
wrap.append(link);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.buildFeedEntry = entry => {
|
||||
const card = document.createElement("article");
|
||||
card.className = "feed-entry";
|
||||
const row = document.createElement("article");
|
||||
row.className = "feed-entry timeline-row";
|
||||
|
||||
if (entry.is_mention) {
|
||||
card.classList.add("mention");
|
||||
row.classList.add("mention");
|
||||
}
|
||||
|
||||
card.dataset.messageId = String(entry.id);
|
||||
row.dataset.messageId = String(entry.id);
|
||||
|
||||
if (entry.room_slug) card.dataset.roomSlug = entry.room_slug;
|
||||
if (entry.team_slug) card.dataset.teamSlug = entry.team_slug;
|
||||
if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug;
|
||||
if (entry.room_slug) row.dataset.roomSlug = entry.room_slug;
|
||||
if (entry.team_slug) row.dataset.teamSlug = entry.team_slug;
|
||||
if (entry.channel_slug) row.dataset.channelSlug = entry.channel_slug;
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "feed-entry-heading";
|
||||
|
||||
const room = document.createElement("button");
|
||||
room.className = "feed-room-link";
|
||||
room.type = "button";
|
||||
const top = document.createElement("div");
|
||||
top.className = "feed-entry-top";
|
||||
|
||||
const context = document.createElement("button");
|
||||
context.className = "feed-room-link";
|
||||
context.type = "button";
|
||||
|
||||
if (entry.team_name && entry.channel_name) {
|
||||
room.textContent = `${entry.team_name} · # ${entry.channel_name}`;
|
||||
context.textContent = `${entry.team_name} · # ${entry.channel_name}`;
|
||||
} else {
|
||||
room.textContent =
|
||||
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
|
||||
context.textContent =
|
||||
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
|
||||
}
|
||||
|
||||
room.addEventListener("click", () => {
|
||||
context.addEventListener("click", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
@ -236,7 +196,7 @@ Glow.buildFeedEntry = entry => {
|
||||
time.textContent = Glow.relativeTime(entry.created_at);
|
||||
time.title = new Date(entry.created_at).toLocaleString();
|
||||
|
||||
heading.append(room, time);
|
||||
top.append(context, time);
|
||||
|
||||
const author = document.createElement("button");
|
||||
author.className = "feed-entry-author profile-link";
|
||||
@ -248,37 +208,59 @@ Glow.buildFeedEntry = entry => {
|
||||
});
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "feed-entry-body";
|
||||
body.textContent = entry.text;
|
||||
body.className = "feed-entry-body clamp-2";
|
||||
body.textContent = entry.text || "";
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "feed-entry-footer";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "feed-entry-meta";
|
||||
|
||||
const attachmentSummary = Glow.feedAttachmentSummary(entry);
|
||||
if (attachmentSummary) {
|
||||
const attachmentButton = document.createElement("button");
|
||||
attachmentButton.className = "small-button feed-attachment-summary";
|
||||
attachmentButton.type = "button";
|
||||
attachmentButton.textContent = attachmentSummary;
|
||||
attachmentButton.addEventListener("click", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
meta.append(attachmentButton);
|
||||
}
|
||||
|
||||
const attachmentBlock = Glow.buildFeedAttachmentBlock(entry);
|
||||
const reactions = Glow.buildFeedReactions(entry);
|
||||
if (reactions) {
|
||||
meta.append(reactions);
|
||||
}
|
||||
|
||||
const openButton = document.createElement("button");
|
||||
openButton.className = "small-button feed-open-room";
|
||||
openButton.type = "button";
|
||||
openButton.textContent = "Open channel";
|
||||
openButton.textContent = "Open";
|
||||
openButton.addEventListener("click", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
card.addEventListener("dblclick", () => {
|
||||
footer.append(meta, openButton);
|
||||
|
||||
row.addEventListener("dblclick", () => {
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
card.append(heading, author, body);
|
||||
row.addEventListener("click", event => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
(event.target.closest("button") || event.target.closest("a"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Glow.openFeedEntry(entry);
|
||||
});
|
||||
|
||||
if (attachmentBlock) {
|
||||
card.append(attachmentBlock);
|
||||
}
|
||||
row.append(top, author, body, footer);
|
||||
|
||||
if (reactions) {
|
||||
card.append(reactions);
|
||||
}
|
||||
|
||||
card.append(openButton);
|
||||
|
||||
return card;
|
||||
return row;
|
||||
};
|
||||
|
||||
Glow.appendFeedEntries = (entries, forceScroll = false) => {
|
||||
@ -427,4 +409,4 @@ Glow.openFeed = async () => {
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadFeed();
|
||||
Glow.schedulePoll();
|
||||
};
|
||||
};
|
||||
@ -1,58 +1,49 @@
|
||||
// static/js/06-pages.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
// comments, version history, and share links intentionally disabled in production until backend support complete
|
||||
|
||||
Glow.renderPagesEmptyState = message => {
|
||||
Glow.els.pagesBox.replaceChildren();
|
||||
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = message;
|
||||
|
||||
state.append(card);
|
||||
Glow.els.pagesBox.append(state);
|
||||
};
|
||||
|
||||
Glow.pageTypeLabel = type => {
|
||||
const labels = {
|
||||
project: "Project",
|
||||
document: "Document",
|
||||
update: "Update",
|
||||
resource: "Resource"
|
||||
project: "project",
|
||||
document: "document",
|
||||
update: "update",
|
||||
resource: "resource"
|
||||
};
|
||||
|
||||
return labels[type] || "Page";
|
||||
return labels[type] || "page";
|
||||
};
|
||||
|
||||
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
|
||||
const team = Glow.state.activeTeam;
|
||||
|
||||
if (!team) {
|
||||
Glow.renderPagesEmptyState("Select a team to view pages.");
|
||||
Glow.renderPagesEmptyState("select a team to view pages.");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.els.pagesBox.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
|
||||
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(team.slug)}/pages${query}`
|
||||
);
|
||||
|
||||
if (Glow.state.activeTeam?.slug !== team.slug) return;
|
||||
|
||||
Glow.state.pages = data.pages || [];
|
||||
Glow.state.pageTypeFilter = pageType || "";
|
||||
Glow.renderPages();
|
||||
} catch (error) {
|
||||
Glow.renderPagesEmptyState(error.message || "Could not load pages.");
|
||||
Glow.showToast(error.message || "Could not load pages.", "error");
|
||||
Glow.renderPagesEmptyState(error.message || "could not load pages.");
|
||||
Glow.showToast(error.message || "could not load pages.", "error");
|
||||
} finally {
|
||||
Glow.els.pagesBox.setAttribute("aria-busy", "false");
|
||||
}
|
||||
@ -62,20 +53,16 @@ Glow.renderPages = () => {
|
||||
const box = Glow.els.pagesBox;
|
||||
const team = Glow.state.activeTeam;
|
||||
const pages = Glow.state.pages || [];
|
||||
|
||||
box.replaceChildren();
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "pages-header";
|
||||
|
||||
const copy = document.createElement("div");
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = `${team?.name || "Team"} pages`;
|
||||
|
||||
title.textContent = `${team?.name || "team"} pages`;
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.textContent = "Shared team knowledge and public resources.";
|
||||
|
||||
subtitle.textContent = "shared team knowledge and public resources.";
|
||||
copy.append(title, subtitle);
|
||||
header.append(copy);
|
||||
|
||||
@ -83,23 +70,21 @@ Glow.renderPages = () => {
|
||||
const create = document.createElement("button");
|
||||
create.className = "button";
|
||||
create.type = "button";
|
||||
create.textContent = "New page";
|
||||
create.textContent = "new page";
|
||||
create.addEventListener("click", Glow.createPage);
|
||||
|
||||
header.append(create);
|
||||
}
|
||||
|
||||
box.append(header);
|
||||
|
||||
const filters = document.createElement("div");
|
||||
filters.className = "page-filters";
|
||||
|
||||
filters.className = "page-filters compact";
|
||||
const filterOptions = [
|
||||
["", "All"],
|
||||
["project", "Projects"],
|
||||
["document", "Documents"],
|
||||
["update", "Updates"],
|
||||
["resource", "Resources"]
|
||||
["", "all"],
|
||||
["project", "projects"],
|
||||
["document", "documents"],
|
||||
["update", "updates"],
|
||||
["resource", "resources"]
|
||||
];
|
||||
|
||||
for (const [value, label] of filterOptions) {
|
||||
@ -108,7 +93,6 @@ Glow.renderPages = () => {
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.classList.toggle("active", Glow.state.pageTypeFilter === value);
|
||||
|
||||
button.addEventListener("click", () => Glow.loadPages(value));
|
||||
filters.append(button);
|
||||
}
|
||||
@ -118,11 +102,9 @@ Glow.renderPages = () => {
|
||||
if (!pages.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty-state page-empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = "No pages match this filter.";
|
||||
|
||||
card.textContent = "no pages match this filter.";
|
||||
empty.append(card);
|
||||
box.append(empty);
|
||||
return;
|
||||
@ -140,7 +122,7 @@ Glow.renderPages = () => {
|
||||
|
||||
Glow.buildPageCard = page => {
|
||||
const article = document.createElement("article");
|
||||
article.className = "page-card";
|
||||
article.className = "page-card compact";
|
||||
article.tabIndex = 0;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
@ -159,31 +141,22 @@ Glow.buildPageCard = page => {
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = page.title;
|
||||
|
||||
const excerpt = document.createElement("p");
|
||||
excerpt.className = "page-card-excerpt";
|
||||
|
||||
const compactBody = (page.body || "").replace(/\s+/g, " ").trim();
|
||||
|
||||
excerpt.textContent =
|
||||
compactBody.length > 180
|
||||
? `${compactBody.slice(0, 180)}…`
|
||||
: compactBody || "No content yet.";
|
||||
|
||||
const footer = document.createElement("div");
|
||||
footer.className = "page-card-footer";
|
||||
|
||||
const context = document.createElement("span");
|
||||
context.textContent = page.channel_name ? `#${page.channel_name}` : "Team page";
|
||||
context.textContent = page.channel_name
|
||||
? `#${page.channel_name}`
|
||||
: "team page";
|
||||
|
||||
const updated = document.createElement("time");
|
||||
updated.dateTime = page.updated_at;
|
||||
updated.title = new Date(page.updated_at).toLocaleString();
|
||||
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
|
||||
updated.textContent = `updated ${Glow.relativeTime(page.updated_at)}`;
|
||||
|
||||
footer.append(context, updated);
|
||||
|
||||
const open = () => Glow.openPage(page.slug);
|
||||
|
||||
article.addEventListener("click", open);
|
||||
article.addEventListener("keydown", event => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
@ -192,7 +165,7 @@ Glow.buildPageCard = page => {
|
||||
}
|
||||
});
|
||||
|
||||
article.append(meta, title, excerpt, footer);
|
||||
article.append(meta, title, footer);
|
||||
return article;
|
||||
};
|
||||
|
||||
@ -200,7 +173,6 @@ Glow.openPages = async () => {
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeView = "pages";
|
||||
Glow.state.activePage = null;
|
||||
|
||||
Glow.updateViewUI();
|
||||
await Glow.loadPages();
|
||||
};
|
||||
@ -212,18 +184,16 @@ Glow.openPage = async pageSlug => {
|
||||
Glow.state.activeView = "page";
|
||||
Glow.state.activePage = null;
|
||||
Glow.updateViewUI();
|
||||
|
||||
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
|
||||
|
||||
try {
|
||||
const data = await Glow.apiRequest(
|
||||
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
|
||||
);
|
||||
|
||||
Glow.state.activePage = data.page;
|
||||
Glow.renderPageDetail();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not load page.", "error");
|
||||
Glow.showToast(error.message || "could not load page.", "error");
|
||||
Glow.openPages();
|
||||
} finally {
|
||||
Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
|
||||
@ -233,17 +203,14 @@ Glow.openPage = async pageSlug => {
|
||||
Glow.renderPageDetail = () => {
|
||||
const box = Glow.els.pageDetailBox;
|
||||
const page = Glow.state.activePage;
|
||||
|
||||
box.replaceChildren();
|
||||
|
||||
if (!page) {
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = "Page not found.";
|
||||
|
||||
card.textContent = "page not found.";
|
||||
state.append(card);
|
||||
box.append(state);
|
||||
return;
|
||||
@ -267,7 +234,7 @@ Glow.renderPageDetail = () => {
|
||||
updated.className = "page-updated";
|
||||
updated.dateTime = page.updated_at;
|
||||
updated.title = new Date(page.updated_at).toLocaleString();
|
||||
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
|
||||
updated.textContent = `updated ${Glow.relativeTime(page.updated_at)}`;
|
||||
|
||||
meta.append(type, visibility, updated);
|
||||
|
||||
@ -285,61 +252,61 @@ Glow.renderPageDetail = () => {
|
||||
|
||||
const body = document.createElement("article");
|
||||
body.className = "page-body markdown-body";
|
||||
body.innerHTML = page.body_html || "<p>No content yet.</p>";
|
||||
|
||||
body.innerHTML = page.body_html || "<p>no content yet.</p>";
|
||||
box.append(body);
|
||||
|
||||
if (page.can_edit) {
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "page-detail-actions";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "page-detail-actions";
|
||||
|
||||
if (page.can_edit) {
|
||||
const edit = document.createElement("button");
|
||||
edit.className = "button";
|
||||
edit.type = "button";
|
||||
edit.textContent = "Edit";
|
||||
edit.textContent = "edit";
|
||||
edit.addEventListener("click", () => Glow.editPage(page));
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "danger-button";
|
||||
remove.type = "button";
|
||||
remove.textContent = "Delete";
|
||||
remove.textContent = "delete";
|
||||
remove.addEventListener("click", () => Glow.deletePage(page));
|
||||
|
||||
actions.append(edit, remove);
|
||||
box.append(actions);
|
||||
}
|
||||
|
||||
box.append(actions);
|
||||
};
|
||||
|
||||
Glow.pageDialogFields = (page = null) => [
|
||||
{
|
||||
name: "title",
|
||||
label: "Title",
|
||||
label: "title",
|
||||
value: page?.title || "",
|
||||
placeholder: "Page title",
|
||||
placeholder: "page title",
|
||||
maxLength: 160
|
||||
},
|
||||
{
|
||||
name: "page_type",
|
||||
label: "Type: project, document, update, or resource",
|
||||
label: "type: project, document, update, or resource",
|
||||
value: page?.page_type || "document",
|
||||
maxLength: 12
|
||||
},
|
||||
{
|
||||
name: "visibility",
|
||||
label: "Visibility: team or public",
|
||||
label: "visibility: team or public",
|
||||
value: page?.visibility || "team",
|
||||
maxLength: 6
|
||||
},
|
||||
{
|
||||
name: "channel_slug",
|
||||
label: "Channel slug (optional)",
|
||||
label: "channel slug (optional)",
|
||||
value: page?.channel_slug || "",
|
||||
placeholder: "general",
|
||||
maxLength: 48
|
||||
},
|
||||
{
|
||||
name: "body",
|
||||
label: "Markdown content",
|
||||
label: "markdown content",
|
||||
value: page?.body || "",
|
||||
multiline: true,
|
||||
rows: 14,
|
||||
@ -349,19 +316,19 @@ Glow.pageDialogFields = (page = null) => [
|
||||
|
||||
Glow.validatePageValues = values => {
|
||||
if (!values.title) {
|
||||
Glow.showToast("Page title is required.", "error");
|
||||
Glow.showToast("page title is required.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
const pageType = values.page_type.toLowerCase();
|
||||
if (!["project", "document", "update", "resource"].includes(pageType)) {
|
||||
Glow.showToast("Choose a valid page type.", "error");
|
||||
Glow.showToast("choose a valid page type.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
const visibility = values.visibility.toLowerCase();
|
||||
if (!["team", "public"].includes(visibility)) {
|
||||
Glow.showToast("Visibility must be team or public.", "error");
|
||||
Glow.showToast("visibility must be team or public.", "error");
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -379,12 +346,11 @@ Glow.createPage = async () => {
|
||||
if (!team?.can_manage) return;
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: "Create page",
|
||||
body: "Create a shared team page or a public resource. Markdown is supported.",
|
||||
confirmText: "Create page",
|
||||
title: "create page",
|
||||
body: "create a shared team page or a public resource. markdown is supported.",
|
||||
confirmText: "create page",
|
||||
fields: Glow.pageDialogFields()
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const payload = Glow.validatePageValues(values);
|
||||
@ -399,11 +365,10 @@ Glow.createPage = async () => {
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
Glow.showToast("Page created.");
|
||||
Glow.showToast("page created.");
|
||||
await Glow.openPage(data.page.slug);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not create page.", "error");
|
||||
Glow.showToast(error.message || "could not create page.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
@ -411,11 +376,10 @@ Glow.editPage = async page => {
|
||||
if (!page.can_edit) return;
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: `Edit ${page.title}`,
|
||||
confirmText: "Save",
|
||||
title: `edit ${page.title}`,
|
||||
confirmText: "save",
|
||||
fields: Glow.pageDialogFields(page)
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
const payload = Glow.validatePageValues(values);
|
||||
@ -430,12 +394,11 @@ Glow.editPage = async page => {
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
Glow.state.activePage = data.page;
|
||||
Glow.renderPageDetail();
|
||||
Glow.showToast("Page updated.");
|
||||
Glow.showToast("page updated.");
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update page.", "error");
|
||||
Glow.showToast(error.message || "could not update page.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
@ -443,12 +406,11 @@ Glow.deletePage = async page => {
|
||||
if (!page.can_edit) return;
|
||||
|
||||
const result = await Glow.openDialog({
|
||||
title: `Delete ${page.title}?`,
|
||||
body: "This page will no longer be available.",
|
||||
confirmText: "Delete page",
|
||||
title: `delete ${page.title}?`,
|
||||
body: "this page will no longer be available.",
|
||||
confirmText: "delete page",
|
||||
danger: true
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
|
||||
try {
|
||||
@ -456,10 +418,9 @@ Glow.deletePage = async page => {
|
||||
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
Glow.showToast("Page deleted.");
|
||||
Glow.showToast("page deleted.");
|
||||
await Glow.openPages();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not delete page.", "error");
|
||||
Glow.showToast(error.message || "could not delete page.", "error");
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -3,11 +3,14 @@
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
// note:
|
||||
// this file keeps team/channel helper methods available,
|
||||
// but the old production monkey-patching adapter is disabled.
|
||||
// rooms remain the stable fallback runtime path.
|
||||
|
||||
Glow.teamAdapter = Glow.teamAdapter || {
|
||||
initialized: false,
|
||||
originalLoadRooms: null,
|
||||
originalUpdateViewUI: null,
|
||||
chatBarVisibilityInstalled: false
|
||||
enabled: false
|
||||
};
|
||||
|
||||
Glow.loadTeamsForAdapter = async () => {
|
||||
@ -43,7 +46,7 @@ Glow.loadTeamsForAdapter = async () => {
|
||||
Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Team adapter could not load teams:", error);
|
||||
console.warn("could not load teams:", error);
|
||||
Glow.state.teams = [];
|
||||
Glow.state.activeTeam = null;
|
||||
Glow.els.teamSelect.disabled = true;
|
||||
@ -69,7 +72,6 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
|
||||
Glow.state.channels = channels;
|
||||
|
||||
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
|
||||
|
||||
if (matchingTeam || data.team) {
|
||||
Glow.state.activeTeam = {
|
||||
...(matchingTeam || {}),
|
||||
@ -89,24 +91,21 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
|
||||
const selected =
|
||||
channels.find(channel => channel.slug === preferredChannelSlug) ||
|
||||
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) ||
|
||||
channels.find(channel => channel.slug === Glow.state.activeRoom?.slug) ||
|
||||
channels.find(channel => channel.slug === "general") ||
|
||||
channels[0] ||
|
||||
null;
|
||||
|
||||
Glow.state.activeChannel = selected;
|
||||
Glow.state.activeRoom = selected;
|
||||
|
||||
Glow.els.roomSelect.disabled = !selected;
|
||||
|
||||
if (selected) {
|
||||
Glow.state.activeRoom = selected;
|
||||
Glow.els.roomSelect.value = selected.slug;
|
||||
}
|
||||
|
||||
Glow.els.roomSelect.disabled = !selected;
|
||||
};
|
||||
|
||||
Glow.decorateChannelSelector = () => {
|
||||
if (!Glow.els.roomSelect) return;
|
||||
|
||||
Glow.els.roomSelect.classList.add("channel-select");
|
||||
Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
|
||||
};
|
||||
@ -114,21 +113,23 @@ Glow.decorateChannelSelector = () => {
|
||||
Glow.updateTeamChannelHeader = () => {
|
||||
const team = Glow.state.activeTeam;
|
||||
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
|
||||
|
||||
const isChannelView =
|
||||
Glow.state.activeView === "channel" ||
|
||||
Glow.state.activeView === "room";
|
||||
const view = Glow.state.activeView;
|
||||
|
||||
if (
|
||||
Glow.state.activeView === "dm" ||
|
||||
Glow.state.activeView === "dms"
|
||||
view === "dm" ||
|
||||
view === "dms" ||
|
||||
view === "feed" ||
|
||||
view === "pages" ||
|
||||
view === "page" ||
|
||||
view === "profile" ||
|
||||
view === "settings" ||
|
||||
view === "thread"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isChannelView || !team || !channel || !Glow.els.headerTitle) {
|
||||
return;
|
||||
}
|
||||
if (!(view === "channel" || view === "room")) return;
|
||||
if (!team || !channel || !Glow.els.headerTitle) return;
|
||||
|
||||
Glow.els.headerTitle.textContent = `# ${channel.name}`;
|
||||
Glow.els.headerSubtitle.textContent =
|
||||
@ -530,152 +531,10 @@ Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) =>
|
||||
|
||||
Glow.installTeamAdapter = () => {
|
||||
if (Glow.teamAdapter.initialized) return;
|
||||
|
||||
if (!Glow.els.teamSelect || !Glow.els.roomSelect) {
|
||||
console.warn("Team adapter skipped: expected team/channel elements are missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.teamAdapter.initialized = true;
|
||||
|
||||
Glow.state.teams ||= [];
|
||||
Glow.state.channels ||= [];
|
||||
Glow.state.activeTeam ||= null;
|
||||
Glow.state.activeChannel ||= null;
|
||||
|
||||
Glow.decorateChannelSelector();
|
||||
|
||||
Glow.teamAdapter.originalLoadRooms = Glow.loadRooms;
|
||||
|
||||
Glow.loadRooms = async () => {
|
||||
await Glow.teamAdapter.originalLoadRooms();
|
||||
await Glow.loadTeamsForAdapter();
|
||||
Glow.decorateChannelSelector();
|
||||
|
||||
if (Glow.state.activeTeam) {
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(
|
||||
Glow.state.activeTeam.slug,
|
||||
Glow.state.activeRoom?.slug
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Team adapter could not load channels after rooms:", error);
|
||||
}
|
||||
}
|
||||
|
||||
Glow.updateTeamChannelHeader();
|
||||
};
|
||||
|
||||
Glow.teamAdapter.originalUpdateViewUI = Glow.updateViewUI;
|
||||
|
||||
Glow.updateViewUI = () => {
|
||||
Glow.teamAdapter.originalUpdateViewUI?.();
|
||||
Glow.updateTeamChannelHeader();
|
||||
Glow.applyComposerVisibility();
|
||||
};
|
||||
|
||||
const initializeAdapter = async () => {
|
||||
await Glow.loadTeamsForAdapter();
|
||||
Glow.decorateChannelSelector();
|
||||
|
||||
if (!Glow.state.activeTeam) {
|
||||
Glow.applyComposerVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(
|
||||
Glow.state.activeTeam.slug,
|
||||
Glow.state.activeRoom?.slug
|
||||
);
|
||||
|
||||
Glow.updateTeamChannelHeader();
|
||||
} catch (error) {
|
||||
console.warn("Team adapter could not load initial channels:", error);
|
||||
}
|
||||
|
||||
Glow.applyComposerVisibility();
|
||||
};
|
||||
|
||||
initializeAdapter();
|
||||
|
||||
Glow.els.teamSelect.addEventListener("change", async () => {
|
||||
const selected = Glow.state.teams.find(
|
||||
team => team.slug === Glow.els.teamSelect.value
|
||||
);
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeTeam = selected;
|
||||
Glow.state.activeChannel = null;
|
||||
Glow.state.activeRoom = null;
|
||||
|
||||
Glow.state.typingUsers = [];
|
||||
Glow.state.dmTypingUsers = [];
|
||||
Glow.renderTyping?.();
|
||||
Glow.clearMessages?.();
|
||||
|
||||
try {
|
||||
await Glow.loadChannelsForTeam(selected.slug);
|
||||
|
||||
Glow.state.activeView = "channel";
|
||||
Glow.updateViewUI();
|
||||
|
||||
if (Glow.state.activeChannel) {
|
||||
await Glow.loadChannelMessages?.();
|
||||
Glow.pollTyping?.();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
} else {
|
||||
Glow.renderEmptyState?.("No channels are available in this team.");
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not load channels for this team.", "error");
|
||||
}
|
||||
|
||||
Glow.schedulePoll?.();
|
||||
});
|
||||
|
||||
Glow.els.roomSelect.addEventListener("change", () => {
|
||||
window.setTimeout(() => {
|
||||
const selected = Glow.state.channels.find(
|
||||
channel => channel.slug === Glow.els.roomSelect.value
|
||||
);
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
Glow.state.activeDm = null;
|
||||
Glow.state.activeChannel = selected;
|
||||
Glow.state.activeRoom = selected;
|
||||
|
||||
if (
|
||||
Glow.state.activeView === "room" ||
|
||||
Glow.state.activeView === "channel"
|
||||
) {
|
||||
Glow.state.activeView = "channel";
|
||||
}
|
||||
|
||||
Glow.updateViewUI();
|
||||
Glow.pingPresence?.();
|
||||
Glow.refreshPresence?.();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
console.info("Team/channel adapter enabled.");
|
||||
console.info("team/channel helper mode enabled; runtime monkey-patching disabled.");
|
||||
};
|
||||
|
||||
Glow.installTeamAdapter();
|
||||
|
||||
if (!Glow.teamAdapter.chatBarVisibilityInstalled) {
|
||||
Glow.teamAdapter.chatBarVisibilityInstalled = true;
|
||||
|
||||
const previousUpdateViewUI = Glow.updateViewUI;
|
||||
|
||||
Glow.updateViewUI = () => {
|
||||
previousUpdateViewUI?.();
|
||||
Glow.applyComposerVisibility();
|
||||
};
|
||||
}
|
||||
|
||||
Glow.applyComposerVisibility();
|
||||
Glow.applyComposerVisibility();
|
||||
@ -1,8 +1,6 @@
|
||||
// static/js/08-settings.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.state = Glow.state || {};
|
||||
Glow.state.settings = Glow.state.settings || null;
|
||||
|
||||
@ -14,46 +12,179 @@ Glow.applyThemeMode = mode => {
|
||||
|
||||
Glow.applyCustomCss = cssText => {
|
||||
let style = document.getElementById("glowCustomCss");
|
||||
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = "glowCustomCss";
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
style.textContent = typeof cssText === "string" ? cssText : "";
|
||||
};
|
||||
|
||||
Glow.cloneSettingsState = source => {
|
||||
const value = source && typeof source === "object" ? source : {};
|
||||
return {
|
||||
accent_color:
|
||||
Glow.normalizeAccentColor(value.accent_color || Glow.state.accentColor) ||
|
||||
Glow.DEFAULT_ACCENT_COLOR,
|
||||
name_color:
|
||||
Glow.normalizeNameColor(value.name_color) || null,
|
||||
custom_css:
|
||||
typeof value.custom_css === "string" ? value.custom_css : "",
|
||||
theme_mode:
|
||||
value.theme_mode === "light" ? "light" : "dark",
|
||||
profile_background_color:
|
||||
Glow.normalizeAccentColor(value.profile_background_color) || "#121214",
|
||||
profile_banner_color:
|
||||
Glow.normalizeAccentColor(value.profile_banner_color) || "#3a3140",
|
||||
profile_visibility:
|
||||
["public", "members", "private"].includes(
|
||||
String(value.profile_visibility || "").toLowerCase()
|
||||
)
|
||||
? String(value.profile_visibility).toLowerCase()
|
||||
: "public",
|
||||
last_active_visibility:
|
||||
["everyone", "members", "nobody"].includes(
|
||||
String(value.last_active_visibility || "").toLowerCase()
|
||||
)
|
||||
? String(value.last_active_visibility).toLowerCase()
|
||||
: "everyone",
|
||||
presence_mode:
|
||||
["online", "silent", "focus", "dnd"].includes(
|
||||
String(value.presence_mode || "").toLowerCase()
|
||||
)
|
||||
? String(value.presence_mode).toLowerCase()
|
||||
: "online"
|
||||
};
|
||||
};
|
||||
|
||||
Glow.initializeSettingsDraft = settings => {
|
||||
const saved = Glow.cloneSettingsState(settings);
|
||||
Glow.state.settings = saved;
|
||||
Glow.state.settingsSaved = saved;
|
||||
Glow.state.settingsDraft = Glow.cloneSettingsState(saved);
|
||||
Glow.clearSettingsDirty?.();
|
||||
};
|
||||
|
||||
Glow.previewSettingsDraft = () => {
|
||||
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
||||
Glow.applyAccentColor(draft.accent_color);
|
||||
Glow.applyThemeMode(draft.theme_mode);
|
||||
Glow.applyCustomCss(draft.custom_css || "");
|
||||
};
|
||||
|
||||
Glow.updateSettingsDraft = patch => {
|
||||
const current = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
||||
Glow.state.settingsDraft = {
|
||||
...current,
|
||||
...patch
|
||||
};
|
||||
Glow.previewSettingsDraft();
|
||||
Glow.markSettingsDirty?.();
|
||||
};
|
||||
|
||||
Glow.syncSettingsSavePanel = () => {
|
||||
const panel = Glow.els.settingsSavePanel;
|
||||
if (!panel) return;
|
||||
|
||||
const shouldShow =
|
||||
Glow.state.activeView === "settings" &&
|
||||
Glow.isSettingsDirty?.();
|
||||
|
||||
panel.classList.toggle("hidden", !shouldShow);
|
||||
|
||||
if (Glow.els.settingsUnsavedIndicator) {
|
||||
Glow.els.settingsUnsavedIndicator.textContent = shouldShow
|
||||
? "Unsaved changes"
|
||||
: "";
|
||||
}
|
||||
};
|
||||
|
||||
Glow.saveSettingsDraft = async () => {
|
||||
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
||||
|
||||
const payload = {
|
||||
accent_color:
|
||||
Glow.normalizeAccentColor(draft.accent_color) ||
|
||||
Glow.DEFAULT_ACCENT_COLOR,
|
||||
name_color:
|
||||
Glow.normalizeNameColor(draft.name_color) || null,
|
||||
custom_css: draft.custom_css || "",
|
||||
theme_mode: draft.theme_mode === "light" ? "light" : "dark",
|
||||
profile_background_color:
|
||||
Glow.normalizeAccentColor(draft.profile_background_color) || null,
|
||||
profile_banner_color:
|
||||
Glow.normalizeAccentColor(draft.profile_banner_color) || null,
|
||||
profile_visibility:
|
||||
["public", "members", "private"].includes(draft.profile_visibility)
|
||||
? draft.profile_visibility
|
||||
: "public",
|
||||
last_active_visibility:
|
||||
["everyone", "members", "nobody"].includes(draft.last_active_visibility)
|
||||
? draft.last_active_visibility
|
||||
: "everyone",
|
||||
presence_mode:
|
||||
["online", "silent", "focus", "dnd"].includes(draft.presence_mode)
|
||||
? draft.presence_mode
|
||||
: "online"
|
||||
};
|
||||
|
||||
const data = await Glow.apiUpdateMySettings(payload);
|
||||
const merged = {
|
||||
...Glow.cloneSettingsState(draft),
|
||||
...(data.settings || {})
|
||||
};
|
||||
|
||||
Glow.state.settings = merged;
|
||||
Glow.state.settingsSaved = Glow.cloneSettingsState(merged);
|
||||
Glow.state.settingsDraft = Glow.cloneSettingsState(merged);
|
||||
Glow.previewSettingsDraft();
|
||||
Glow.clearSettingsDirty?.();
|
||||
|
||||
if (Glow.state.currentUser) {
|
||||
Glow.state.currentUser.presence_mode =
|
||||
merged.presence_mode || Glow.state.currentUser.presence_mode || "online";
|
||||
}
|
||||
|
||||
Glow.showToast("Settings saved.");
|
||||
Glow.renderSettingsView();
|
||||
};
|
||||
|
||||
Glow.discardSettingsDraft = () => {
|
||||
const saved = Glow.state.settingsSaved || Glow.cloneSettingsState({});
|
||||
Glow.state.settingsDraft = Glow.cloneSettingsState(saved);
|
||||
Glow.previewSettingsDraft();
|
||||
Glow.clearSettingsDirty?.();
|
||||
if (Glow.state.activeView === "settings") {
|
||||
Glow.renderSettingsView();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.loadMySettings = async () => {
|
||||
try {
|
||||
const data = await Glow.apiGetMySettings();
|
||||
Glow.state.settings = data.settings || {};
|
||||
|
||||
const accent = Glow.state.settings.accent_color || Glow.state.accentColor;
|
||||
Glow.applyAccentColor(accent);
|
||||
|
||||
const mode =
|
||||
Glow.state.settings.theme_mode ||
|
||||
localStorage.getItem("glow_theme_mode") ||
|
||||
"dark";
|
||||
|
||||
Glow.applyThemeMode(mode);
|
||||
Glow.applyCustomCss(Glow.state.settings.custom_css || "");
|
||||
const base = Glow.cloneSettingsState(data.settings || {});
|
||||
Glow.initializeSettingsDraft(base);
|
||||
Glow.previewSettingsDraft();
|
||||
} catch (_) {
|
||||
const mode = localStorage.getItem("glow_theme_mode") || "dark";
|
||||
Glow.applyThemeMode(mode);
|
||||
const fallback = Glow.cloneSettingsState({
|
||||
theme_mode: localStorage.getItem("glow_theme_mode") || "dark",
|
||||
accent_color: Glow.state.accentColor,
|
||||
profile_visibility: "public",
|
||||
last_active_visibility: "everyone",
|
||||
presence_mode: "online"
|
||||
});
|
||||
Glow.initializeSettingsDraft(fallback);
|
||||
Glow.previewSettingsDraft();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.renderHeaderMenu = () => {
|
||||
const list = Glow.els.headerMenuList;
|
||||
if (!list) return;
|
||||
|
||||
list.replaceChildren();
|
||||
|
||||
const addItem = ({ label, hint = "", onClick, hidden = false }) => {
|
||||
if (hidden) return;
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.className = "header-menu-item";
|
||||
button.type = "button";
|
||||
@ -69,7 +200,6 @@ Glow.renderHeaderMenu = () => {
|
||||
Glow.closeHeaderMenu?.();
|
||||
onClick?.();
|
||||
});
|
||||
|
||||
list.append(button);
|
||||
};
|
||||
|
||||
@ -180,11 +310,17 @@ Glow.openSettings = async () => {
|
||||
Glow.state.activeView = "settings";
|
||||
Glow.updateViewUI();
|
||||
|
||||
if (!Glow.state.settings && Glow.state.currentUser) {
|
||||
if (!Glow.state.settingsSaved && Glow.state.currentUser) {
|
||||
await Glow.loadMySettings();
|
||||
} else if (!Glow.state.settingsDraft) {
|
||||
Glow.initializeSettingsDraft(
|
||||
Glow.state.settingsSaved || Glow.cloneSettingsState({})
|
||||
);
|
||||
Glow.previewSettingsDraft();
|
||||
}
|
||||
|
||||
Glow.renderSettingsView();
|
||||
Glow.syncSettingsSavePanel();
|
||||
};
|
||||
|
||||
Glow.renderNameColorPreview = value => {
|
||||
@ -207,13 +343,174 @@ Glow.renderNameColorPreview = value => {
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.renderChoicePills = ({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange
|
||||
}) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "settings-row";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.className = "dialog-label";
|
||||
title.textContent = label;
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "settings-theme-row";
|
||||
|
||||
for (const option of options) {
|
||||
const button = document.createElement("button");
|
||||
button.className = `theme-pill${value === option.value ? " active" : ""}`;
|
||||
button.type = "button";
|
||||
button.textContent = option.label;
|
||||
button.addEventListener("click", () => onChange(option.value));
|
||||
row.append(button);
|
||||
}
|
||||
|
||||
wrap.append(title, row);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.renderSettingsView = () => {
|
||||
const box = Glow.els.profileBox;
|
||||
box.replaceChildren();
|
||||
|
||||
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
||||
const card = document.createElement("div");
|
||||
card.className = "settings-card";
|
||||
|
||||
const 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");
|
||||
appearance.className = "settings-section";
|
||||
|
||||
@ -221,37 +518,28 @@ Glow.renderSettingsView = () => {
|
||||
aTitle.textContent = "Appearance";
|
||||
|
||||
const aText = document.createElement("p");
|
||||
aText.textContent = "Set your theme mode, accent color, username color, and custom CSS.";
|
||||
aText.textContent = "Set theme and accent color.";
|
||||
|
||||
const themeRow = document.createElement("div");
|
||||
themeRow.className = "settings-theme-row";
|
||||
|
||||
const currentTheme =
|
||||
Glow.state.settings?.theme_mode ||
|
||||
localStorage.getItem("glow_theme_mode") ||
|
||||
"dark";
|
||||
|
||||
const darkButton = document.createElement("button");
|
||||
darkButton.className = `theme-pill${currentTheme !== "light" ? " active" : ""}`;
|
||||
darkButton.className = `theme-pill${draft.theme_mode !== "light" ? " active" : ""}`;
|
||||
darkButton.type = "button";
|
||||
darkButton.textContent = "Dark";
|
||||
|
||||
const lightButton = document.createElement("button");
|
||||
lightButton.className = `theme-pill${currentTheme === "light" ? " active" : ""}`;
|
||||
lightButton.className = `theme-pill${draft.theme_mode === "light" ? " active" : ""}`;
|
||||
lightButton.type = "button";
|
||||
lightButton.textContent = "Light";
|
||||
|
||||
darkButton.addEventListener("click", () => {
|
||||
Glow.applyThemeMode("dark");
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.theme_mode = "dark";
|
||||
Glow.updateSettingsDraft({ theme_mode: "dark" });
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
lightButton.addEventListener("click", () => {
|
||||
Glow.applyThemeMode("light");
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.theme_mode = "light";
|
||||
Glow.updateSettingsDraft({ theme_mode: "light" });
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
@ -268,97 +556,74 @@ Glow.renderSettingsView = () => {
|
||||
accentInput.type = "color";
|
||||
accentInput.className = "accent-color-input";
|
||||
accentInput.value =
|
||||
Glow.normalizeAccentColor(
|
||||
Glow.state.settings?.accent_color || Glow.state.accentColor
|
||||
) || Glow.DEFAULT_ACCENT_COLOR;
|
||||
Glow.normalizeAccentColor(draft.accent_color) ||
|
||||
Glow.DEFAULT_ACCENT_COLOR;
|
||||
|
||||
accentInput.addEventListener("input", () => {
|
||||
Glow.applyAccentColor(accentInput.value);
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.accent_color = accentInput.value;
|
||||
Glow.updateSettingsDraft({
|
||||
accent_color: accentInput.value
|
||||
});
|
||||
});
|
||||
|
||||
accentLabel.append(accentInput);
|
||||
accentWrap.append(accentLabel);
|
||||
|
||||
const nameColorWrap = document.createElement("div");
|
||||
nameColorWrap.className = "settings-row";
|
||||
appearance.append(aTitle, aText, themeRow, accentWrap);
|
||||
|
||||
const nameColorLabel = document.createElement("label");
|
||||
nameColorLabel.className = "dialog-label";
|
||||
nameColorLabel.textContent = "Username color";
|
||||
const profileVisuals = document.createElement("section");
|
||||
profileVisuals.className = "settings-section";
|
||||
|
||||
const nameColorInput = document.createElement("input");
|
||||
nameColorInput.type = "color";
|
||||
nameColorInput.className = "accent-color-input";
|
||||
nameColorInput.value =
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
const pvTitle = document.createElement("h2");
|
||||
pvTitle.textContent = "Profile visuals";
|
||||
|
||||
const nameColorText = document.createElement("input");
|
||||
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 pvText = document.createElement("p");
|
||||
pvText.textContent = "Adjust background and banner colors used in your profile view.";
|
||||
|
||||
const nameRow = document.createElement("div");
|
||||
nameRow.className = "accent-picker-row";
|
||||
const backgroundWrap = document.createElement("div");
|
||||
backgroundWrap.className = "settings-row";
|
||||
|
||||
const previewSwatch = document.createElement("span");
|
||||
previewSwatch.className = "accent-preview";
|
||||
previewSwatch.style.background =
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
const backgroundLabel = document.createElement("label");
|
||||
backgroundLabel.className = "dialog-label";
|
||||
backgroundLabel.textContent = "Profile background color";
|
||||
|
||||
const help = document.createElement("div");
|
||||
help.className = "header-subtitle";
|
||||
help.textContent =
|
||||
`Leave empty to use your role default (${Glow.roleDefaultNameColor(Glow.state.currentUser?.role)}).`;
|
||||
const backgroundInput = document.createElement("input");
|
||||
backgroundInput.type = "color";
|
||||
backgroundInput.className = "accent-color-input";
|
||||
backgroundInput.value =
|
||||
Glow.normalizeAccentColor(draft.profile_background_color) || "#121214";
|
||||
|
||||
const updatePreview = value => {
|
||||
const normalized = Glow.normalizeNameColor(value);
|
||||
const finalColor =
|
||||
normalized || Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||
|
||||
previewSwatch.style.background = finalColor;
|
||||
return finalColor;
|
||||
};
|
||||
|
||||
nameColorInput.addEventListener("input", () => {
|
||||
nameColorText.value = nameColorInput.value;
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.name_color = nameColorInput.value;
|
||||
updatePreview(nameColorInput.value);
|
||||
Glow.renderSettingsView();
|
||||
backgroundInput.addEventListener("input", () => {
|
||||
Glow.updateSettingsDraft({
|
||||
profile_background_color: backgroundInput.value
|
||||
});
|
||||
});
|
||||
|
||||
nameColorText.addEventListener("input", () => {
|
||||
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.name_color = normalized || null;
|
||||
updatePreview(nameColorText.value.trim());
|
||||
backgroundLabel.append(backgroundInput);
|
||||
backgroundWrap.append(backgroundLabel);
|
||||
|
||||
const bannerWrap = document.createElement("div");
|
||||
bannerWrap.className = "settings-row";
|
||||
|
||||
const bannerLabel = document.createElement("label");
|
||||
bannerLabel.className = "dialog-label";
|
||||
bannerLabel.textContent = "Profile banner color";
|
||||
|
||||
const bannerInput = document.createElement("input");
|
||||
bannerInput.type = "color";
|
||||
bannerInput.className = "accent-color-input";
|
||||
bannerInput.value =
|
||||
Glow.normalizeAccentColor(draft.profile_banner_color) || "#3a3140";
|
||||
|
||||
bannerInput.addEventListener("input", () => {
|
||||
Glow.updateSettingsDraft({
|
||||
profile_banner_color: bannerInput.value
|
||||
});
|
||||
});
|
||||
|
||||
const resetNameColor = document.createElement("button");
|
||||
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();
|
||||
});
|
||||
bannerLabel.append(bannerInput);
|
||||
bannerWrap.append(bannerLabel);
|
||||
|
||||
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);
|
||||
profileVisuals.append(pvTitle, pvText, backgroundWrap, bannerWrap);
|
||||
|
||||
const cssSection = document.createElement("section");
|
||||
cssSection.className = "settings-section";
|
||||
@ -368,17 +633,16 @@ Glow.renderSettingsView = () => {
|
||||
|
||||
const cText = document.createElement("p");
|
||||
cText.textContent =
|
||||
"Optional CSS applied only for your account in this browser session and saved to your settings. \n ex: *,html,body{font-size:95%;text-transform:lowercase;}";
|
||||
"Optional CSS applied locally for preview and saved to your account when supported.";
|
||||
|
||||
const cssInput = document.createElement("textarea");
|
||||
cssInput.className = "field settings-custom-css";
|
||||
cssInput.placeholder = "/* Custom CSS */";
|
||||
cssInput.value = Glow.state.settings?.custom_css || "";
|
||||
|
||||
cssInput.value = draft.custom_css || "";
|
||||
cssInput.addEventListener("input", () => {
|
||||
Glow.applyCustomCss(cssInput.value);
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.custom_css = cssInput.value;
|
||||
Glow.updateSettingsDraft({
|
||||
custom_css: cssInput.value
|
||||
});
|
||||
});
|
||||
|
||||
cssSection.append(cTitle, cText, cssInput);
|
||||
@ -390,7 +654,7 @@ Glow.renderSettingsView = () => {
|
||||
accountTitle.textContent = "Account";
|
||||
|
||||
const accountText = document.createElement("p");
|
||||
accountText.textContent = "Update your public profile and application preferences.";
|
||||
accountText.textContent = "Open your profile editor or reset visual customizations.";
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "settings-actions";
|
||||
@ -403,49 +667,57 @@ Glow.renderSettingsView = () => {
|
||||
Glow.openMyProfile?.(true);
|
||||
});
|
||||
|
||||
const saveSettings = document.createElement("button");
|
||||
saveSettings.className = "button";
|
||||
saveSettings.type = "button";
|
||||
saveSettings.textContent = "Save settings";
|
||||
saveSettings.addEventListener("click", async () => {
|
||||
try {
|
||||
const payload = {
|
||||
accent_color:
|
||||
Glow.normalizeAccentColor(
|
||||
Glow.state.settings?.accent_color || Glow.state.accentColor
|
||||
) || Glow.DEFAULT_ACCENT_COLOR,
|
||||
name_color:
|
||||
Glow.normalizeNameColor(Glow.state.settings?.name_color) || null,
|
||||
custom_css: Glow.state.settings?.custom_css || "",
|
||||
theme_mode:
|
||||
Glow.state.settings?.theme_mode ||
|
||||
localStorage.getItem("glow_theme_mode") ||
|
||||
"dark"
|
||||
};
|
||||
|
||||
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");
|
||||
resetAccent.className = "button secondary";
|
||||
resetAccent.type = "button";
|
||||
resetAccent.textContent = "Reset accent";
|
||||
resetAccent.addEventListener("click", () => {
|
||||
Glow.resetAccentColor();
|
||||
Glow.state.settings ||= {};
|
||||
Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR;
|
||||
Glow.updateSettingsDraft({
|
||||
accent_color: Glow.DEFAULT_ACCENT_COLOR
|
||||
});
|
||||
Glow.renderSettingsView();
|
||||
});
|
||||
|
||||
actions.append(editProfile, saveSettings, resetAccent);
|
||||
const discard = document.createElement("button");
|
||||
discard.className = "button secondary";
|
||||
discard.type = "button";
|
||||
discard.textContent = "Discard changes";
|
||||
discard.addEventListener("click", () => {
|
||||
Glow.discardSettingsDraft();
|
||||
});
|
||||
|
||||
actions.append(editProfile, resetAccent, discard);
|
||||
accountSection.append(accountTitle, accountText, actions);
|
||||
|
||||
card.append(appearance, cssSection, accountSection);
|
||||
card.append(identity, appearance, profileVisuals, cssSection, accountSection);
|
||||
box.append(card);
|
||||
Glow.syncSettingsSavePanel();
|
||||
};
|
||||
|
||||
Glow.els.settingsSaveButton?.addEventListener("click", async () => {
|
||||
try {
|
||||
await Glow.saveSettingsDraft();
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not save settings.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
Glow.els.settingsCloseButton?.addEventListener("click", async () => {
|
||||
if (!Glow.isSettingsDirty?.()) {
|
||||
await Glow.openFeed?.();
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await Glow.openDialog({
|
||||
title: "Leave settings?",
|
||||
body: "You have unsaved changes.",
|
||||
confirmText: "Discard changes",
|
||||
cancelText: "Stay",
|
||||
danger: true
|
||||
});
|
||||
|
||||
if (!values) return;
|
||||
|
||||
Glow.discardSettingsDraft();
|
||||
await Glow.openFeed?.();
|
||||
});
|
||||
@ -1,6 +1,5 @@
|
||||
// static/js/09-profile.js
|
||||
"use strict";
|
||||
|
||||
window.Glow = window.Glow || {};
|
||||
|
||||
Glow.escapeHtml = value =>
|
||||
@ -13,29 +12,153 @@ Glow.escapeHtml = value =>
|
||||
Glow.renderProfileBody = profile => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "profile-rendered";
|
||||
|
||||
const content = String(profile?.bio_html || "").trim();
|
||||
|
||||
if (!content) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "profile-empty";
|
||||
empty.textContent = "No profile bio yet.";
|
||||
return empty;
|
||||
}
|
||||
|
||||
wrap.innerHTML = content;
|
||||
return wrap;
|
||||
};
|
||||
|
||||
Glow.profileCanShowPresenceBadge = profile => {
|
||||
if (!profile) return false;
|
||||
if (profile.is_self) return true;
|
||||
if (typeof profile.show_presence_badge === "boolean") {
|
||||
return profile.show_presence_badge;
|
||||
}
|
||||
return Glow.state.profilePresenceBadgeVisible !== false;
|
||||
};
|
||||
|
||||
Glow.profileWebsiteMeta = website => {
|
||||
return Glow.websiteBrandMeta?.(website) || {
|
||||
host: "",
|
||||
brand: "web",
|
||||
label: "website",
|
||||
iconText: "â—‹"
|
||||
};
|
||||
};
|
||||
|
||||
Glow.profileOpenMediaPicker = kind => {
|
||||
if (kind === "avatar") {
|
||||
Glow.els.avatarUploadInput?.click();
|
||||
return;
|
||||
}
|
||||
if (kind === "banner") {
|
||||
Glow.els.bannerUploadInput?.click();
|
||||
}
|
||||
};
|
||||
|
||||
Glow.profileResetCropState = () => {
|
||||
const crop = Glow.state.imageCrop;
|
||||
if (crop.previewUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(crop.previewUrl);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Glow.state.imageCrop = {
|
||||
open: false,
|
||||
kind: "",
|
||||
file: null,
|
||||
previewUrl: "",
|
||||
zoom: 1,
|
||||
autoCrop: false
|
||||
};
|
||||
|
||||
if (Glow.els.imageCropOverlay) {
|
||||
Glow.els.imageCropOverlay.classList.add("hidden");
|
||||
}
|
||||
if (Glow.els.imageCropPreview) {
|
||||
Glow.els.imageCropPreview.replaceChildren();
|
||||
}
|
||||
if (Glow.els.imageCropZoom) {
|
||||
Glow.els.imageCropZoom.value = "1";
|
||||
}
|
||||
if (Glow.els.imageCropAuto) {
|
||||
Glow.els.imageCropAuto.checked = false;
|
||||
}
|
||||
};
|
||||
|
||||
Glow.profileOpenCropDialog = (kind, file) => {
|
||||
if (!file || !kind) return;
|
||||
|
||||
Glow.profileResetCropState();
|
||||
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
Glow.state.imageCrop = {
|
||||
open: true,
|
||||
kind,
|
||||
file,
|
||||
previewUrl,
|
||||
zoom: 1,
|
||||
autoCrop: false
|
||||
};
|
||||
|
||||
if (Glow.els.imageCropPreview) {
|
||||
const image = document.createElement("img");
|
||||
image.src = previewUrl;
|
||||
image.alt = `${kind} preview`;
|
||||
image.className = "crop-preview-image";
|
||||
Glow.els.imageCropPreview.replaceChildren(image);
|
||||
}
|
||||
|
||||
Glow.els.imageCropOverlay?.classList.remove("hidden");
|
||||
};
|
||||
|
||||
Glow.profileApplyCropSelection = () => {
|
||||
const crop = Glow.state.imageCrop;
|
||||
if (!crop?.file || !crop?.kind) {
|
||||
Glow.profileResetCropState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (crop.kind === "avatar") {
|
||||
Glow.state.profileMediaDraft.avatarFile = crop.file;
|
||||
} else if (crop.kind === "banner") {
|
||||
Glow.state.profileMediaDraft.bannerFile = crop.file;
|
||||
}
|
||||
|
||||
Glow.showToast(
|
||||
crop.kind === "avatar"
|
||||
? "Avatar image selected. Save profile changes to keep it."
|
||||
: "Banner image selected. Save profile changes to keep it."
|
||||
);
|
||||
|
||||
Glow.profileResetCropState();
|
||||
};
|
||||
|
||||
Glow.profileBuildWebsiteBadge = website => {
|
||||
if (!website) return null;
|
||||
|
||||
const meta = Glow.profileWebsiteMeta(website);
|
||||
const link = document.createElement("a");
|
||||
link.className = `profile-website-badge brand-${meta.brand}`;
|
||||
link.href = website;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "profile-website-badge-icon";
|
||||
icon.textContent = meta.iconText;
|
||||
|
||||
const text = document.createElement("span");
|
||||
text.className = "profile-website-badge-text";
|
||||
text.textContent = meta.label;
|
||||
|
||||
link.append(icon, text);
|
||||
return link;
|
||||
};
|
||||
|
||||
Glow.openProfile = async username => {
|
||||
Glow.state.activeView = "profile";
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.updateViewUI();
|
||||
|
||||
const box = Glow.els.profileBox;
|
||||
box.setAttribute("aria-busy", "true");
|
||||
box.replaceChildren();
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetPublicProfile(username);
|
||||
Glow.state.activeProfile = data.profile;
|
||||
@ -44,14 +167,11 @@ Glow.openProfile = async username => {
|
||||
box.replaceChildren();
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = error.message || "Could not load profile.";
|
||||
|
||||
state.append(card);
|
||||
box.append(state);
|
||||
|
||||
Glow.showToast(error.message || "Could not load profile.", "error");
|
||||
} finally {
|
||||
box.setAttribute("aria-busy", "false");
|
||||
@ -63,15 +183,12 @@ Glow.openMyProfile = async (edit = false) => {
|
||||
Glow.showToast("Sign in to view your profile.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Glow.state.activeView = "profile";
|
||||
Glow.state.activeProfile = null;
|
||||
Glow.updateViewUI();
|
||||
|
||||
const box = Glow.els.profileBox;
|
||||
box.setAttribute("aria-busy", "true");
|
||||
box.replaceChildren();
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetMyProfile();
|
||||
Glow.state.activeProfile = data.profile;
|
||||
@ -83,30 +200,61 @@ Glow.openMyProfile = async (edit = false) => {
|
||||
}
|
||||
};
|
||||
|
||||
Glow.uploadBannerForProfile = async file => {
|
||||
const result = await Glow.uploadAttachment(file);
|
||||
return result?.attachment || null;
|
||||
};
|
||||
|
||||
Glow.renderProfileView = (editing = false) => {
|
||||
const box = Glow.els.profileBox;
|
||||
const profile = Glow.state.activeProfile;
|
||||
|
||||
box.replaceChildren();
|
||||
|
||||
if (!profile) {
|
||||
const state = document.createElement("div");
|
||||
state.className = "empty-state";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "empty-card";
|
||||
card.textContent = "Profile not found.";
|
||||
|
||||
state.append(card);
|
||||
box.append(state);
|
||||
return;
|
||||
}
|
||||
|
||||
box.style.background =
|
||||
profile.profile_background_color || "transparent";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "profile-card";
|
||||
|
||||
const banner = document.createElement("div");
|
||||
const banner = document.createElement(profile.is_self ? "button" : "div");
|
||||
banner.className = "profile-banner";
|
||||
if (profile.is_self) {
|
||||
banner.type = "button";
|
||||
banner.classList.add("profile-banner-button");
|
||||
banner.title = "Choose banner image";
|
||||
banner.addEventListener("click", () => {
|
||||
Glow.profileOpenMediaPicker("banner");
|
||||
});
|
||||
}
|
||||
|
||||
if (profile.profile_banner_color) {
|
||||
banner.style.background =
|
||||
`linear-gradient(135deg, ${profile.profile_banner_color}, transparent 72%), ${profile.profile_banner_color}`;
|
||||
}
|
||||
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");
|
||||
content.className = "profile-content";
|
||||
@ -114,15 +262,23 @@ Glow.renderProfileView = (editing = false) => {
|
||||
const header = document.createElement("div");
|
||||
header.className = "profile-header";
|
||||
|
||||
const avatar = document.createElement("div");
|
||||
const avatar = document.createElement(profile.is_self ? "button" : "div");
|
||||
avatar.className = "profile-avatar";
|
||||
avatar.textContent = (profile.display_name || profile.username || "?")
|
||||
.slice(0, 1)
|
||||
.toUpperCase();
|
||||
|
||||
if (profile.is_self) {
|
||||
avatar.type = "button";
|
||||
avatar.classList.add("profile-avatar-button");
|
||||
avatar.title = "Choose avatar image";
|
||||
avatar.addEventListener("click", () => {
|
||||
Glow.profileOpenMediaPicker("avatar");
|
||||
});
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "profile-actions";
|
||||
|
||||
if (profile.is_self) {
|
||||
const editButton = document.createElement("button");
|
||||
editButton.className = "button";
|
||||
@ -139,10 +295,8 @@ Glow.renderProfileView = (editing = false) => {
|
||||
settingsButton.addEventListener("click", () => {
|
||||
Glow.openSettings?.();
|
||||
});
|
||||
|
||||
actions.append(editButton, settingsButton);
|
||||
}
|
||||
|
||||
header.append(avatar, actions);
|
||||
|
||||
const name = document.createElement("div");
|
||||
@ -165,15 +319,23 @@ Glow.renderProfileView = (editing = false) => {
|
||||
badges.className = "profile-badges";
|
||||
|
||||
const roleBadge = document.createElement("span");
|
||||
roleBadge.className = "profile-badge";
|
||||
roleBadge.className = `profile-badge role role-${String(profile.role || "user").toLowerCase()}`;
|
||||
roleBadge.textContent = profile.role;
|
||||
|
||||
const statusBadge = document.createElement("span");
|
||||
statusBadge.className = "profile-badge";
|
||||
statusBadge.className = `profile-badge status status-${String(profile.status || "active").toLowerCase()}`;
|
||||
statusBadge.textContent = profile.status;
|
||||
|
||||
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");
|
||||
grid.className = "profile-grid";
|
||||
|
||||
@ -182,46 +344,67 @@ Glow.renderProfileView = (editing = false) => {
|
||||
} else {
|
||||
const bioSection = document.createElement("section");
|
||||
bioSection.className = "profile-section";
|
||||
|
||||
const bioTitle = document.createElement("h3");
|
||||
bioTitle.textContent = "Bio";
|
||||
|
||||
bioSection.append(bioTitle, Glow.renderProfileBody(profile));
|
||||
|
||||
const metaSection = document.createElement("section");
|
||||
metaSection.className = "profile-section";
|
||||
|
||||
const metaTitle = document.createElement("h3");
|
||||
metaTitle.textContent = "Profile";
|
||||
|
||||
const metaList = document.createElement("dl");
|
||||
metaList.className = "profile-meta-list";
|
||||
|
||||
const addMeta = (label, value) => {
|
||||
if (!value) return;
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value || "—";
|
||||
|
||||
dd.textContent = value;
|
||||
metaList.append(dt, dd);
|
||||
};
|
||||
|
||||
addMeta("Display name", profile.display_name || profile.username);
|
||||
addMeta("Website", profile.website || "");
|
||||
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");
|
||||
addMeta("Joined", profile.created_at
|
||||
? new Date(profile.created_at).toLocaleDateString()
|
||||
: "");
|
||||
|
||||
addMeta("Location", profile.location || "");
|
||||
|
||||
if (profile.last_active_at) {
|
||||
addMeta("Last active", new Date(profile.last_active_at).toLocaleString());
|
||||
}
|
||||
|
||||
const websiteBadge = Glow.profileBuildWebsiteBadge(profile.website || "");
|
||||
if (websiteBadge) {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = "Website";
|
||||
const dd = document.createElement("dd");
|
||||
dd.append(websiteBadge);
|
||||
metaList.append(dt, dd);
|
||||
}
|
||||
|
||||
if (!metaList.children.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "profile-empty";
|
||||
empty.textContent = "No public profile details yet.";
|
||||
metaSection.append(metaTitle, empty);
|
||||
} else {
|
||||
metaSection.append(metaTitle, metaList);
|
||||
}
|
||||
|
||||
metaSection.append(metaTitle, metaList);
|
||||
grid.append(bioSection, metaSection);
|
||||
}
|
||||
|
||||
content.append(header, name, handle, badges, grid);
|
||||
content.append(header, name, handle);
|
||||
|
||||
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);
|
||||
box.append(card);
|
||||
};
|
||||
@ -229,13 +412,46 @@ Glow.renderProfileView = (editing = false) => {
|
||||
Glow.buildProfileEditor = profile => {
|
||||
const section = document.createElement("section");
|
||||
section.className = "profile-section";
|
||||
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = "Edit profile";
|
||||
|
||||
const editor = document.createElement("div");
|
||||
editor.className = "profile-editor";
|
||||
|
||||
const uploadInfo = document.createElement("div");
|
||||
uploadInfo.className = "profile-upload-section";
|
||||
|
||||
const uploadTitle = document.createElement("div");
|
||||
uploadTitle.className = "profile-upload-title";
|
||||
uploadTitle.textContent = "Profile images";
|
||||
|
||||
const uploadText = document.createElement("p");
|
||||
uploadText.className = "header-subtitle";
|
||||
uploadText.textContent =
|
||||
"Choose new avatar or banner images. A preview/crop step opens before the file is staged.";
|
||||
|
||||
const uploadActions = document.createElement("div");
|
||||
uploadActions.className = "settings-actions";
|
||||
|
||||
const avatarButton = document.createElement("button");
|
||||
avatarButton.className = "button secondary";
|
||||
avatarButton.type = "button";
|
||||
avatarButton.textContent = "Choose avatar";
|
||||
avatarButton.addEventListener("click", () => {
|
||||
Glow.profileOpenMediaPicker("avatar");
|
||||
});
|
||||
|
||||
const bannerButton = document.createElement("button");
|
||||
bannerButton.className = "button secondary";
|
||||
bannerButton.type = "button";
|
||||
bannerButton.textContent = "Choose banner";
|
||||
bannerButton.addEventListener("click", () => {
|
||||
Glow.profileOpenMediaPicker("banner");
|
||||
});
|
||||
|
||||
uploadActions.append(avatarButton, bannerButton);
|
||||
uploadInfo.append(uploadTitle, uploadText, uploadActions);
|
||||
|
||||
const displayName = document.createElement("input");
|
||||
displayName.className = "field";
|
||||
displayName.type = "text";
|
||||
@ -243,6 +459,13 @@ Glow.buildProfileEditor = profile => {
|
||||
displayName.placeholder = "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");
|
||||
website.className = "field";
|
||||
website.type = "url";
|
||||
@ -259,7 +482,6 @@ Glow.buildProfileEditor = profile => {
|
||||
|
||||
const modeRow = document.createElement("div");
|
||||
modeRow.className = "profile-mode-row";
|
||||
|
||||
let selectedMode = profile.bio_mode || "txt";
|
||||
|
||||
const body = document.createElement("textarea");
|
||||
@ -273,14 +495,12 @@ Glow.buildProfileEditor = profile => {
|
||||
button.className = `mode-pill${selectedMode === mode ? " active" : ""}`;
|
||||
button.type = "button";
|
||||
button.textContent = mode.toUpperCase();
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
selectedMode = mode;
|
||||
for (const el of modeRow.querySelectorAll(".mode-pill")) {
|
||||
el.classList.toggle("active", el.textContent.toLowerCase() === mode);
|
||||
}
|
||||
});
|
||||
|
||||
modeRow.append(button);
|
||||
}
|
||||
|
||||
@ -295,20 +515,34 @@ Glow.buildProfileEditor = profile => {
|
||||
const save = document.createElement("button");
|
||||
save.className = "button";
|
||||
save.type = "button";
|
||||
save.textContent = "Save profile";
|
||||
save.textContent = "Save profile changes";
|
||||
save.addEventListener("click", async () => {
|
||||
try {
|
||||
let bannerAttachmentId = profile.banner_attachment_id || null;
|
||||
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
|
||||
if (bannerFile) {
|
||||
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
|
||||
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
|
||||
}
|
||||
|
||||
const data = await Glow.apiUpdateMyProfile({
|
||||
display_name: displayName.value,
|
||||
status_message: statusMessage.value,
|
||||
website: website.value,
|
||||
location: location.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 = {
|
||||
...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.renderProfileView(false);
|
||||
@ -317,33 +551,83 @@ Glow.buildProfileEditor = profile => {
|
||||
}
|
||||
});
|
||||
|
||||
const preview = document.createElement("button");
|
||||
preview.className = "button secondary";
|
||||
preview.type = "button";
|
||||
preview.textContent = "Preview";
|
||||
preview.addEventListener("click", async () => {
|
||||
const apply = document.createElement("button");
|
||||
apply.className = "button secondary";
|
||||
apply.type = "button";
|
||||
apply.textContent = "Apply changes";
|
||||
apply.addEventListener("click", async () => {
|
||||
try {
|
||||
let bannerAttachmentId = profile.banner_attachment_id || null;
|
||||
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
|
||||
if (bannerFile) {
|
||||
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
|
||||
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
|
||||
}
|
||||
|
||||
const data = await Glow.apiUpdateMyProfile({
|
||||
display_name: displayName.value,
|
||||
status_message: statusMessage.value,
|
||||
website: website.value,
|
||||
location: location.value,
|
||||
bio: body.value,
|
||||
bio_mode: selectedMode
|
||||
bio_mode: selectedMode,
|
||||
banner_attachment_id: bannerAttachmentId
|
||||
});
|
||||
|
||||
Glow.state.activeProfile = {
|
||||
...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);
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not preview profile.", "error");
|
||||
Glow.showToast(error.message || "Could not apply profile changes.", "error");
|
||||
}
|
||||
});
|
||||
|
||||
actions.append(save, preview);
|
||||
editor.append(displayName, website, location, modeRow, help, body, actions);
|
||||
actions.append(save, apply);
|
||||
editor.append(
|
||||
uploadInfo,
|
||||
displayName,
|
||||
statusMessage,
|
||||
website,
|
||||
location,
|
||||
modeRow,
|
||||
help,
|
||||
body,
|
||||
actions
|
||||
);
|
||||
section.append(title, editor);
|
||||
|
||||
return section;
|
||||
};
|
||||
|
||||
Glow.els.avatarUploadInput?.addEventListener("change", () => {
|
||||
const file = Glow.els.avatarUploadInput.files?.[0];
|
||||
if (!file) return;
|
||||
Glow.profileOpenCropDialog("avatar", file);
|
||||
Glow.els.avatarUploadInput.value = "";
|
||||
});
|
||||
|
||||
Glow.els.bannerUploadInput?.addEventListener("change", () => {
|
||||
const file = Glow.els.bannerUploadInput.files?.[0];
|
||||
if (!file) return;
|
||||
Glow.profileOpenCropDialog("banner", file);
|
||||
Glow.els.bannerUploadInput.value = "";
|
||||
});
|
||||
|
||||
Glow.els.imageCropCancel?.addEventListener("click", () => {
|
||||
Glow.profileResetCropState();
|
||||
});
|
||||
|
||||
Glow.els.imageCropApply?.addEventListener("click", () => {
|
||||
Glow.profileApplyCropSelection();
|
||||
});
|
||||
|
||||
Glow.els.imageCropZoom?.addEventListener("input", () => {
|
||||
Glow.state.imageCrop.zoom = Number(Glow.els.imageCropZoom.value || "1") || 1;
|
||||
});
|
||||
|
||||
Glow.els.imageCropAuto?.addEventListener("change", () => {
|
||||
Glow.state.imageCrop.autoCrop = Boolean(Glow.els.imageCropAuto.checked);
|
||||
});
|
||||
@ -5,8 +5,13 @@ window.Glow = window.Glow || {};
|
||||
|
||||
Glow.reactions = Glow.reactions || {
|
||||
enabled: true,
|
||||
emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"],
|
||||
activeMessage: null
|
||||
emojis: [
|
||||
"👍", "👎", "❤️", "🔥", "😂", "😄", "🙂", "😭",
|
||||
"👏", "🙌", "🎉", "✨", "💯", "✅", "👀", "🤔",
|
||||
"🙏", "💡", "⭐", "🚀", "😮", "😢", "😡", "👋"
|
||||
],
|
||||
activeMessage: null,
|
||||
selectedIndex: 0
|
||||
};
|
||||
|
||||
Glow.submitCustomReaction = async () => {
|
||||
@ -41,8 +46,47 @@ Glow.submitCustomReaction = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
Glow.highlightReactionSelection = () => {
|
||||
const grid = Glow.els.reactionPopupGrid;
|
||||
if (!grid) return;
|
||||
|
||||
const buttons = [...grid.querySelectorAll(".reaction-popup-button")];
|
||||
buttons.forEach((button, index) => {
|
||||
button.classList.toggle("active", index === Glow.reactions.selectedIndex);
|
||||
});
|
||||
};
|
||||
|
||||
Glow.activateReactionAtIndex = async index => {
|
||||
const message = Glow.reactions.activeMessage;
|
||||
const emoji = Glow.reactions.emojis[index];
|
||||
if (!message || !emoji) return;
|
||||
|
||||
try {
|
||||
const existing = (message.reactions || []).find(item => item.emoji === emoji);
|
||||
|
||||
if (existing?.reacted) {
|
||||
await Glow.apiRemoveReaction(message.id, emoji);
|
||||
} else {
|
||||
await Glow.apiAddReaction(message.id, emoji);
|
||||
}
|
||||
|
||||
const data = await Glow.apiGetMessageReactions(message.id);
|
||||
message.reactions = data.reactions || [];
|
||||
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
|
||||
Glow.renderReactionPopupUsers(message.reactions);
|
||||
Glow.highlightReactionSelection();
|
||||
|
||||
if (Glow.state.activeView === "feed") {
|
||||
Glow.loadFeed?.();
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update reaction.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
Glow.openReactionPopup = async message => {
|
||||
Glow.reactions.activeMessage = message;
|
||||
Glow.reactions.selectedIndex = 0;
|
||||
|
||||
const grid = Glow.els.reactionPopupGrid;
|
||||
const list = Glow.els.reactionPopupList;
|
||||
@ -53,38 +97,21 @@ Glow.openReactionPopup = async message => {
|
||||
grid.replaceChildren();
|
||||
list.replaceChildren();
|
||||
|
||||
for (const emoji of Glow.reactions.emojis) {
|
||||
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 () => {
|
||||
try {
|
||||
const existing = (message.reactions || []).find(item => item.emoji === emoji);
|
||||
|
||||
if (existing?.reacted) {
|
||||
await Glow.apiRemoveReaction(message.id, emoji);
|
||||
} else {
|
||||
await Glow.apiAddReaction(message.id, emoji);
|
||||
}
|
||||
|
||||
const data = await Glow.apiGetMessageReactions(message.id);
|
||||
message.reactions = data.reactions || [];
|
||||
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
|
||||
|
||||
Glow.renderReactionPopupUsers(message.reactions);
|
||||
|
||||
if (Glow.state.activeView === "feed") {
|
||||
Glow.loadFeed?.();
|
||||
}
|
||||
} catch (error) {
|
||||
Glow.showToast(error.message || "Could not update reaction.", "error");
|
||||
}
|
||||
Glow.reactions.selectedIndex = index;
|
||||
Glow.highlightReactionSelection();
|
||||
await Glow.activateReactionAtIndex(index);
|
||||
});
|
||||
|
||||
grid.append(button);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await Glow.apiGetMessageReactions(message.id);
|
||||
@ -99,12 +126,26 @@ Glow.openReactionPopup = async message => {
|
||||
|
||||
Glow.renderReactionPopupUsers(message.reactions || []);
|
||||
Glow.els.reactionPopupOverlay?.classList.remove("hidden");
|
||||
Glow.highlightReactionSelection();
|
||||
|
||||
setTimeout(() => {
|
||||
Glow.els.reactionCustomInput?.focus();
|
||||
const first = grid.querySelector(".reaction-popup-button");
|
||||
first?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
Glow.moveReactionSelection = delta => {
|
||||
const total = Glow.reactions.emojis.length;
|
||||
if (!total) return;
|
||||
Glow.reactions.selectedIndex =
|
||||
(Glow.reactions.selectedIndex + delta + total) % total;
|
||||
Glow.highlightReactionSelection();
|
||||
|
||||
const grid = Glow.els.reactionPopupGrid;
|
||||
const selected = grid?.querySelectorAll(".reaction-popup-button")[Glow.reactions.selectedIndex];
|
||||
selected?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
};
|
||||
|
||||
Glow.renderReactionPopupUsers = reactions => {
|
||||
const list = Glow.els.reactionPopupList;
|
||||
if (!list) return;
|
||||
@ -128,19 +169,12 @@ Glow.renderReactionPopupUsers = reactions => {
|
||||
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
|
||||
section.append(title);
|
||||
|
||||
for (const user of reaction.users || []) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "reaction-popup-user";
|
||||
|
||||
const left = document.createElement("span");
|
||||
left.textContent = user.username;
|
||||
|
||||
const right = document.createElement("span");
|
||||
right.textContent = new Date(user.created_at).toLocaleString();
|
||||
|
||||
row.append(left, right);
|
||||
section.append(row);
|
||||
}
|
||||
const names = document.createElement("div");
|
||||
names.className = "reaction-popup-user";
|
||||
names.textContent = (reaction.users || [])
|
||||
.map(user => user.username)
|
||||
.join(", ");
|
||||
section.append(names);
|
||||
|
||||
list.append(section);
|
||||
}
|
||||
@ -148,6 +182,7 @@ Glow.renderReactionPopupUsers = reactions => {
|
||||
|
||||
Glow.closeReactionPopup = () => {
|
||||
Glow.reactions.activeMessage = null;
|
||||
Glow.reactions.selectedIndex = 0;
|
||||
if (Glow.els.reactionCustomInput) {
|
||||
Glow.els.reactionCustomInput.value = "";
|
||||
}
|
||||
@ -163,4 +198,49 @@ Glow.els.reactionCustomInput?.addEventListener("keydown", event => {
|
||||
event.preventDefault();
|
||||
Glow.submitCustomReaction?.();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
Glow.closeReactionPopup?.();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", async event => {
|
||||
if (Glow.els.reactionPopupOverlay?.classList.contains("hidden")) return;
|
||||
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
Glow.moveReactionSelection(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
Glow.moveReactionSelection(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
Glow.moveReactionSelection(4);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
Glow.moveReactionSelection(-4);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const active = document.activeElement;
|
||||
if (active === Glow.els.reactionCustomInput) return;
|
||||
event.preventDefault();
|
||||
await Glow.activateReactionAtIndex(Glow.reactions.selectedIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
Glow.closeReactionPopup?.();
|
||||
}
|
||||
});
|
||||
@ -34,15 +34,17 @@ Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => {
|
||||
Glow.resetMediaModal();
|
||||
|
||||
const mime = String(mimeType || "").toLowerCase();
|
||||
const compactName = String(name || "Media").trim() || "Media";
|
||||
|
||||
Glow.els.mediaTitle.textContent = name;
|
||||
Glow.els.mediaTitle.textContent = compactName;
|
||||
Glow.els.mediaTitle.title = compactName;
|
||||
Glow.els.mediaDownloadLink.href = url;
|
||||
Glow.els.mediaDownloadLink.download = name;
|
||||
Glow.els.mediaDownloadLink.download = compactName;
|
||||
Glow.els.mediaFallbackLink.href = url;
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
Glow.els.mediaImage.src = url;
|
||||
Glow.els.mediaImage.alt = name;
|
||||
Glow.els.mediaImage.alt = compactName;
|
||||
Glow.els.mediaImage.classList.remove("hidden");
|
||||
} else if (mime.startsWith("audio/")) {
|
||||
Glow.els.mediaAudio.src = url;
|
||||
@ -62,4 +64,4 @@ Glow.closeMediaModal = () => {
|
||||
|
||||
Glow.els.mediaOverlay.classList.add("hidden");
|
||||
Glow.resetMediaModal();
|
||||
};
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
784
static/style.css
784
static/style.css
File diff suppressed because it is too large
Load Diff
17
templates/blocked.html
Normal file
17
templates/blocked.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
||||
>
|
||||
<meta name="theme-color" content="#0f1012">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="referrer" content="same-origin">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
403
|
||||
</body>
|
||||
</html>
|
||||
@ -33,86 +33,102 @@
|
||||
</div>
|
||||
|
||||
<div class="header-navigation">
|
||||
<button
|
||||
id="feedButton"
|
||||
class="header-action feed-button active"
|
||||
type="button"
|
||||
aria-label="Open activity feed"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="header-nav-group">
|
||||
<button
|
||||
id="feedButton"
|
||||
class="header-action feed-button active"
|
||||
type="button"
|
||||
aria-label="Open activity feed"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="pagesButton"
|
||||
class="header-action pages-button"
|
||||
type="button"
|
||||
aria-label="Open team pages"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<g fill="none">
|
||||
<path d="M3.5 2.5h17v19h-17z"/>
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M3.5 2.5h17v19h-17zM8 8h8m-8 4h8m-8 4h5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="pagesButton"
|
||||
class="header-action pages-button"
|
||||
type="button"
|
||||
aria-label="Open team pages"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<g fill="none">
|
||||
<path d="M3.5 2.5h17v19h-17z"/>
|
||||
<path stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M3.5 2.5h17v19h-17zM8 8h8m-8 4h8m-8 4h5"/>
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="dmsButton"
|
||||
class="header-action"
|
||||
type="button"
|
||||
aria-label="Open direct messages"
|
||||
aria-pressed="false"
|
||||
title="Direct messages"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="dmsButton"
|
||||
class="header-action"
|
||||
type="button"
|
||||
aria-label="Open direct messages"
|
||||
aria-pressed="false"
|
||||
title="Direct messages"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="backButton"
|
||||
class="header-action hidden"
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
id="backButton"
|
||||
class="header-action hidden"
|
||||
type="button"
|
||||
aria-label="Go back"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
id="teamSelect"
|
||||
class="room-select team-select"
|
||||
aria-label="Select team"
|
||||
disabled
|
||||
>
|
||||
<option value="">Loading teams…</option>
|
||||
</select>
|
||||
<div class="header-context-group">
|
||||
<select
|
||||
id="teamSelect"
|
||||
class="room-select team-select"
|
||||
aria-label="Select team"
|
||||
disabled
|
||||
>
|
||||
<option value="">Loading teams...</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
id="roomSelect"
|
||||
class="room-select channel-select"
|
||||
aria-label="Select channel"
|
||||
disabled
|
||||
>
|
||||
<option value="">Loading channels…</option>
|
||||
</select>
|
||||
<select
|
||||
id="roomSelect"
|
||||
class="room-select channel-select"
|
||||
aria-label="Select channel"
|
||||
disabled
|
||||
>
|
||||
<option value="">Loading channels...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
|
||||
<div class="header-utility-group">
|
||||
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
|
||||
|
||||
<button
|
||||
id="settingsButton"
|
||||
class="header-action"
|
||||
type="button"
|
||||
aria-label="Open settings and profile menu"
|
||||
title="Settings and profile"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="settingsButton"
|
||||
class="header-action"
|
||||
type="button"
|
||||
aria-label="Open settings and profile menu"
|
||||
title="Settings and profile"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="callStatusChip"
|
||||
class="header-action call-status-chip"
|
||||
type="button"
|
||||
aria-label="Open call panel"
|
||||
title="Voice call"
|
||||
>
|
||||
Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-account-wrap">
|
||||
@ -141,7 +157,7 @@
|
||||
aria-label="Activity feed"
|
||||
>
|
||||
<div class="empty-state">
|
||||
<div class="empty-card">Loading activity…</div>
|
||||
<div class="empty-card">Loading activity...</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -207,19 +223,39 @@
|
||||
|
||||
<div id="composerWrap" class="composer-wrap hidden">
|
||||
<form id="chatForm" class="composer">
|
||||
<label class="sr-only" for="messageInput">Message</label>
|
||||
<div class="composer-stack">
|
||||
<div class="composer-shell">
|
||||
<label class="sr-only" for="messageInput">Message</label>
|
||||
|
||||
<div class="composer-field-wrap">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
class="composer-input"
|
||||
rows="1"
|
||||
maxlength="1000"
|
||||
autocomplete="off"
|
||||
placeholder="sign in to send"
|
||||
aria-label="Message"
|
||||
disabled
|
||||
></textarea>
|
||||
<textarea
|
||||
id="messageInput"
|
||||
class="composer-input"
|
||||
rows="1"
|
||||
maxlength="1000"
|
||||
autocomplete="off"
|
||||
placeholder="sign in to send"
|
||||
aria-label="Message"
|
||||
disabled
|
||||
></textarea>
|
||||
|
||||
<div class="composer-shell-actions">
|
||||
<label
|
||||
id="attachmentButton"
|
||||
class="attachment-button disabled"
|
||||
for="attachmentInput"
|
||||
title="Attach file(s)"
|
||||
aria-label="Attach file(s)"
|
||||
>
|
||||
+
|
||||
</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>
|
||||
@ -227,16 +263,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
id="attachmentButton"
|
||||
class="attachment-button disabled"
|
||||
for="attachmentInput"
|
||||
title="Attach file(s)"
|
||||
aria-label="Attach file(s)"
|
||||
>
|
||||
+
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="attachmentInput"
|
||||
class="sr-only"
|
||||
@ -245,28 +271,44 @@
|
||||
multiple
|
||||
disabled
|
||||
>
|
||||
|
||||
<button id="sendButton" class="send-button" type="submit" disabled>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="threadComposerWrap" class="composer-wrap hidden">
|
||||
<form id="threadForm" class="composer">
|
||||
<label class="sr-only" for="threadInput">Reply in thread</label>
|
||||
<div class="composer-stack">
|
||||
<div class="composer-shell">
|
||||
<label class="sr-only" for="threadInput">Reply in thread</label>
|
||||
|
||||
<div class="composer-field-wrap">
|
||||
<textarea
|
||||
id="threadInput"
|
||||
class="composer-input"
|
||||
rows="1"
|
||||
maxlength="1000"
|
||||
autocomplete="off"
|
||||
placeholder="reply in thread"
|
||||
aria-label="Reply in thread"
|
||||
disabled
|
||||
></textarea>
|
||||
<textarea
|
||||
id="threadInput"
|
||||
class="composer-input"
|
||||
rows="1"
|
||||
maxlength="1000"
|
||||
autocomplete="off"
|
||||
placeholder="reply in thread"
|
||||
aria-label="Reply in thread"
|
||||
disabled
|
||||
></textarea>
|
||||
|
||||
<div class="composer-shell-actions">
|
||||
<label
|
||||
id="threadAttachmentButton"
|
||||
class="attachment-button disabled"
|
||||
for="threadAttachmentInput"
|
||||
title="Attach file(s)"
|
||||
aria-label="Attach file(s)"
|
||||
>
|
||||
+
|
||||
</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>
|
||||
@ -274,16 +316,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label
|
||||
id="threadAttachmentButton"
|
||||
class="attachment-button disabled"
|
||||
for="threadAttachmentInput"
|
||||
title="Attach file(s)"
|
||||
aria-label="Attach file(s)"
|
||||
>
|
||||
+
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="threadAttachmentInput"
|
||||
class="sr-only"
|
||||
@ -292,14 +324,24 @@
|
||||
multiple
|
||||
disabled
|
||||
>
|
||||
|
||||
<button id="threadSendButton" class="send-button" type="submit" disabled>
|
||||
Reply
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
id="avatarUploadInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp"
|
||||
>
|
||||
|
||||
<input
|
||||
id="bannerUploadInput"
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp"
|
||||
>
|
||||
|
||||
<section
|
||||
id="authOverlay"
|
||||
class="auth-overlay"
|
||||
@ -321,7 +363,7 @@
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
maxlength="256"
|
||||
placeholder="private access key"
|
||||
placeholder="access key"
|
||||
>
|
||||
|
||||
<label class="sr-only" for="usernameInput">Username</label>
|
||||
@ -364,7 +406,7 @@
|
||||
>
|
||||
<div class="auth-card">
|
||||
<h1>Access blocked</h1>
|
||||
<p id="blockedMessage">Access to this chat service is unavailable.</p>
|
||||
<p id="blockedMessage">Access is unavailable.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -389,7 +431,10 @@
|
||||
aria-label="Reactions"
|
||||
>
|
||||
<div class="reaction-popup-card">
|
||||
<h2 class="reaction-popup-title">Reactions</h2>
|
||||
<div class="reaction-popup-header">
|
||||
<h2 class="reaction-popup-title">Reactions</h2>
|
||||
</div>
|
||||
|
||||
<div id="reactionPopupGrid" class="reaction-popup-grid"></div>
|
||||
|
||||
<div class="reaction-popup-custom">
|
||||
@ -426,14 +471,15 @@
|
||||
aria-label="Media preview"
|
||||
>
|
||||
<div class="media-card">
|
||||
<div class="media-header">
|
||||
<div class="media-header minimal">
|
||||
<div id="mediaTitle" class="media-title">Media</div>
|
||||
<div class="settings-actions">
|
||||
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
|
||||
<button id="closeMediaButton" class="button secondary" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-body">
|
||||
|
||||
<div class="media-body spacious">
|
||||
<img id="mediaImage" class="hidden" alt="">
|
||||
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
|
||||
<video id="mediaVideo" class="hidden" controls preload="metadata"></video>
|
||||
@ -445,17 +491,58 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="imageCropOverlay"
|
||||
class="dialog-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Image crop and preview"
|
||||
>
|
||||
<div class="dialog-card crop-dialog-card">
|
||||
<h2>Adjust image</h2>
|
||||
|
||||
<div class="crop-dialog-body">
|
||||
<div id="imageCropPreview" class="crop-preview-area" aria-live="polite"></div>
|
||||
|
||||
<label class="dialog-label" for="imageCropZoom">
|
||||
Zoom
|
||||
<input
|
||||
id="imageCropZoom"
|
||||
class="field"
|
||||
type="range"
|
||||
min="1"
|
||||
max="3"
|
||||
step="0.05"
|
||||
value="1"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="dialog-label crop-toggle">
|
||||
<input id="imageCropAuto" type="checkbox">
|
||||
Auto-crop placeholder
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="dialog-actions">
|
||||
<button id="imageCropCancel" class="button secondary" type="button">Cancel</button>
|
||||
<button id="imageCropApply" class="button" type="button">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="callOverlay"
|
||||
class="call-overlay hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-modal="false"
|
||||
aria-label="Voice call"
|
||||
>
|
||||
<div class="call-card">
|
||||
<div class="call-card utility-panel">
|
||||
<div id="callDragHandle" class="call-drag-handle" aria-hidden="true"></div>
|
||||
|
||||
<div class="call-title-row">
|
||||
<div>
|
||||
<div class="call-title">Voice call</div>
|
||||
<div class="call-title">call</div>
|
||||
<div id="callStatus" class="call-status">Idle</div>
|
||||
</div>
|
||||
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
|
||||
@ -463,13 +550,40 @@
|
||||
|
||||
<div id="callPeerLabel" class="call-peer-label">No active call</div>
|
||||
|
||||
<audio id="remoteAudio" autoplay playsinline></audio>
|
||||
<div id="callSummaryStats" class="call-summary-stats">
|
||||
<div class="call-stat-row">
|
||||
<span class="call-stat-label">Users</span>
|
||||
<span id="callUserCountStat" class="call-stat-value">0</span>
|
||||
</div>
|
||||
<div class="call-stat-row">
|
||||
<span class="call-stat-label">Elapsed</span>
|
||||
<span id="callElapsedStat" class="call-stat-value">0m</span>
|
||||
</div>
|
||||
<div class="call-stat-row">
|
||||
<span class="call-stat-label">Latency</span>
|
||||
<span id="callLatencyStat" class="call-stat-value"> </span>
|
||||
</div>
|
||||
<div class="call-stat-row">
|
||||
<span class="call-stat-label">Loss</span>
|
||||
<span id="callPacketLossStat" class="call-stat-value"> </span>
|
||||
</div>
|
||||
<div class="call-stat-row">
|
||||
<span class="call-stat-label">Uptime</span>
|
||||
<span id="callUptimeStat" class="call-stat-value"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="callParticipants" class="call-participants"></div>
|
||||
<div id="callPendingInvites" class="call-pending-invites hidden"></div>
|
||||
<div id="remoteAudioContainer" class="remote-audio-container"></div>
|
||||
|
||||
<div class="call-actions">
|
||||
<button id="startCallButton" class="button" type="button">Start call</button>
|
||||
<button id="answerCallButton" class="button hidden" type="button">Answer</button>
|
||||
<button id="retryCallButton" class="button secondary hidden" type="button">Retry connection</button>
|
||||
<button id="addToCallButton" class="button secondary hidden" type="button">Add user</button>
|
||||
<button id="muteCallButton" class="button secondary hidden" type="button">Mute</button>
|
||||
<button id="hangupCallButton" class="danger-button hidden" type="button">Hang up</button>
|
||||
<button id="hangupCallButton" class="danger-button hidden" type="button">Leave call</button>
|
||||
<button id="endCallButton" class="danger-button hidden" type="button">End call</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -507,13 +621,51 @@
|
||||
<div class="admin-actions">
|
||||
<button id="createRoomButton" class="button" type="button">Add chat</button>
|
||||
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</button>
|
||||
<button id="typingToggleButton" class="button secondary" type="button">Typing indicator: on</button>
|
||||
<button id="notificationsToggleButton" class="button secondary" type="button">Notifications: off</button>
|
||||
<button id="themeButton" class="button secondary" type="button" aria-label="Choose accent color">Accent color</button>
|
||||
</div>
|
||||
|
||||
<div id="adminRooms" class="admin-rooms"></div>
|
||||
<div id="adminUsers" class="admin-users"></div>
|
||||
<div class="admin-tabs" role="tablist" aria-label="Moderation sections">
|
||||
<button
|
||||
id="adminTabRooms"
|
||||
class="admin-tab active"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
>
|
||||
Rooms
|
||||
</button>
|
||||
<button
|
||||
id="adminTabUsers"
|
||||
class="admin-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
id="adminTabTeams"
|
||||
class="admin-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
>
|
||||
Teams/channels
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-tab-panels">
|
||||
<div id="adminRoomsPanel" class="admin-tab-panel">
|
||||
<div id="adminRooms" class="admin-rooms"></div>
|
||||
</div>
|
||||
|
||||
<div id="adminUsersPanel" class="admin-tab-panel hidden">
|
||||
<div id="adminUsers" class="admin-users"></div>
|
||||
</div>
|
||||
|
||||
<div id="adminTeamsPanel" class="admin-tab-panel hidden">
|
||||
<div id="adminTeamsChannels" class="admin-rooms"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -531,6 +683,23 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
id="settingsSavePanel"
|
||||
class="settings-save-panel hidden"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="settings-save-panel-inner">
|
||||
<div class="settings-save-copy">
|
||||
<strong id="settingsUnsavedIndicator">Unsaved changes</strong>
|
||||
<small>Your previews are local until you save.</small>
|
||||
</div>
|
||||
<div class="settings-save-actions">
|
||||
<button id="settingsCloseButton" class="button secondary" type="button">Close</button>
|
||||
<button id="settingsSaveButton" class="button" type="button">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statusToast" class="status-toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/00-config.js', v=asset_version) }}" defer></script>
|
||||
@ -547,4 +716,4 @@
|
||||
<script src="{{ url_for('static', filename='js/04-main.js', v=asset_version) }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/07-team-channel-adapter.js', v=asset_version) }}" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user