diff --git a/features.py b/features.py index cb5ae57..25dd75e 100644 --- a/features.py +++ b/features.py @@ -1,18 +1,15 @@ # features.py import json -import mimetypes import re -import secrets import time from datetime import timedelta from pathlib import Path from typing import Optional from flask import abort, jsonify, send_from_directory - import markdown -from sanitizer import sanitize_html_fragment +from sanitizer import sanitize_html_fragment IMAGE_MIME_PREFIX = "image/" AUDIO_MIME_PREFIX = "audio/" @@ -33,9 +30,12 @@ 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"} 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: @@ -65,16 +65,27 @@ def render_markdown_to_safe_html(text: str) -> str: def safe_profile_bio_html(html_module, 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 "

" + html_module.escape(bio or "").replace("\n", "
") + "

" +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 attachment_kind_from_name_and_mime( original_name: Optional[str], mime_type: Optional[str], @@ -82,7 +93,6 @@ def attachment_kind_from_name_and_mime( 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: @@ -90,7 +100,224 @@ def attachment_kind_from_name_and_mime( if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS: return "video" return "file" - + + +def load_page_children(get_db, page_id: int) -> list[dict]: + 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(get_db, 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(get_db, page_id: int) -> list[dict]: + 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 next_page_version_number(get_db, page_id: int) -> int: + with get_db() as db: + row = db.execute( + """ + SELECT COALESCE(MAX(version_number), 0) AS value + FROM page_versions + WHERE page_id = ? + """, + (page_id,), + ).fetchone() + return int(row["value"] or 0) + 1 + + +def snapshot_page_version( + get_db, + page_id: int, + title: str, + body: str, + visibility: str, + page_type: str, + channel_id: Optional[int], + parent_page_id: Optional[int], + created_by_user_id: int, + created_at: str, +) -> int: + version_number = next_page_version_number(get_db, page_id) + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + cursor = db.execute( + """ + INSERT INTO page_versions ( + page_id, + version_number, + title, + body, + visibility, + page_type, + channel_id, + parent_page_id, + created_by_user_id, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + page_id, + version_number, + title, + body, + visibility, + page_type, + channel_id, + parent_page_id, + created_by_user_id, + created_at, + ), + ) + db.commit() + return cursor.lastrowid + + +def load_page_versions(get_db, page_id: int) -> list[dict]: + with get_db() as db: + rows = db.execute( + """ + SELECT + pv.id, + pv.page_id, + pv.version_number, + pv.title, + pv.visibility, + pv.page_type, + pv.channel_id, + pv.parent_page_id, + pv.created_by_user_id, + pv.created_at, + u.username AS created_by_username + FROM page_versions AS pv + JOIN users AS u + ON u.id = pv.created_by_user_id + WHERE pv.page_id = ? + ORDER BY pv.version_number DESC + """, + (page_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def load_page_version_detail(get_db, version_id: int): + with get_db() as db: + row = db.execute( + """ + SELECT + pv.id, + pv.page_id, + pv.version_number, + pv.title, + pv.body, + pv.visibility, + pv.page_type, + pv.channel_id, + pv.parent_page_id, + pv.created_by_user_id, + pv.created_at, + u.username AS created_by_username + FROM page_versions AS pv + JOIN users AS u + ON u.id = pv.created_by_user_id + WHERE pv.id = ? + """, + (version_id,), + ).fetchone() + return row + + +def user_can_see_last_active(settings_row, viewer: Optional[dict], target_user_id: int, is_team_member_func=None) -> bool: + visibility = (settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone") or "everyone" + if visibility == "nobody": + return False + if visibility == "everyone": + return True + if visibility == "members": + if not viewer: + return False + if viewer["id"] == target_user_id: + return True + if viewer["role"] in {"moderator", "admin"}: + return True + if is_team_member_func: + return True + return False + return False + + def page_payload(app_module, page, user: Optional[dict]) -> dict: can_read = bool( page["visibility"] == "public" @@ -98,13 +325,11 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict: user and ( user["role"] in {"moderator", "admin"} - or app_module.is_team_member(page["team_id"], user["id"]) + or app_module["is_team_member"](page["team_id"], user["id"]) ) ) ) - - rendered_html = app_module.render_markdown_to_safe_html(page["body"] or "") - + rendered_html = render_markdown_to_safe_html(page["body"] or "") return { "id": page["id"], "team_id": page["team_id"], @@ -113,6 +338,9 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict: "channel_id": page["channel_id"], "channel_slug": page["channel_slug"], "channel_name": page["channel_name"], + "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, "slug": page["slug"], "title": page["title"], "body": page["body"], @@ -128,20 +356,28 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict: user and ( user["role"] in {"moderator", "admin"} - or app_module.is_team_admin(page["team_id"], user) + or app_module["is_team_admin"](page["team_id"], user) ) ), + "children": load_page_children(app_module["get_db"], page["id"]), + "backlinks": load_page_backlinks( + app_module["get_db"], + page["id"], + page["slug"], + page["team_id"], + ), + "references": extract_page_reference_slugs(page["body"] or ""), + "comments": load_page_comments(app_module["get_db"], page["id"]), } + 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"], @@ -155,12 +391,10 @@ def attachment_payload_from_row(row) -> Optional[dict]: 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"], @@ -188,7 +422,6 @@ def attachment_list_for_message(get_db, message_id: int) -> list[dict]: """, (message_id,), ).fetchall() - return [attachment_payload_from_row(row) for row in rows] @@ -200,7 +433,6 @@ def cleanup_orphaned_attachments( app_logger, ) -> None: cutoff = (utc_now() - timedelta(hours=orphan_ttl_hours)).isoformat() - with get_db() as db: orphaned = db.execute( """ @@ -222,7 +454,6 @@ def cleanup_orphaned_attachments( """, (cutoff,), ).fetchall() - for attachment in orphaned: db.execute( """ @@ -231,7 +462,6 @@ def cleanup_orphaned_attachments( """, (attachment["id"],), ) - db.commit() for attachment in orphaned: @@ -252,12 +482,9 @@ def maybe_cleanup_orphaned_attachments( ) -> None: now = time.time() last = state.get("last_attachment_cleanup", 0.0) - if now - last < cleanup_interval_seconds: return - state["last_attachment_cleanup"] = now - try: cleanup_func() except Exception: @@ -271,23 +498,19 @@ def resolve_owned_attachment_ids( ) -> 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( """ @@ -297,18 +520,14 @@ def resolve_owned_attachment_ids( """, (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 @@ -319,7 +538,6 @@ def resolve_owned_attachment( ) -> tuple[Optional[int], Optional[tuple]]: if attachment_id is None: return None, None - try: parsed_id = int(attachment_id) except (TypeError, ValueError): @@ -336,16 +554,13 @@ def resolve_owned_attachment( """, (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 @@ -359,7 +574,6 @@ def can_download_attachment( ) -> bool: if not user: return False - with get_db() as db: room_rows = db.execute( """ @@ -381,7 +595,6 @@ def can_download_attachment( """, (attachment_id,), ).fetchall() - channel_rows = db.execute( """ SELECT DISTINCT @@ -394,7 +607,6 @@ def can_download_attachment( """, (attachment_id,), ).fetchall() - dm_rows = db.execute( """ SELECT DISTINCT dc.id @@ -413,15 +625,12 @@ def can_download_attachment( 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 @@ -447,7 +656,6 @@ def send_attachment_file( """, (attachment_id,), ).fetchone() - if not attachment: abort(404) @@ -493,7 +701,6 @@ def reaction_summary_for_message(get_db, message_id: int, user: Optional[dict]) """, (user["id"] if user else -1, message_id), ).fetchall() - return [ { "emoji": row["emoji"], @@ -532,6 +739,7 @@ def richer_message_payload( ).fetchone()["count"] seen_by_user = False + is_bookmarked = False if current_user_id: seen_by_user = bool( db.execute( @@ -544,10 +752,29 @@ def richer_message_payload( (row["id"], current_user_id), ).fetchone() ) + is_bookmarked = bool( + db.execute( + """ + SELECT 1 + FROM message_bookmarks + WHERE message_id = ? + AND user_id = ? + """, + (row["id"], current_user_id), + ).fetchone() + ) + + pin_row = db.execute( + """ + SELECT pinned_at, pinned_by_user_id + FROM message_pins + WHERE message_id = ? + """, + (row["id"],), + ).fetchone() primary_attachment = attachment_payload_legacy_from_message_select_row(row) attachments = attachment_list_for_message(get_db, row["id"]) - if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments): attachments.insert(0, primary_attachment) @@ -576,6 +803,10 @@ def richer_message_payload( "seen_by_current_user": seen_by_user, "reactions": reaction_summary_for_message(get_db, row["id"], user), "is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]), + "is_pinned": bool(pin_row), + "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, } @@ -590,7 +821,6 @@ def feed_message_payload( user, user["id"] if user else None, ) - payload.update( { "room_slug": row["room_slug"] if "room_slug" in row.keys() else None, @@ -618,19 +848,42 @@ def profile_payload( viewer: Optional[dict], ) -> dict: is_self = bool(viewer and viewer["id"] == user_row["id"]) - status_payload = user_payload_func(user_row) - + 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 else "public" + if profile_visibility not in PROFILE_VISIBILITIES: + profile_visibility = "public" + + last_active_visibility = settings_row["last_active_visibility"] if settings_row else "everyone" + if last_active_visibility not in LAST_ACTIVE_VISIBILITIES: + last_active_visibility = "everyone" + + presence_mode = settings_row["presence_mode"] if settings_row else "online" + if presence_mode not in PRESENCE_MODES: + presence_mode = "online" + + last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None + + can_show_last_active = False + if last_active_visibility == "everyone": + can_show_last_active = True + elif last_active_visibility == "members": + can_show_last_active = bool(viewer and (viewer["id"] == user_row["id"] or viewer["role"] in {"moderator", "admin"})) + elif last_active_visibility == "nobody": + can_show_last_active = bool(viewer and viewer["id"] == user_row["id"]) + 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 if can_show_last_active else None, "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, @@ -638,10 +891,17 @@ def profile_payload( "bio_html": safe_profile_bio_html(html_module, bio, bio_mode), "website": profile_row["website"] or "", "location": profile_row["location"] or "", + "status_message": profile_row["status_message"] or "", + "banner_attachment_id": profile_row["banner_attachment_id"], + "banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if 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 else None, + "profile_banner_color": settings_row["profile_banner_color"] if settings_row else None, + "profile_visibility": profile_visibility, + "last_active_visibility": last_active_visibility, } @@ -657,6 +917,8 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]: 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 @@ -709,6 +971,8 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]: "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"], @@ -720,7 +984,6 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]: def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) -> int: low_id, high_id = sorted([int(user_a_id), int(user_b_id)]) - with get_db() as db: existing = db.execute( """ @@ -735,7 +998,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) """, (low_id, high_id, f"{low_id}:{high_id}"), ).fetchone() - if existing: return existing["id"] @@ -750,7 +1012,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) """, (f"{low_id}:{high_id}",), ).fetchone() - if existing: db.commit() return existing["id"] @@ -765,7 +1026,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) (f"{low_id}:{high_id}", now_iso()), ) dm_id = cursor.lastrowid - db.execute( """ INSERT INTO direct_conversation_members ( @@ -817,7 +1077,9 @@ def get_dm_other_user(get_db, dm_id: int, user_id: int): u.username, u.role, up.display_name, - us.name_color + 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 @@ -853,6 +1115,8 @@ def dm_payload(get_db, dm_id: int, user_id: int) -> dict: "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", }, } @@ -1071,3 +1335,89 @@ def call_payload(get_db, call_id: int) -> Optional[dict]: "created_at": row["created_at"], "ended_at": row["ended_at"], } + + +def dispatch_scheduled_messages(get_db, now_iso_func, app_logger) -> None: + now_value = now_iso_func() + with get_db() as db: + rows = db.execute( + """ + SELECT + id, + room_id, + channel_id, + direct_conversation_id, + user_id, + username, + text, + attachment_ids_json + FROM scheduled_messages + WHERE sent_at IS NULL + AND cancelled_at IS NULL + AND deliver_at <= ? + ORDER BY deliver_at ASC, id ASC + LIMIT 100 + """, + (now_value,), + ).fetchall() + + if not rows: + return + + db.execute("BEGIN IMMEDIATE") + for row in rows: + try: + attachment_ids = json.loads(row["attachment_ids_json"] or "[]") + except json.JSONDecodeError: + attachment_ids = [] + + attachment_id = attachment_ids[0] if attachment_ids else None + + 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, ?) + """, + ( + row["room_id"], + row["channel_id"], + row["direct_conversation_id"], + row["user_id"], + row["username"], + row["text"], + attachment_id, + now_value, + ), + ) + 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.execute( + """ + UPDATE scheduled_messages + SET sent_at = ? + WHERE id = ? + """, + (now_value, row["id"]), + ) + db.commit() \ No newline at end of file