From 4fdfcc741512c4e16d0318ebd5ca76f1548313b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 09:07:22 +0000 Subject: [PATCH 01/22] =?UTF-8?q?feat(mcp):=20=D7=A4=D7=AA=D7=A7=D7=99?= =?UTF-8?q?=D7=9D=20=D7=93=D7=91=D7=99=D7=A7=D7=99=D7=9D=20=E2=80=94=20?= =?UTF-8?q?=D7=A6=D7=A4=D7=99=D7=99=D7=94,=20=D7=99=D7=A6=D7=99=D7=A8?= =?UTF-8?q?=D7=94=20=D7=95=D7=A2=D7=A8=D7=99=D7=9B=D7=94=20=D7=93=D7=A8?= =?UTF-8?q?=D7=9A=20=D7=94-MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שלושה כלים חדשים מעל קולקציית sticky_notes הקיימת של הוובאפ: - codekeeper_list_notes — פתקי הקובץ (קריאה טהורה, בלי ה-backfill של ה-GET בוובאפ) - codekeeper_create_note — פתק חדש על קובץ קיים; line מעגן לשורת מקור, בלעדיו הפתק נוצר צף עם sentinel __floating__ מפורש (אחרת ה-JS מעגן אוטומטית לשורה הקרובה ודורס את הכוונה) - codekeeper_update_note — עדכון חלקי לפי note_id, עם annotation נפרד (destructive+idempotent) כי פתק נדרס במקום ואין לו היסטוריית גרסאות עקרונות: - כותבים בדיוק את סכמת הוובאפ (scope_id מ-sticky_notes_scope.make_scope_id הקנוני, ברירות מחדל בפריטת הקליינט) — פתק מה-MCP מופיע מיד ב-UI - זהות תמיד מהטוקן; יצירה/עדכון מאחורי require_write - תוכן >5000 תווים נדחה בשגיאה (לא קיטום שקט — סוכן לא ישים לב לאובדן) - מגן אנטי-לולאה: עד 200 פתקים לקובץ ביצירה - אינדקס (user_id, scope_id) שחסר היום נוצר lazy/best-effort — משרת גם את שאילתת ה-scope הזהה של הוובאפ - בלי מחיקה ובלי תזכורות (non-goal מתועד); אפס שינויי קוד בוובאפ — דיפלוי לשירות ה-MCP בלבד בדיקות: 19 טסטים הרמטיים חדשים (סניטציה, ולידציות, sentinel, צבעים, scope filter מול make_scope_id האמיתי, סריאליזציה) + עדכון טסט הרישום. 188 טסטי MCP עוברים; black/flake8/doc8 נקיים; Sphinx נבנה עם 0 אזהרות. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- .../FEATURE_MCP_CLAUDE_INTEGRATION.md | 9 +- docs/mcp-server.rst | 12 +- mcp_server/README.md | 5 +- mcp_server/backend.py | 186 ++++++++++++++ mcp_server/handlers.py | 151 +++++++++++ mcp_server/server.py | 90 ++++++- tests/test_mcp_notes_handlers.py | 240 ++++++++++++++++++ tests/test_mcp_server_build.py | 12 + 8 files changed, 698 insertions(+), 7 deletions(-) create mode 100644 tests/test_mcp_notes_handlers.py diff --git a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md index 7ea680ad5..faaebbbf7 100644 --- a/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md +++ b/FEATURE_SUGGESTIONS/FEATURE_MCP_CLAUDE_INTEGRATION.md @@ -1,6 +1,6 @@ # חיבור Claude.ai ל‑CodeKeeper דרך MCP — מסמך תכנון -> **סטטוס:** פאזות 0 (PAT) **+** 1 (OAuth 2.1 ל‑Claude.ai) **+** 3 (כתיבה: save + edit/append חלקיים) **+** ד' (דפדפן ריפו לאדמין, קריאה בלבד) **מומשו** — ראו `mcp_server/`. מחיקה וכתיבת אוספים עדיין בתכנון. +> **סטטוס:** פאזות 0 (PAT) **+** 1 (OAuth 2.1 ל‑Claude.ai) **+** 3 (כתיבה: save + edit/append חלקיים) **+** ד' (דפדפן ריפו לאדמין, קריאה בלבד) **+** פתקים דביקים (list/create/update) **מומשו** — ראו `mcp_server/`. מחיקה (קבצים ופתקים) וכתיבת אוספים עדיין בתכנון. > **ענף פיתוח:** `claude/mcp-codekeeper-webapp-ldnzsg` > **מתי להשתמש:** לפני מימוש חיבור MCP; מסמך זה הוא מקור האמת לתכנון. > **ראו גם:** `mcp_server/README.md` (שימוש), [CodeBot – Project Docs](https://amirbiron.github.io/CodeBot/), `CLAUDE.md` (מדיניות מחייבת). @@ -144,6 +144,11 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle | `edit_file` *(פאזה 3 ✅)* | `file_name`, `old_string`, `new_string`, `replace_all?` | מצא‑והחלף → גרסה חדשה | `get_latest_version` + `save_file` | | `append_file` *(פאזה 3 ✅)* | `file_name`, `content` | הוספה לסוף → גרסה חדשה | `get_latest_version` + `save_file` | | `delete_file` *(פאזה 3 — טרם מומש)* | `file_name` | מחיקה רכה | `delete_file` | +| `list_notes` *(✅ מומש)* | `file_name` | פתקים דביקים של הקובץ (זהים ל‑UI) | `sticky_notes` + `make_scope_id` | +| `create_note` *(✅ מומש)* | `file_name`, `content`, `line?`, `color?`, `anchor_text?` | פתק חדש (צף בלי `line`) | `sticky_notes` + `get_latest_version` | +| `update_note` *(✅ מומש)* | `note_id`, שדות חלקיים | עדכון פתק במקום (ללא היסטוריה) | `sticky_notes` | + +**Non‑goal לפתקים:** אין מחיקת פתקים ואין תזכורות (`note_reminders`) דרך ה‑MCP — מוחקים/מתזמנים ב‑UI של הוובאפ. ### 5.2 Resources (אופציונלי, מומלץ) לחשוף כל קובץ כ‑`codekeeper://file/{file_name}` כדי לאפשר תיוג `@` של קובץ בתוך Claude. רשימת המשאבים = `get_user_files` (בלי תוכן), והקריאה בפועל = `get_latest_version`. @@ -215,7 +220,7 @@ db.delete_file(user_id, file_name) # מחיקה רכה (recycle | ג' | כתיבה: `save_file` + `edit_file`/`append_file` מאחורי scope (✅ מומש); `delete_file`, rate limiting, מסך "חיבורים פעילים" + revoke (טרם) | ~1–2 ימים | בדיקות כתיבה על tmp בלבד, אישור scope, revoke | | ד' | דפדפן הריפו (אדמין בלבד): `list_repos`/`list_repo_tree`/`get_repo_file`/`search_repo` מעל Repo Sync Engine + `require_admin` + סינון tools/list + מדיניות סודות (13.5) — ראו סעיף 13 | ~2–3 ימים | יחידה עם mirror מזויף/tmp, בדיקת fail‑closed לאדמין, מדיניות סודות (כל כלל + מקרי קצה + מדיניות‑חסרה), ברירות מחדל/תקרות/clamp, תקציב פלט, timeout, snippet בלבד בחיפוש | -**מיפוי ומצב (קנוני):** שלב א' = פאזה 0 (✅ מומש) · שלב ב' = פאזה 1 (✅ מומש) · שלב ג' = פאזה 3 (✅ מומש חלקית — save + edit/append; מחיקה טרם) · שלב ד' = פאזה ד' (✅ מומש — קריאה בלבד, אדמין). +**מיפוי ומצב (קנוני):** שלב א' = פאזה 0 (✅ מומש) · שלב ב' = פאזה 1 (✅ מומש) · שלב ג' = פאזה 3 (✅ מומש חלקית — save + edit/append; מחיקה טרם) · שלב ד' = פאזה ד' (✅ מומש — קריאה בלבד, אדמין) · פתקים דביקים (✅ מומש — list/create/update, בלי מחיקה/תזכורות). **בדיקות — לפי `CLAUDE.md`:** לעבוד רק על תיקיות זמניות, בלי מחיקות ב‑root, בידוד לכל טסט. לפני תיקוני טסטים — לעיין ב‑[CodeBot Docs](https://amirbiron.github.io/CodeBot/). diff --git a/docs/mcp-server.rst b/docs/mcp-server.rst index a774a6f88..6e15334b8 100644 --- a/docs/mcp-server.rst +++ b/docs/mcp-server.rst @@ -64,6 +64,15 @@ OAuth 2.1) וגם מול **Claude Code / Claude Desktop** (טוקן אישי). - **כתיבה:** הוספת טקסט לסוף קובץ קיים (גרסה חדשה). דורש ``write`` * - ``codekeeper_list_versions`` - היסטוריית גרסאות של קובץ + * - ``codekeeper_list_notes`` + - פתקים דביקים של קובץ (לפי ``file_name``) — אותם פתקים שמוצגים + ב-UI של הוובאפ + * - ``codekeeper_create_note`` + - **כתיבה:** יצירת פתק דביק על קובץ קיים; ``line`` אופציונלי מעגן + לשורת מקור (בלעדיו הפתק צף). דורש ``write`` + * - ``codekeeper_update_note`` + - **כתיבה:** עדכון חלקי של פתק לפי ``note_id`` (תוכן/שורה/צבע/מוזער) + — דורס במקום, אין היסטוריית גרסאות לפתקים. דורש ``write`` * - ``codekeeper_list_collections`` / ``codekeeper_get_collection`` / ``codekeeper_get_collection_items`` - האוספים והקבצים שבתוכם @@ -106,7 +115,8 @@ OAuth 2.1) וגם מול **Claude Code / Claude Desktop** (טוקן אישי). - ``read`` — ברירת המחדל לכל חיבור. - ``write`` — נדרש לכלי הכתיבה (``codekeeper_save_file`` / - ``codekeeper_edit_file`` / ``codekeeper_append_file``); ניתן רק באישור + ``codekeeper_edit_file`` / ``codekeeper_append_file`` / + ``codekeeper_create_note`` / ``codekeeper_update_note``); ניתן רק באישור מפורש (מסך ההרשאה ב-Claude.ai או טוקן ``write`` מהבוט). - **אדמין** — כלי הריפו זמינים רק למשתמשים שב-``ADMIN_USER_IDS``; לכל אחד אחר הם diff --git a/mcp_server/README.md b/mcp_server/README.md index ea2a8c14e..187f7d75d 100644 --- a/mcp_server/README.md +++ b/mcp_server/README.md @@ -19,7 +19,7 @@ Claude Desktop** (טוקן אישי). קריאה זמינה תמיד; **כתיב כל הכלים מקודמים ב‑`codekeeper_` (מונע התנגשות עם connectors אחרים). כולם read-only פרט לכלי הכתיבה `codekeeper_save_file`/`codekeeper_edit_file`/`codekeeper_append_file` -(דורשים הרשאת `write`). +וכלי הפתקים `codekeeper_create_note`/`codekeeper_update_note` (דורשים הרשאת `write`). | כלי | תיאור | |-----|-------| @@ -30,6 +30,9 @@ Claude Desktop** (טוקן אישי). קריאה זמינה תמיד; **כתיב | `codekeeper_edit_file` | **כתיבה:** מצא‑והחלף מדויק (`old_string`→`new_string`, אופציונלית `replace_all`) בלי לשלוח את כל הקובץ; גרסה חדשה, משמר שפה/תיאור/תגיות. דורש `write` | | `codekeeper_append_file` | **כתיבה:** הוספת טקסט לסוף קובץ קיים (מוסיף שורת‑הפרדה אם צריך); גרסה חדשה. דורש `write` | | `codekeeper_list_versions` | היסטוריית גרסאות של קובץ (מטא‑דאטה) | +| `codekeeper_list_notes` | פתקים דביקים של קובץ (לפי `file_name`) — אותם פתקים שמוצגים ב‑UI של הוובאפ | +| `codekeeper_create_note` | **כתיבה:** יצירת פתק דביק על קובץ קיים; `line` אופציונלי מעגן לשורת מקור (בלעדיו הפתק צף). דורש `write` | +| `codekeeper_update_note` | **כתיבה:** עדכון חלקי של פתק לפי `note_id` (תוכן/שורה/צבע/מוזער) — דורס במקום, אין היסטוריית גרסאות לפתקים. דורש `write` | | `codekeeper_list_collections` | האוספים של המשתמש | | `codekeeper_get_collection` | אוסף בודד לפי id | | `codekeeper_get_collection_items` | הקבצים בתוך אוסף (עם עימוד/סינון תיקייה) | diff --git a/mcp_server/backend.py b/mcp_server/backend.py index 495d293d2..85479b498 100644 --- a/mcp_server/backend.py +++ b/mcp_server/backend.py @@ -16,10 +16,17 @@ from __future__ import annotations import datetime as _dt +import html +import logging from typing import Any +logger = logging.getLogger(__name__) + _HEAVY_FIELDS = ("code", "content", "raw_data", "raw_content") +# שדות הפתק שנחשפים ל-MCP — רזה במכוון (בלי מיקום/גודל פיקסלים, שהם עניין ויזואלי) +_NOTE_FIELDS = ("content", "color", "line_start", "anchor_text", "is_minimized") + def _json_safe(value: Any) -> Any: """Recursively convert Mongo/BSON types to JSON-friendly values.""" @@ -70,6 +77,37 @@ def _strip_heavy(value: Any) -> Any: return value +def _as_note(doc: dict[str, Any]) -> dict[str, Any]: + """Serialize a sticky-note document for MCP output (lean, JSON-safe).""" + doc = doc or {} + out: dict[str, Any] = {"id": str(doc.get("_id") or "")} + for key in _NOTE_FIELDS: + out[key] = _json_safe(doc.get(key)) + # פתקי legacy נשמרו עם HTML entities — משחזרים טקסט כמו שהוובאפ עושה בקריאה + if isinstance(out.get("content"), str): + out["content"] = html.unescape(out["content"]) + out["created_at"] = _json_safe(doc.get("created_at")) + out["updated_at"] = _json_safe(doc.get("updated_at")) + return out + + +def _notes_scope_filter( + user_id: int, scope_id: str | None, related_ids: list[str] +) -> dict[str, Any]: + """The webapp-parity notes query: by scope_id, plus file_id for legacy notes. + + Module-level and pure so tests can assert the exact query shape. + """ + clauses: list[dict[str, Any]] = [] + if scope_id: + clauses.append({"scope_id": scope_id}) + if related_ids: + clauses.append({"file_id": {"$in": list(related_ids)}}) + if not clauses: + return {"user_id": int(user_id)} + return {"user_id": int(user_id), "$or": clauses} + + class ProductionBackend: """Backend backed by the real in-process ``database`` layer. @@ -83,6 +121,7 @@ def __init__( self._dbm = db_manager self._mongo = mongo_db self._cm = collections_manager + self._notes_idx_done = False # -- lazy wiring ------------------------------------------------------- def _require_dbm(self) -> Any: @@ -213,3 +252,150 @@ def get_collection_items( if isinstance(result, dict) and isinstance(result.get("items"), list): result["items"] = [_strip_heavy(item) for item in result["items"]] return result + + # -- sticky notes ------------------------------------------------------ + def _raw_mongo(self) -> Any: + mongo = self._mongo if self._mongo is not None else getattr(self._require_dbm(), "db", None) + if mongo is None: + raise RuntimeError("MongoDB handle unavailable for sticky notes") + return mongo + + def _notes_coll(self) -> Any: + coll = self._raw_mongo()["sticky_notes"] + # אינדקס שחסר היום לשאילתת ה-scope (משרת גם את הוובאפ); חד-פעמי, לא מפיל כלי + if not self._notes_idx_done: + self._notes_idx_done = True + try: + coll.create_index([("user_id", 1), ("scope_id", 1)], name="user_scope_idx") + except Exception: + logger.warning("sticky notes index creation failed (non-fatal)", exc_info=True) + return coll + + def _related_file_ids(self, user_id: int, file_name: str) -> list[str]: + """כל מזהי הגרסאות של השם הזה — לפריטת שאילתת הוובאפ (פתקי legacy בלי scope_id).""" + try: + rows = self._raw_mongo()["code_snippets"].find( + {"user_id": int(user_id), "file_name": file_name}, {"_id": 1} + ) + return [str(r["_id"]) for r in rows if r and r.get("_id") is not None] + except Exception: + logger.warning("related file ids lookup failed", exc_info=True) + return [] + + def list_notes(self, user_id: int, *, file_name: str) -> dict[str, Any]: + """List notes for a file (pure read — no backfill, unlike the webapp GET).""" + from sticky_notes_scope import make_scope_id # מודול טהור בשורש הריפו + + scope_id = make_scope_id(int(user_id), file_name) + related = self._related_file_ids(user_id, file_name) + query = _notes_scope_filter(user_id, scope_id, related) + rows = list(self._notes_coll().find(query).sort("created_at", 1).limit(500)) + return { + "ok": True, + "file_name": file_name, + "count": len(rows), + "notes": [_as_note(r) for r in rows], + } + + def create_note( + self, + user_id: int, + *, + file_name: str, + content: str, + line: int | None, + color: str, + anchor_text: str | None, + anchor_id: str | None, + ) -> dict[str, Any]: + """Insert a webapp-schema note attached to an existing file.""" + from .handlers import MAX_NOTES_PER_SCOPE + from sticky_notes_scope import make_scope_id + + doc = self._require_dbm().get_latest_version(int(user_id), file_name) + if not doc: + return { + "ok": False, + "error": "file_not_found", + "hint": "save the file first with codekeeper_save_file", + } + canonical_name = str(doc.get("file_name") or file_name) + scope_id = make_scope_id(int(user_id), canonical_name) + related = self._related_file_ids(user_id, canonical_name) + + coll = self._notes_coll() + try: + existing = int(coll.count_documents(_notes_scope_filter(user_id, scope_id, related))) + except Exception: + existing = 0 # המגן הוא soft-cap; כשל ספירה לא חוסם יצירה + if existing >= MAX_NOTES_PER_SCOPE: + return { + "ok": False, + "error": "too_many_notes", + "max": MAX_NOTES_PER_SCOPE, + "count": existing, + } + + now = _dt.datetime.now(_dt.timezone.utc) + note = { + "user_id": int(user_id), + "file_id": str(doc.get("_id") or ""), + "content": content, + # ברירות מחדל בפריטת הקליינט — פתק מה-MCP נראה כמו פתק שנוצר ביד + "position_x": 120, + "position_y": 120, + "width": 260, + "height": 200, + "color": color, + "is_minimized": False, + "line_start": line, + "line_end": None, + "anchor_id": anchor_id, + "anchor_text": anchor_text, + "scope_id": scope_id, + "file_name": canonical_name, + "created_at": now, + "updated_at": now, + } + res = coll.insert_one(note) + note["_id"] = getattr(res, "inserted_id", None) + return {"ok": True, "note": _as_note(note)} + + def update_note(self, user_id: int, *, note_id: str, fields: dict[str, Any]) -> dict[str, Any]: + """Partial in-place update by ObjectId, ownership enforced in the filter.""" + from bson import ObjectId # lazy heavy import + + try: + oid = ObjectId(str(note_id)) + except Exception: + return {"ok": False, "error": "invalid_note_id"} + + coll = self._notes_coll() + note = coll.find_one({"_id": oid, "user_id": int(user_id)}) + if not note: + return {"ok": False, "error": "not_found"} + + updates = dict(fields) + # backfill לפתק legacy בלי scope_id — רק במסלול הכתיבה (list נשאר קריאה טהורה) + if not note.get("scope_id"): + fname = note.get("file_name") + if not fname and note.get("file_id"): + try: + ref = self._raw_mongo()["code_snippets"].find_one( + {"_id": ObjectId(str(note["file_id"])), "user_id": int(user_id)}, + {"file_name": 1}, + ) + fname = (ref or {}).get("file_name") + except Exception: + fname = None + if fname: + from sticky_notes_scope import make_scope_id + + sid = make_scope_id(int(user_id), str(fname)) + if sid: + updates["scope_id"] = sid + updates["file_name"] = str(fname) + + updates["updated_at"] = _dt.datetime.now(_dt.timezone.utc) + coll.update_one({"_id": oid, "user_id": int(user_id)}, {"$set": updates}) + return {"ok": True, "note": _as_note({**note, **updates})} diff --git a/mcp_server/handlers.py b/mcp_server/handlers.py index 14eec3760..0edf61c21 100644 --- a/mcp_server/handlers.py +++ b/mcp_server/handlers.py @@ -10,6 +10,8 @@ from __future__ import annotations +import html +import re from typing import Any MAX_PER_PAGE = 200 @@ -264,3 +266,152 @@ def append_file(backend: Any, user_id: int, *, file_name: str, content: str) -> if not res.get("ok"): return res return {"ok": True, "appended_chars": len(content), "file": res.get("file")} + + +# -- sticky notes ---------------------------------------------------------- + +MAX_NOTE_CONTENT = 5000 +MAX_NOTES_PER_SCOPE = 200 +MAX_ANCHOR_TEXT = 256 +MAX_NOTE_LINE = 1_000_000 +DEFAULT_NOTE_COLOR = "#FFFFCC" +# sentinel של הוובאפ לפתק "צף": בלעדיו ה-JS מעגן פתק חדש אוטומטית לשורה הקרובה +NOTE_FLOATING_ANCHOR = "__floating__" + +_NOTE_ID_RE = re.compile(r"^[0-9a-fA-F]{24}$") +_NOTE_COLOR_RE = re.compile(r"^#[0-9A-Fa-f]{3,8}$") +# עותק של webapp/sticky_notes_api.py:_CONTROL_CHARS_RE — לשמור מסונכרן +_NOTE_CONTROL_CHARS_RE = re.compile(r"[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]") + + +def _sanitize_note_text(text: Any) -> str: + """Normalize note text like the webapp does — without truncating. + + Length is the caller's decision (reject, not clip): silent clipping is data + loss an agent won't notice. + """ + if text is None: + return "" + try: + s = str(text) + except Exception: + return "" + s = html.unescape(s) + s = s.replace("\r\n", "\n").replace("\r", "\n") + return _NOTE_CONTROL_CHARS_RE.sub("", s) + + +def _valid_note_line(line: Any) -> int | None: + """Coerce a 1-indexed source line; None on invalid.""" + try: + line_i = int(line) + except (TypeError, ValueError): + return None + if not 1 <= line_i <= MAX_NOTE_LINE: + return None + return line_i + + +def _clean_anchor_text(anchor_text: Any) -> str | None: + text = _sanitize_note_text(anchor_text).strip() + # קיטום קוסמטי בלבד (פריטת הוובאפ) — לא תוכן משתמש שאסור לאבד + return text[:MAX_ANCHOR_TEXT] or None + + +def list_notes(backend: Any, user_id: int, *, file_name: str) -> dict[str, Any]: + """List the user's sticky notes attached to ``file_name`` (read-only).""" + name = (file_name or "").strip() + if not name: + return {"ok": False, "error": "missing_file_name"} + return backend.list_notes(user_id, file_name=name) + + +def create_note( + backend: Any, + user_id: int, + *, + file_name: str, + content: str, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, +) -> dict[str, Any]: + """Attach a sticky note to an existing file. + + With ``line`` the note anchors to that 1-indexed source line; without it the + note is created floating (explicit sentinel — otherwise the web client + auto-anchors it to the nearest line on first render). + """ + name = (file_name or "").strip() + if not name: + return {"ok": False, "error": "missing_file_name"} + + clean = _sanitize_note_text(content).strip() + if not clean: + return {"ok": False, "error": "empty_content"} + if len(clean) > MAX_NOTE_CONTENT: + return {"ok": False, "error": "content_too_long", "max": MAX_NOTE_CONTENT} + + line_i: int | None = None + if line is not None: + line_i = _valid_note_line(line) + if line_i is None: + return {"ok": False, "error": "invalid_line", "min": 1, "max": MAX_NOTE_LINE} + + color_s = (color or "").strip() + if not _NOTE_COLOR_RE.match(color_s): + color_s = DEFAULT_NOTE_COLOR # ביצירה: צבע לא חוקי נופל לברירת המחדל + + return backend.create_note( + user_id, + file_name=name, + content=clean, + line=line_i, + color=color_s, + anchor_text=_clean_anchor_text(anchor_text), + anchor_id=None if line_i else NOTE_FLOATING_ANCHOR, + ) + + +def update_note( + backend: Any, + user_id: int, + *, + note_id: str, + content: str | None = None, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, + is_minimized: bool | None = None, +) -> dict[str, Any]: + """Partial update of a sticky note by its id (in-place, no version history).""" + nid = (note_id or "").strip() + if not _NOTE_ID_RE.match(nid): + return {"ok": False, "error": "invalid_note_id"} + + fields: dict[str, Any] = {} + if content is not None: + clean = _sanitize_note_text(content).strip() + if not clean: + return {"ok": False, "error": "empty_content"} + if len(clean) > MAX_NOTE_CONTENT: + return {"ok": False, "error": "content_too_long", "max": MAX_NOTE_CONTENT} + fields["content"] = clean + if line is not None: + line_i = _valid_note_line(line) + if line_i is None: + return {"ok": False, "error": "invalid_line", "min": 1, "max": MAX_NOTE_LINE} + # מעבר לעיגון-שורה מנקה עוגני כותרת/sentinel — כמו הקליינט של הוובאפ + fields.update({"line_start": line_i, "anchor_id": None, "line_end": None}) + if color is not None: + color_s = (color or "").strip() + if _NOTE_COLOR_RE.match(color_s): + fields["color"] = color_s # בעדכון: צבע לא חוקי נשמט, לא מוחלף בברירת מחדל + if anchor_text is not None: + fields["anchor_text"] = _clean_anchor_text(anchor_text) + if is_minimized is not None: + fields["is_minimized"] = bool(is_minimized) + + if not fields: + return {"ok": False, "error": "no_fields_to_update"} + return backend.update_note(user_id, note_id=nid, fields=fields) diff --git a/mcp_server/server.py b/mcp_server/server.py index 662f5ecb3..c3ddabbbd 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -35,6 +35,9 @@ "codekeeper_save_file to create or update a file, and prefer " "codekeeper_edit_file / codekeeper_append_file to change part of an existing " "file without resending all of it (write tools require write permission). " + "Sticky notes: codekeeper_list_notes reads a file's notes; " + "codekeeper_create_note / codekeeper_update_note add or change them (write " + "permission; notes appear in the CodeKeeper web UI). " "All data is scoped to the authenticated user." ) @@ -48,9 +51,10 @@ "openWorldHint": False, } -# The one write tool. Saves are append-only (an update creates a new version and -# never overwrites), so this is non-destructive; not idempotent because repeating -# it bumps the version each time. +# File-write tools (save/edit/append/create-note). These saves are append-only +# or additive (a file update creates a new version and never overwrites), so +# they are non-destructive; not idempotent because repeating one adds another +# version/note each time. _WRITE_TOOL = { "readOnlyHint": False, "destructiveHint": False, @@ -58,6 +62,16 @@ "openWorldHint": False, } +# In-place update (sticky-note edit): overwrites the note with no version +# history — destructive by the MCP definition, but idempotent (same input +# twice ⇒ same final state). +_UPDATE_IN_PLACE_TOOL = { + "readOnlyHint": False, + "destructiveHint": True, + "idempotentHint": True, + "openWorldHint": False, +} + # Admin-only repo-browser tools (Phase D). Hidden from tools/list for # non-admins by AdminAwareFastMCP — but hiding is UX only, NOT access control: # every one of these also calls require_admin(ctx) in its body. @@ -287,6 +301,76 @@ def get_collection_items( folder=folder, ) + @mcp.tool( + name="codekeeper_list_notes", + description=( + "List the user's sticky notes attached to a file (by file_name): content, " + "color, anchored line, timestamps. Same notes shown in the web UI." + ), + annotations=_READ_ONLY_TOOL, + ) + def list_notes(ctx: Context, file_name: str) -> dict: + return handlers.list_notes(backend, current_user_id(ctx), file_name=file_name) + + @mcp.tool( + name="codekeeper_create_note", + description=( + "Attach a sticky note to an existing saved file. Optional line anchors it to " + "a 1-indexed source line (as read via codekeeper_get_file); without line the " + "note floats at a default position. Notes appear in the CodeKeeper web UI. " + "Requires write permission." + ), + annotations=_WRITE_TOOL, + ) + def create_note( + ctx: Context, + file_name: str, + content: str, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, + ) -> dict: + require_write(ctx) # דחיית טוקן קריאה-בלבד לפני כל נגיעה בנתונים + return handlers.create_note( + backend, + current_user_id(ctx), + file_name=file_name, + content=content, + line=line, + color=color, + anchor_text=anchor_text, + ) + + @mcp.tool( + name="codekeeper_update_note", + description=( + "Update an existing sticky note by note_id (from codekeeper_list_notes): any " + "of content, line, color, anchor_text, is_minimized. Overwrites in place " + "(notes have no version history). Requires write permission." + ), + annotations=_UPDATE_IN_PLACE_TOOL, + ) + def update_note( + ctx: Context, + note_id: str, + content: str | None = None, + line: int | None = None, + color: str | None = None, + anchor_text: str | None = None, + is_minimized: bool | None = None, + ) -> dict: + require_write(ctx) # דחיית טוקן קריאה-בלבד לפני כל נגיעה בנתונים + return handlers.update_note( + backend, + current_user_id(ctx), + note_id=note_id, + content=content, + line=line, + color=color, + anchor_text=anchor_text, + is_minimized=is_minimized, + ) + if repo_backend is not None: _register_repo_tools(mcp, repo_backend) diff --git a/tests/test_mcp_notes_handlers.py b/tests/test_mcp_notes_handlers.py new file mode 100644 index 000000000..da89f701e --- /dev/null +++ b/tests/test_mcp_notes_handlers.py @@ -0,0 +1,240 @@ +"""טסטים ל-handlers של הפתקים הדביקים ב-MCP (list/create/update). + +הכול הרמטי: fake backend שמקליט kwargs, בלי Flask ובלי Mongo. את נכונות +ה-scope בודקים מול make_scope_id האמיתי (sticky_notes_scope בשורש — מודול טהור). +""" + +from mcp_server import handlers +from mcp_server.backend import _as_note, _notes_scope_filter +from mcp_server.handlers import ( + DEFAULT_NOTE_COLOR, + MAX_NOTE_CONTENT, + NOTE_FLOATING_ANCHOR, + _sanitize_note_text, +) + + +class _NotesBackend: + """Fake שמקליט את הקריאה האחרונה ומחזיר תשובת הצלחה קבועה.""" + + def __init__(self): + self.calls = [] + + def list_notes(self, user_id, *, file_name): + self.calls.append(("list", user_id, file_name)) + return {"ok": True, "file_name": file_name, "count": 0, "notes": []} + + def create_note(self, user_id, *, file_name, content, line, color, anchor_text, anchor_id): + self.calls.append( + ( + "create", + user_id, + { + "file_name": file_name, + "content": content, + "line": line, + "color": color, + "anchor_text": anchor_text, + "anchor_id": anchor_id, + }, + ) + ) + return {"ok": True, "note": {"id": "a" * 24, "content": content}} + + def update_note(self, user_id, *, note_id, fields): + self.calls.append(("update", user_id, note_id, dict(fields))) + return {"ok": True, "note": {"id": note_id, **fields}} + + @property + def last_kwargs(self): + return self.calls[-1][2] + + +# -- sanitizer -------------------------------------------------------------- + + +def test_sanitize_normalizes_crlf_and_strips_control_chars(): + assert _sanitize_note_text("a\r\nb\rc") == "a\nb\nc" + assert _sanitize_note_text("xyz") == "xyz" + assert _sanitize_note_text("שלום\tעולם") == "שלום\tעולם" # טאב מותר, כמו בוובאפ + + +def test_sanitize_unescapes_html_entities_and_handles_none(): + assert _sanitize_note_text(""ציטוט"") == '"ציטוט"' + assert _sanitize_note_text(None) == "" + + +# -- list_notes ------------------------------------------------------------- + + +def test_list_notes_requires_file_name(): + be = _NotesBackend() + assert handlers.list_notes(be, 7, file_name=" ") == { + "ok": False, + "error": "missing_file_name", + } + assert be.calls == [] + + +def test_list_notes_strips_name_and_delegates(): + be = _NotesBackend() + out = handlers.list_notes(be, 7, file_name=" notes.md ") + assert out["ok"] is True + assert be.calls[0] == ("list", 7, "notes.md") + + +# -- create_note ------------------------------------------------------------ + + +def test_create_note_rejects_empty_and_control_only_content(): + be = _NotesBackend() + assert handlers.create_note(be, 7, file_name="a.md", content=" ")["error"] == "empty_content" + assert handlers.create_note(be, 7, file_name="a.md", content="")["error"] == "empty_content" + assert be.calls == [] + + +def test_create_note_rejects_oversize_content(): + be = _NotesBackend() + out = handlers.create_note(be, 7, file_name="a.md", content="x" * (MAX_NOTE_CONTENT + 1)) + assert out == {"ok": False, "error": "content_too_long", "max": MAX_NOTE_CONTENT} + assert be.calls == [] + + +def test_create_note_sanitizes_content(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="a\r\nb"c") + assert be.last_kwargs["content"] == 'a\nb"c' + + +def test_create_note_invalid_line_rejected(): + be = _NotesBackend() + for bad in (0, -3, "x", 10**7): + out = handlers.create_note(be, 7, file_name="a.md", content="hi", line=bad) + assert out["error"] == "invalid_line", bad + assert be.calls == [] + + +def test_create_note_without_line_sets_floating_sentinel(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi") + assert be.last_kwargs["anchor_id"] == NOTE_FLOATING_ANCHOR + assert be.last_kwargs["line"] is None + + +def test_create_note_with_line_sets_no_sentinel(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi", line=42) + assert be.last_kwargs["anchor_id"] is None + assert be.last_kwargs["line"] == 42 + + +def test_create_note_color_default_and_fallback(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi") + assert be.last_kwargs["color"] == DEFAULT_NOTE_COLOR + handlers.create_note(be, 7, file_name="a.md", content="hi", color="red") + assert be.last_kwargs["color"] == DEFAULT_NOTE_COLOR # לא-חוקי ⇒ ברירת מחדל + handlers.create_note(be, 7, file_name="a.md", content="hi", color="#AABBCC") + assert be.last_kwargs["color"] == "#AABBCC" + + +def test_create_note_anchor_text_trimmed_and_capped(): + be = _NotesBackend() + handlers.create_note(be, 7, file_name="a.md", content="hi", anchor_text=" " + "t" * 300) + assert be.last_kwargs["anchor_text"] == "t" * 256 + handlers.create_note(be, 7, file_name="a.md", content="hi", anchor_text=" ") + assert be.last_kwargs["anchor_text"] is None + + +# -- update_note ------------------------------------------------------------ + + +def test_update_note_rejects_bad_note_id(): + be = _NotesBackend() + for bad in ("", "zzz", "a" * 23, "g" * 24): + out = handlers.update_note(be, 7, note_id=bad, content="x") + assert out == {"ok": False, "error": "invalid_note_id"}, bad + assert be.calls == [] + + +def test_update_note_requires_some_field(): + be = _NotesBackend() + out = handlers.update_note(be, 7, note_id="a" * 24) + assert out == {"ok": False, "error": "no_fields_to_update"} + # צבע לא-חוקי בעדכון נשמט — ואם זה השדה היחיד, אין מה לעדכן + out = handlers.update_note(be, 7, note_id="a" * 24, color="red") + assert out == {"ok": False, "error": "no_fields_to_update"} + assert be.calls == [] + + +def test_update_note_line_clears_anchor_fields(): + be = _NotesBackend() + handlers.update_note(be, 7, note_id="a" * 24, line=7) + assert be.calls[0][3] == {"line_start": 7, "anchor_id": None, "line_end": None} + + +def test_update_note_partial_fields_forwarded(): + be = _NotesBackend() + handlers.update_note(be, 7, note_id="b" * 24, content="new", is_minimized=1) + fields = be.calls[0][3] + assert fields == {"content": "new", "is_minimized": True} # רק מה שנמסר, bool אמיתי + + +def test_update_note_rejects_empty_and_oversize_content(): + be = _NotesBackend() + assert handlers.update_note(be, 7, note_id="a" * 24, content=" ")["error"] == "empty_content" + out = handlers.update_note(be, 7, note_id="a" * 24, content="x" * (MAX_NOTE_CONTENT + 1)) + assert out["error"] == "content_too_long" + assert be.calls == [] + + +# -- scope filter + serialization (מול make_scope_id האמיתי) --------------- + + +def test_notes_scope_filter_matches_webapp_shape(): + from sticky_notes_scope import make_scope_id + + sid = make_scope_id(42, "Notes.md") + assert sid is not None and sid.startswith("user:42:file:") + assert len(sid.rsplit(":", 1)[-1]) == 16 # 16 תווי hex + # נרמול רווחים ואותיות — שמות שקולים מקבלים אותו scope + assert make_scope_id(42, " notes.MD ") == sid + assert make_scope_id(42, "notes .md") == make_scope_id(42, "notes .md") + + q = _notes_scope_filter(42, sid, ["id1", "id2"]) + assert q == {"user_id": 42, "$or": [{"scope_id": sid}, {"file_id": {"$in": ["id1", "id2"]}}]} + assert _notes_scope_filter(42, sid, []) == {"user_id": 42, "$or": [{"scope_id": sid}]} + assert _notes_scope_filter(42, None, []) == {"user_id": 42} + + +def test_as_note_serialization(): + import datetime as dt + + doc = { + "_id": "OID", + "content": "a "b"", + "color": "#FFFFCC", + "line_start": 3, + "anchor_text": None, + "is_minimized": False, + "created_at": dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc), + "updated_at": dt.datetime(2026, 1, 2, tzinfo=dt.timezone.utc), + "position_x": 120, # שדה ויזואלי — לא נחשף + } + out = _as_note(doc) + assert out["id"] == "OID" + assert out["content"] == 'a "b"' # פתקי legacy עם entities משוחזרים + assert out["line_start"] == 3 + assert out["created_at"].startswith("2026-01-01") + assert out["updated_at"].startswith("2026-01-02") + assert "position_x" not in out + assert set(out) == { + "id", + "content", + "color", + "line_start", + "anchor_text", + "is_minimized", + "created_at", + "updated_at", + } diff --git a/tests/test_mcp_server_build.py b/tests/test_mcp_server_build.py index d56c02f28..829964b8b 100644 --- a/tests/test_mcp_server_build.py +++ b/tests/test_mcp_server_build.py @@ -14,6 +14,9 @@ "codekeeper_save_file", "codekeeper_edit_file", "codekeeper_append_file", + "codekeeper_list_notes", + "codekeeper_create_note", + "codekeeper_update_note", "codekeeper_list_versions", "codekeeper_list_collections", "codekeeper_get_collection", @@ -46,6 +49,15 @@ def get_collection_items(self, *a, **k): def save_file(self, *a, **k): return {"ok": True, "created": True, "file": {}} + def list_notes(self, *a, **k): + return {"ok": True, "notes": [], "count": 0} + + def create_note(self, *a, **k): + return {"ok": True, "note": {}} + + def update_note(self, *a, **k): + return {"ok": True, "note": {}} + class _FakeStore: def verify(self, token): From fd6b7063c644bedc2e76b6d38e3347e7e0d79bf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 12:12:50 +0000 Subject: [PATCH 02/22] =?UTF-8?q?feat(webapp):=20=D7=A7=D7=99=D7=A6=D7=95?= =?UTF-8?q?=D7=A8=20=D7=93=D7=A8=D7=9A=20=D7=9C=D7=A2=D7=A8=D7=99=D7=9B?= =?UTF-8?q?=D7=AA=20=D7=AA=D7=99=D7=90=D7=95=D7=A8=20=D7=9E=D7=AA=D7=A4?= =?UTF-8?q?=D7=A8=D7=99=D7=98=20=E2=8B=AE=20=D7=91=D7=A2=D7=9E=D7=95=D7=93?= =?UTF-8?q?=20=D7=94=D7=A7=D7=95=D7=91=D7=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit הוספת פריט "תיאור" בתפריט 3-הנקודות בעמוד הצפייה בקובץ (view_file), ראשון ברשימה, שפותח מודאל קטן לעריכת שדה התיאור — חוסך את הכניסה לעריכת קובץ מלאה רק כדי לשנות תיאור. "נעץ לדשבורד" עלה לשני, "שתף" לשלישי. - משתמש ב-endpoint הקיים POST /api/file//quick-update שמעדכן את התיאור in-place (מטא-דאטה, בלי גרסה חדשה) — אין קוד שרת חדש - מודאל בדפוס מודאל השיתוף הקיים (Escape, לחיצה על הרקע, טוסט הצלחה), עם textarea ומונה תווים (עד 500, תואם למגבלת ה-endpoint) - עדכון חי של התצוגה מתחת לשם ושל תווית התפריט בלי רענון עמוד - זמין לכל הקבצים (התיאור הוא מטא-דאטה אוניברסלי) - אין בעיית מודאל-בתוך-מודאל: התפריט (dropdown) נסגר לפני שהמודאל נפתח, בדיוק כמו "שתף קובץ" הקיים - שינוי template בלבד; דורש דיפלוי לוובאפ בדיקות: Jinja parse תקין, תחביר JS תקין; אין קוד Python שהשתנה. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- webapp/templates/view_file.html | 193 +++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 6 deletions(-) diff --git a/webapp/templates/view_file.html b/webapp/templates/view_file.html index 738bf5565..954945171 100644 --- a/webapp/templates/view_file.html +++ b/webapp/templates/view_file.html @@ -764,10 +764,9 @@

{{ file.file_name }}

- {% if file.description %} -

{{ file.description }}

- {% endif %} - + {# התיאור תמיד קיים ב-DOM (מוסתר כשריק) כדי שנוכל לעדכנו חי אחרי שמירה #} +

{{ file.description }}

+ {% if file.tags %}
{% for tag in file.tags %} @@ -871,8 +870,11 @@

{{ file.file_name }}

+{# מודאל עריכת תיאור — קיצור דרך לשדה התיאור בלי להיכנס לעריכת קובץ מלאה #} + + `; @@ -2036,8 +2052,8 @@ itemsContainer.querySelectorAll('.collection-cards').forEach(section => { wireDnd(section, collectionId); }); - // Auto fit text for both old and new card styles - autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + // Auto fit text for both old and new card styles + מיקום אייקון התיאור (Slot 1 ליד השם / Slot 2 בשורת הכפתורים) + layoutDescIcons(); autoFitText('#collectionItems .file', { minPx: 12, maxPx: 16 }); const loadMoreBtn = container.querySelector('.load-more'); if (loadMoreBtn) { @@ -2068,6 +2084,24 @@ ensureCollectionsSidebar(); container.innerHTML = '
האוסף נמחק. הקבצים נשארים זמינים בבוט ובמסך הקבצים.
'; }); + // ארכוב/שחזור אוסף — מנצל את PUT הקיים דרך שדה is_archived (כמו is_favorite) + const archiveBtn = container.querySelector('.collection-header .archive'); + const unarchiveBtn = container.querySelector('.collection-header .unarchive'); + if (archiveBtn) archiveBtn.addEventListener('click', async () => { + if (!confirm('להעביר את האוסף לארכיון? הוא ייעלם מהרשימה אך יישמר, וניתן לשחזר אותו דרך 🗄️ "הצג ארכיון".')) return; + const res = await api.updateCollection(collectionId, { is_archived: true }); + if (!res || !res.ok) return alert((res && res.error) || 'שגיאה בארכוב האוסף'); + ensureCollectionsSidebar(); + container.innerHTML = '
האוסף הועבר לארכיון. אפשר לשחזר אותו דרך 🗄️ "הצג ארכיון".
'; + }); + if (unarchiveBtn) unarchiveBtn.addEventListener('click', async () => { + const res = await api.updateCollection(collectionId, { is_archived: false }); + if (!res || !res.ok) return alert((res && res.error) || 'שגיאה בשחזור מהארכיון'); + // חוזרים לתצוגת האוספים הרגילים ומציגים את האוסף המשוחזר + showArchived = false; + ensureCollectionsSidebar(); + await renderCollectionItems(collectionId); + }); if (!isWorkspace && itemsContainer) { itemsContainer.addEventListener('click', async (ev) => { @@ -2863,13 +2897,15 @@ ${selectBox} ⋮⋮
-
- ${escapeHtml(fileName)} +
+ + ${item.description ? `` : ''}
${tagsHtml} ${item.note ? `📝 ${escapeHtml(item.note)}` : ''} - ${item.description ? `` : ''}
@@ -3539,6 +3575,42 @@ } catch(_e){ /* ignore */ } } + // מיקום אייקון התיאור בכרטיס אוסף: ברירת מחדל ליד השם (Slot 1); ואם השם ארוך + // ונשבר לשתי שורות — אין מקום בשורת השם, אז האייקון "קופץ" לשורת 4 הכפתורים, + // משמאל להם (RTL ⇒ ילד אחרון), עם רווח קטן (Slot 2). מנצל את זיהוי ה-is-wrapped + // של autoFitText, ולכן מודד את השם *ליד* האייקון (sibling ב-flex). + function layoutDescIcons(){ + try { + const cards = document.querySelectorAll('#collectionItems .collection-card'); + if (!cards || !cards.length) return; + // שלב 1: reset — כל אייקון חוזר ל-Slot 1 (מיד אחרי השם, בתוך ה-title-row) + cards.forEach((card) => { + const icon = card.querySelector('.desc-info'); + const titleRow = card.querySelector('.collection-card__title-row'); + if (icon && titleRow && icon.parentElement !== titleRow) { + titleRow.appendChild(icon); + } + }); + // שלב 2: התאמת גודל השם — מודד את הרוחב שנשאר *ליד* האייקון (הוא sibling ב-flex) + autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + // שלב 3: שם שנשבר לשתי שורות ⇒ אין מקום ⇒ האייקון עובר לשורת הכפתורים (ילד אחרון) + let moved = false; + cards.forEach((card) => { + const name = card.querySelector('.collection-card__name'); + const icon = card.querySelector('.desc-info'); + const actions = card.querySelector('.collection-card__actions'); + if (name && icon && actions && name.classList.contains('is-wrapped')) { + actions.appendChild(icon); + moved = true; + } + }); + // רה-פיט לשמות שהאייקון עזב (עכשיו ברוחב מלא) — רק אם באמת הזזנו משהו + if (moved) { + autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + } + } catch (_e) { /* ignore */ } + } + // תזמון/חסימה של ארועי resize כדי לא להציף חישובים function debounce(fn, wait){ let t = null; @@ -3562,7 +3634,7 @@ // עדכון אוטומטי בהתאמת חלון const onResize = throttle(() => { autoFitText('#collectionItems .file', { minPx: 12, maxPx: 16 }); - autoFitText('#collectionItems .collection-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); + layoutDescIcons(); autoFitText('#collectionsSidebar .sidebar-item .name', { minPx: 12, maxPx: 16 }); autoFitText('.workspace-card__name', { minPx: 12, maxPx: 16, allowWrap: true }); }, 150); From 991b1ad24c335dc0b1f869083dac451729c50a13 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 19:16:27 +0000 Subject: [PATCH 06/22] =?UTF-8?q?fix(webapp):=20=D7=94=D7=9E=D7=A8=D7=AA?= =?UTF-8?q?=20=D7=A6=D7=91=D7=A2=D7=99=D7=9D=20=D7=A7=D7=A9=D7=99=D7=97?= =?UTF-8?q?=D7=99=D7=9D=20=D7=9C=D7=98=D7=95=D7=A7=D7=A0=D7=99=20=D7=A2?= =?UTF-8?q?=D7=A8=D7=9B=D7=94=20=D7=91=D7=9E=D7=95=D7=93=D7=90=D7=9C=20?= =?UTF-8?q?=D7=94=D7=AA=D7=99=D7=90=D7=95=D7=A8=20=D7=95=D7=91=D7=9B=D7=A4?= =?UTF-8?q?=D7=AA=D7=95=D7=A8=20=D7=94=D7=90=D7=A8=D7=9B=D7=99=D7=95=D7=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit לפי theming_and_css.rst אסורים צבעים קשיחים בקבצי רכיבים — רק var(--token). שתי חריגות הומרו: - מודאל "ערוך תיאור" (view_file.html): הרקע הכהה הקבוע (#1f2a44, #fff, rgba לבנים, focus בצבע primary קשיח) הוחלף בטוקנים סמנטיים — bg-secondary/tertiary, text-primary/secondary/muted, glass-border, primary; ה-scrim וה-shadow קיבלו טוקן-רכיב עם fallback (var(--modal-backdrop, ...), var(--solid-surface-shadow, ...)). כך המודאל מקבל את צבעי הערכה גם בערכות בהירות (rose-pine-dawn, classic). - כפתור "הצג ארכיון" במצב לחוץ (collections.css): rgba לבנים קשיחים הוחלפו בטוקני glass קיימים (glass-hover/glass-border/glass). מודאל ה-ℹ️ באוספים נבדק ונמצא תקין (יורש var(--collections-modal-*)) — ללא שינוי. מודאל השיתוף הסמוך הוא legacy קיים ולא נכלל (חוב נפרד). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- webapp/static/css/collections.css | 4 ++-- webapp/templates/view_file.html | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/webapp/static/css/collections.css b/webapp/static/css/collections.css index 8cd1ba59c..8b70a8812 100644 --- a/webapp/static/css/collections.css +++ b/webapp/static/css/collections.css @@ -347,8 +347,8 @@ .sidebar-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem} .sidebar-header .title{font-weight:700} .sidebar-header__actions{display:flex;align-items:center;gap:.35rem} -/* כפתור "הצג ארכיון" במצב פעיל (aria-pressed) — מודגש כדי לסמן שאנחנו בתצוגת ארכיון */ -#toggleArchivedBtn[aria-pressed="true"],#toggleArchivedBtn.is-active{background:rgba(255,255,255,.2);border-color:rgba(255,255,255,.45);box-shadow:0 0 0 1px rgba(255,255,255,.1) inset} +/* כפתור "הצג ארכיון" במצב פעיל (aria-pressed) — מודגש דרך טוקני glass (בלי צבעים קשיחים) */ +#toggleArchivedBtn[aria-pressed="true"],#toggleArchivedBtn.is-active{background:var(--glass-hover);border-color:var(--glass-border);box-shadow:0 0 0 1px var(--glass) inset} .sidebar-search{margin:.5rem 0} .sidebar-search input{width:100%;padding:.5rem .6rem;border-radius:8px;border:1px solid var(--glass-border);background:rgba(255,255,255,.1);color:var(--text-primary)} .sidebar-list{display:flex;flex-direction:column;gap:.25rem;max-height:60vh;overflow:auto;scrollbar-width:thin} diff --git a/webapp/templates/view_file.html b/webapp/templates/view_file.html index 954945171..401062a14 100644 --- a/webapp/templates/view_file.html +++ b/webapp/templates/view_file.html @@ -1166,28 +1166,28 @@

🗑️ העבר לסל

color: #fff; } -/* מודאל עריכת תיאור — מחקה את מודאל השיתוף, עם textarea ומונה תווים */ +/* מודאל עריכת תיאור — כמו מודאל השיתוף אך על טוקני ערכה (theming_and_css.rst: בלי צבעים קשיחים) */ .desc-modal { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.55); + background: var(--modal-backdrop, rgba(0, 0, 0, 0.55)); z-index: 9999; } .desc-modal[hidden] { display: none; } .desc-modal__content { - background: #1f2a44; + background: var(--bg-secondary); border-radius: 16px; padding: 1.5rem; max-width: 460px; width: calc(100% - 2rem); - color: #fff; + color: var(--text-primary); position: relative; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); + box-shadow: var(--solid-surface-shadow, 0 12px 30px rgba(0, 0, 0, 0.35)); } .desc-modal__title { margin: 0 0 0.5rem; @@ -1195,15 +1195,15 @@

🗑️ העבר לסל

} .desc-modal__subtitle { margin: 0 0 1rem; - color: rgba(255, 255, 255, 0.8); + color: var(--text-secondary); } .desc-modal__textarea { width: 100%; box-sizing: border-box; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.2); + background: var(--bg-tertiary); + border: 1px solid var(--glass-border); border-radius: 10px; - color: #fff; + color: var(--text-primary); padding: 0.75rem; font-size: 1rem; line-height: 1.5; @@ -1212,12 +1212,12 @@

🗑️ העבר לסל

} .desc-modal__textarea:focus { outline: none; - border-color: rgba(102, 126, 234, 0.8); + border-color: var(--primary); } .desc-modal__counter { text-align: left; font-size: 0.85rem; - color: rgba(255, 255, 255, 0.6); + color: var(--text-muted); margin: 0.35rem 0 1rem; } .desc-modal__actions { @@ -1234,13 +1234,13 @@

🗑️ העבר לסל

left: 0.75rem; background: transparent; border: none; - color: rgba(255, 255, 255, 0.7); + color: var(--text-secondary); font-size: 1.1rem; cursor: pointer; } .desc-modal__close:hover, .desc-modal__close:focus { - color: #fff; + color: var(--text-primary); } .glass-modal { From c7c49a3d8faca3ef58102c5e7af0fb2f67acb1da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 19:21:12 +0000 Subject: [PATCH 07/22] =?UTF-8?q?fix(bot):=20=D7=AA=D7=99=D7=A7=D7=95?= =?UTF-8?q?=D7=9F=20=D7=9E=D7=A1=D7=A4=D7=A8=20=D7=A7=D7=91=D7=A6=D7=99?= =?UTF-8?q?=D7=9D=20=D7=91=D7=94=D7=95=D7=93=D7=A2=D7=95=D7=AA=20ZIP=20+?= =?UTF-8?q?=20=D7=A9=D7=9C=D7=91=20=D7=91=D7=97=D7=99=D7=A8=D7=AA=20=D7=A9?= =?UTF-8?q?=D7=9D=20=D7=9C-ZIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit באג שורש: המחרוזת `סה""כ` פיצלה f-strings לשתי מחרוזות סמוכות — השנייה (בלי קידומת f) הציגה placeholders כטקסט מילולי (למשל "{len(items)}"). תוקן ב-3 מקומות (handlers/documents.py, conversation_handlers.py, handlers/save_flow.py) ע"י מעבר ל-f-string רציף אחד עם מרכאות בודדות. כך "✅ נוסף: X (סה"כ N קבצים)" מציג את המספר בפועל, וכן כותרת רשימת ה-ZIP השמורים ומסך איסוף הקוד הארוך. פיצ'ר: שלב בחירת שם ל-ZIP. אחרי "✅ סיום" הבוט מבקש שם (או "⏭️ דלג" לשם אוטומטי): - conversation_handlers.py: helper משותף finalize_zip_create + _cleanup_zip_state; zip_create_finish מציב awaiting_zip_name ומבקש שם; callback חדש zip_create_skip_name. - main.py: hook בראש handle_text_message שתופס את השם (נבדק ראשון כדי שלא ייבלע/ייחשב קוד). - שם מנוקה דרך TextUtils.clean_filename + סיומת .zip; fallback ל-my-files-.zip. טסט: חיזוק test_handle_document_collects_zip_items לאימות שהמספר מוצג בפועל (הגנת רגרסיה). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- conversation_handlers.py | 103 +++++++++++++++++++++++-------- handlers/documents.py | 2 +- handlers/save_flow.py | 2 +- main.py | 10 +++ tests/handlers/test_documents.py | 5 ++ 5 files changed, 93 insertions(+), 29 deletions(-) diff --git a/conversation_handlers.py b/conversation_handlers.py index 50a469869..b87290067 100644 --- a/conversation_handlers.py +++ b/conversation_handlers.py @@ -870,6 +870,59 @@ async def start_zip_create_flow(update: Update, context: ContextTypes.DEFAULT_TY pass return ConversationHandler.END + +def _cleanup_zip_state(context: ContextTypes.DEFAULT_TYPE) -> None: + """מנקה את דגלי מצב יצירת ה‑ZIP (אחרי סיום/ביטול/בחירת שם).""" + for key in ('upload_mode', 'zip_create_items', 'awaiting_zip_name'): + context.user_data.pop(key, None) + + +async def finalize_zip_create(update: Update, context: ContextTypes.DEFAULT_TYPE, zip_name: Optional[str] = None) -> None: + """בונה ZIP מהקבצים שנאספו ושולח למשתמש. + + אם zip_name ניתן — משתמשים בו לאחר ניקוי (TextUtils.clean_filename) והבטחת סיומת .zip; + אחרת נופלים לשם ברירת המחדל my-files-.zip. + """ + items = context.user_data.get('zip_create_items') or [] + msg = update.effective_message + if not items: + if msg is not None: + await msg.reply_text("ℹ️ לא נאספו קבצים. שלח/י קבצים ואז נסה שוב.") + _cleanup_zip_state(context) + return + try: + from io import BytesIO as _BytesIO + import zipfile as _zip + buf = _BytesIO() + with _zip.ZipFile(buf, 'w', compression=_zip.ZIP_DEFLATED) as z: + for it in items: + try: + z.writestr(it.get('filename') or 'file', it.get('bytes') or b'') + except Exception: + pass + buf.seek(0) + # קביעת שם ה‑ZIP: שם מהמשתמש (מנוקה) או ברירת מחדל לפי חותמת זמן + default_base = f"my-files-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" + base = '' + if zip_name: + try: + from utils import TextUtils + base = (TextUtils.clean_filename(zip_name) or '').strip() + except Exception: + base = '' + if base.lower().endswith('.zip'): + base = base[:-4] + safe_name = f"{base or default_base}.zip" + await msg.reply_document(document=buf, filename=safe_name) + await msg.reply_text(f'✅ נוצר ZIP "{safe_name}" עם {len(items)} קבצים ונשלח אליך.') + except Exception as e: + logger.exception(f"finalize_zip_create failed: {e}") + if msg is not None: + await msg.reply_text(f"❌ שגיאה ביצירת ה‑ZIP: {e}") + finally: + _cleanup_zip_state(context) + + async def show_by_repo_menu(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int: """מציג תפריט קבוצות לפי תגיות ריפו ומאפשר בחירה.""" user_id = update.effective_user.id @@ -3749,32 +3802,28 @@ async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TY ) return ConversationHandler.END elif data == "zip_create_finish": - # בניית ZIP מהקבצים שנאספו ושליחה למשתמש - try: - items = context.user_data.get('zip_create_items') or [] - if not items: - await query.edit_message_text("ℹ️ לא נאספו קבצים. שלח/י קבצים ואז נסה שוב.") - return ConversationHandler.END - from io import BytesIO as _BytesIO - import zipfile as _zip - buf = _BytesIO() - with _zip.ZipFile(buf, 'w', compression=_zip.ZIP_DEFLATED) as z: - for it in items: - # it: {"filename": str, "bytes": bytes} - try: - z.writestr(it.get('filename') or 'file', it.get('bytes') or b'') - except Exception: - pass - buf.seek(0) - safe_name = f"my-files-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.zip" - await query.message.reply_document(document=buf, filename=safe_name) - await query.edit_message_text(f"✅ נוצר ZIP עם {len(items)} קבצים ונשלח אליך.") - except Exception as e: - logger.exception(f"zip_create_finish failed: {e}") - await query.edit_message_text(f"❌ שגיאה ביצירת ה‑ZIP: {e}") - finally: - context.user_data.pop('upload_mode', None) - context.user_data.pop('zip_create_items', None) + # לפני יצירת ה‑ZIP — מבקשים מהמשתמש שם (או דילוג לשם אוטומטי) + items = context.user_data.get('zip_create_items') or [] + if not items: + await query.edit_message_text("ℹ️ לא נאספו קבצים. שלח/י קבצים ואז נסה שוב.") + _cleanup_zip_state(context) + return ConversationHandler.END + context.user_data['awaiting_zip_name'] = True + # לא אוספים עוד קבצים בזמן שממתינים לשם + context.user_data.pop('upload_mode', None) + kb = [[InlineKeyboardButton("⏭️ דלג (שם אוטומטי)", callback_data="zip_create_skip_name")]] + await query.edit_message_text( + "✍️ איך לקרוא ל‑ZIP?\n" + "שלח/י שם (בלי הסיומת .zip), או לחצ/י דלג לשם אוטומטי.\n" + f"📦 {len(items)} קבצים ייכללו.", + reply_markup=InlineKeyboardMarkup(kb), + ) + return ConversationHandler.END + elif data == "zip_create_skip_name": + # יצירת ZIP עם שם ברירת מחדל (דילוג על בחירת שם) + context.user_data.pop('awaiting_zip_name', None) + await query.edit_message_text("⏳ יוצר ZIP…") + await finalize_zip_create(update, context, zip_name=None) return ConversationHandler.END elif data.startswith("replace_") or data == "rename_file" or data == "cancel_save": return await handle_duplicate_callback(update, context) @@ -4768,7 +4817,7 @@ async def show_batch_zips_menu(update: Update, context: ContextTypes.DEFAULT_TYP end = min(start + PAGE_SIZE, total) items = backups[start:end] - lines = [f"📦 קבצי ZIP שמורים — סה""כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n"] + lines = [f'📦 קבצי ZIP שמורים — סה"כ: {total}\n📄 עמוד {page} מתוך {total_pages}\n'] keyboard = [] # חישוב גרסאות vN לפי ריפו repo_to_sorted: Dict[str, list] = {} diff --git a/handlers/documents.py b/handlers/documents.py index cc6aeaae0..7878949c4 100644 --- a/handlers/documents.py +++ b/handlers/documents.py @@ -850,7 +850,7 @@ async def _handle_zip_create(self, update: Update, context: ContextTypes.DEFAULT safe_name = (document.file_name or f"file_{len(items)+1}").strip() or f"file_{len(items)+1}" items.append({"filename": safe_name, "bytes": raw}) await update.message.reply_text( - f"✅ נוסף: {html_escape(safe_name)} (סה""כ {len(items)} קבצים)", + f'✅ נוסף: {html_escape(safe_name)} (סה"כ {len(items)} קבצים)', parse_mode=ParseMode.HTML, ) except Exception as err: diff --git a/handlers/save_flow.py b/handlers/save_flow.py index f77698cb5..39818a4b0 100644 --- a/handlers/save_flow.py +++ b/handlers/save_flow.py @@ -323,7 +323,7 @@ async def long_collect_timeout_job(context: ContextTypes.DEFAULT_TYPE): chat_id=chat_id, text=( f"⏳ מצב איסוף נסגר לאחר חוסר פעילות.\n" - f"✅ נאספו {len(parts)} חלקים (סה""כ ~{total_bytes // 1024}KB).\n" + f'✅ נאספו {len(parts)} חלקים (סה"כ ~{total_bytes // 1024}KB).\n' f"שלח/י /done לאיחוד לקובץ אחד או /cancel לביטול." ) ) diff --git a/main.py b/main.py index 1e91a1435..7603f5bf5 100644 --- a/main.py +++ b/main.py @@ -4472,6 +4472,16 @@ async def handle_text_message(self, update: Update, context: ContextTypes.DEFAUL return text = message_text + # מצב "בחירת שם ל-ZIP" — הופעל אחרי לחיצה על 'סיום' בזרימת יצירת ZIP. + # נבדק ראשון כדי שהשם לא ייבלע/ייחשב כקוד (הטקסט נופל לכאן במצב הזה). + if context.user_data.pop('awaiting_zip_name', False): + try: + from conversation_handlers import finalize_zip_create + await finalize_zip_create(update, context, zip_name=text) + except Exception as _zip_err: + logger.exception("zip name finalize failed: %s", _zip_err) + return + # מצב חיפוש אינטראקטיבי (מופעל מהכפתור "🔎 חפש קובץ") if context.user_data.get('awaiting_search_text'): query_text = (text or '').strip() diff --git a/tests/handlers/test_documents.py b/tests/handlers/test_documents.py index 323cc7a9b..7b9501e8e 100644 --- a/tests/handlers/test_documents.py +++ b/tests/handlers/test_documents.py @@ -234,6 +234,11 @@ async def test_handle_document_collects_zip_items(handler_env): items = context.user_data.get("zip_create_items") assert items and items[0]["filename"] == "bundle.txt" assert replies.messages, "צפויה הודעה על הוספת הפריט ל-ZIP" + # הגנה מפני באג f-string השבור (סה""כ): המספר חייב להופיע בפועל, לא כטקסט מילולי + reply_text = replies.messages[0][0] + assert "1 קבצים" in reply_text, "ההודעה חייבת להציג את מספר הקבצים בפועל" + assert 'סה"כ' in reply_text, "הטקסט 'סה\"כ' חייב להופיע תקין" + assert "{len(items)}" not in reply_text, "אסור ש-{len(items)} יופיע כטקסט מילולי" @pytest.mark.asyncio From fd88aa07f929ba5b18b717dd51f281cce591035b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:15:08 +0000 Subject: [PATCH 08/22] =?UTF-8?q?fix(bot):=20=D7=94=D7=A7=D7=A9=D7=97?= =?UTF-8?q?=D7=AA=20=D7=99=D7=A6=D7=99=D7=A8=D7=AA=20ZIP=20=E2=80=94=20?= =?UTF-8?q?=D7=A0=D7=99=D7=A7=D7=95=D7=99=20=D7=A9=D7=9E=D7=95=D7=AA=20(Zi?= =?UTF-8?q?p-Slip),=20=D7=9E=D7=92=D7=91=D7=9C=D7=95=D7=AA=20=D7=90=D7=99?= =?UTF-8?q?=D7=A1=D7=95=D7=A3,=20to=5Fthread,=20=D7=9E=D7=A1=D7=9C=D7=95?= =?UTF-8?q?=D7=9C=20=D7=91=D7=99=D7=98=D7=95=D7=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מענה ל-review findings ב-PR #3194: - ניקוי שם רשומת ZIP (utils.safe_zip_entry_name): basename בלבד, דחיית נתיב מוחלט/ מקונן ו-"."/".." — הגנת Zip-Slip. משמש בבניית הארכיון במקום השם הגולמי. - בניית ה-ZIP חולצה ל-utils.build_zip_bytes (טהור) ורצה תחת asyncio.to_thread כדי לא לחסום את לולאת האירועים (בהתאם לכלל ה-Performance ב-CLAUDE.md). - אכיפת מגבלות איסוף (ZIP_CREATE_MAX_FILES=50, ZIP_CREATE_MAX_TOTAL_BYTES=45MB) בזמן צבירת הקבצים ב-handlers/documents.py, עם הגנה כפולה גם בשלב הבנייה. - מסלול ביטול במצב "המתנה לשם": zip_create_cancel עובר דרך _cleanup_zip_state (מנקה גם awaiting_zip_name), ונוסף כפתור "❌ ביטול" למקלדת בקשת השם — כך שמשתמש שמתחרט לא ישלח ארכיון בטעות בהודעת טקסט כלשהי. - טסטים: tests/test_zip_bundle_utils.py (ניקוי שמות + מגבלות, נבדק ע"י פענוח ה-ZIP). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- conversation_handlers.py | 25 +++++------ handlers/documents.py | 14 +++++++ tests/test_zip_bundle_utils.py | 77 ++++++++++++++++++++++++++++++++++ utils.py | 52 +++++++++++++++++++++++ 4 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 tests/test_zip_bundle_utils.py diff --git a/conversation_handlers.py b/conversation_handlers.py index b87290067..1675f6f0a 100644 --- a/conversation_handlers.py +++ b/conversation_handlers.py @@ -892,21 +892,16 @@ async def finalize_zip_create(update: Update, context: ContextTypes.DEFAULT_TYPE return try: from io import BytesIO as _BytesIO - import zipfile as _zip - buf = _BytesIO() - with _zip.ZipFile(buf, 'w', compression=_zip.ZIP_DEFLATED) as z: - for it in items: - try: - z.writestr(it.get('filename') or 'file', it.get('bytes') or b'') - except Exception: - pass + from utils import build_zip_bytes, TextUtils + # בניית ה-ZIP (סינכרונית/כבדה) מחוץ ל-event loop — עם ניקוי שמות (Zip-Slip) ואכיפת מגבלות + zip_bytes = await asyncio.to_thread(build_zip_bytes, items) + buf = _BytesIO(zip_bytes) buf.seek(0) # קביעת שם ה‑ZIP: שם מהמשתמש (מנוקה) או ברירת מחדל לפי חותמת זמן default_base = f"my-files-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}" base = '' if zip_name: try: - from utils import TextUtils base = (TextUtils.clean_filename(zip_name) or '').strip() except Exception: base = '' @@ -3792,9 +3787,8 @@ async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TY pass return next_state elif data == "zip_create_cancel": - # ביטול מצב יצירת ZIP בלבד - context.user_data.pop('upload_mode', None) - context.user_data.pop('zip_create_items', None) + # ביטול מצב יצירת ZIP (כולל מצב המתנה לשם) — ניקוי מלא של הדגלים כולל awaiting_zip_name + _cleanup_zip_state(context) await query.edit_message_text("🚫 יצירת ה‑ZIP בוטלה.") await query.message.reply_text( "🎮 בחר פעולה מתקדמת:", @@ -3811,10 +3805,13 @@ async def handle_callback_query(update: Update, context: ContextTypes.DEFAULT_TY context.user_data['awaiting_zip_name'] = True # לא אוספים עוד קבצים בזמן שממתינים לשם context.user_data.pop('upload_mode', None) - kb = [[InlineKeyboardButton("⏭️ דלג (שם אוטומטי)", callback_data="zip_create_skip_name")]] + kb = [ + [InlineKeyboardButton("⏭️ דלג (שם אוטומטי)", callback_data="zip_create_skip_name")], + [InlineKeyboardButton("❌ ביטול", callback_data="zip_create_cancel")], + ] await query.edit_message_text( "✍️ איך לקרוא ל‑ZIP?\n" - "שלח/י שם (בלי הסיומת .zip), או לחצ/י דלג לשם אוטומטי.\n" + "שלח/י שם (בלי הסיומת .zip), או לחצ/י דלג לשם אוטומטי (או ביטול).\n" f"📦 {len(items)} קבצים ייכללו.", reply_markup=InlineKeyboardMarkup(kb), ) diff --git a/handlers/documents.py b/handlers/documents.py index 7878949c4..683525c07 100644 --- a/handlers/documents.py +++ b/handlers/documents.py @@ -847,6 +847,20 @@ async def _handle_zip_create(self, update: Update, context: ContextTypes.DEFAULT if items is None: items = [] context.user_data["zip_create_items"] = items + # אכיפת מגבלות איסוף לפני צבירה בזיכרון (מספר קבצים + גודל מצטבר) — הגנה מ-DoS/זיכרון + from utils import ZIP_CREATE_MAX_FILES, ZIP_CREATE_MAX_TOTAL_BYTES + if len(items) >= ZIP_CREATE_MAX_FILES: + await update.message.reply_text( + f"⚠️ הגעת למקסימום {ZIP_CREATE_MAX_FILES} קבצים ל-ZIP. לחצ/י 'סיום' כדי ליצור." + ) + return + current_total = sum(len(it.get("bytes") or b"") for it in items) + if current_total + len(raw) > ZIP_CREATE_MAX_TOTAL_BYTES: + limit_mb = ZIP_CREATE_MAX_TOTAL_BYTES // (1024 * 1024) + await update.message.reply_text( + f"⚠️ הקובץ לא נוסף — חריגה מהמגבלה של {limit_mb}MB לכלל ה-ZIP." + ) + return safe_name = (document.file_name or f"file_{len(items)+1}").strip() or f"file_{len(items)+1}" items.append({"filename": safe_name, "bytes": raw}) await update.message.reply_text( diff --git a/tests/test_zip_bundle_utils.py b/tests/test_zip_bundle_utils.py new file mode 100644 index 000000000..046640c86 --- /dev/null +++ b/tests/test_zip_bundle_utils.py @@ -0,0 +1,77 @@ +"""טסטים לכלי בניית ה-ZIP המרוכז ב-utils (הגנת Zip-Slip + מגבלות איסוף). + +חשוב אבטחתית: safe_zip_entry_name מנרמל שמות רשומה לשם בסיס בטוח (בלי נתיב מוחלט/ +מקונן/"..") ו-build_zip_bytes אוכף מגבלת מספר קבצים וגודל מצטבר. נבדק ע"י פענוח ה-ZIP. +""" + +import zipfile +from io import BytesIO + +from utils import ( + safe_zip_entry_name, + build_zip_bytes, + ZIP_CREATE_MAX_FILES, + ZIP_CREATE_MAX_TOTAL_BYTES, +) + + +def _names(zip_bytes): + with zipfile.ZipFile(BytesIO(zip_bytes)) as z: + return z.namelist() + + +def test_safe_zip_entry_name_strips_paths(): + # basename בלבד — שום רכיב נתיב לא שורד + assert safe_zip_entry_name("../../etc/passwd") == "passwd" + assert safe_zip_entry_name("/abs/path/x.py") == "x.py" + assert safe_zip_entry_name("a\\b\\c.txt") == "c.txt" + assert safe_zip_entry_name("plain.md") == "plain.md" + # שמות מסוכנים "טהורים" נופלים ל-fallback + assert safe_zip_entry_name("..", fallback="f") == "f" + assert safe_zip_entry_name(".", fallback="f") == "f" + assert safe_zip_entry_name("", fallback="f") == "f" + assert safe_zip_entry_name(None, fallback="f") == "f" + + +def test_build_zip_sanitizes_entry_names(): + items = [ + {"filename": "../../etc/passwd", "bytes": b"x"}, + {"filename": "/abs/evil.sh", "bytes": b"y"}, + {"filename": "ok.txt", "bytes": b"z"}, + ] + names = _names(build_zip_bytes(items)) + # אף רשומה ללא מפריד נתיב או ".." + assert all("/" not in n and "\\" not in n and ".." not in n for n in names) + assert not any(n.startswith("/") for n in names) + assert set(names) == {"passwd", "evil.sh", "ok.txt"} + + +def test_build_zip_enforces_max_files(): + items = [{"filename": f"f{i}.txt", "bytes": b"x"} for i in range(10)] + names = _names(build_zip_bytes(items, max_files=3)) + assert len(names) == 3 + + +def test_build_zip_enforces_max_total_bytes(): + items = [ + {"filename": "a.bin", "bytes": b"0" * 600}, + {"filename": "b.bin", "bytes": b"1" * 600}, # 600+600=1200 > 1000 ⇒ נעצר לפני + {"filename": "c.bin", "bytes": b"2" * 600}, + ] + names = _names(build_zip_bytes(items, max_total_bytes=1000)) + assert names == ["a.bin"] + + +def test_build_zip_skips_missing_names_with_fallback(): + items = [ + {"filename": None, "bytes": b"content"}, # שם חסר ⇒ fallback file_1 + {"bytes": b"nofield"}, # ללא מפתח filename ⇒ fallback file_2 + ] + names = _names(build_zip_bytes(items)) + assert len(names) == 2 + assert all(n for n in names) # אין שם ריק + + +def test_defaults_are_sane(): + assert ZIP_CREATE_MAX_FILES == 50 + assert ZIP_CREATE_MAX_TOTAL_BYTES == 45 * 1024 * 1024 diff --git a/utils.py b/utils.py index f883c241a..dc730984d 100644 --- a/utils.py +++ b/utils.py @@ -1580,3 +1580,55 @@ def _should_keep(ch: str) -> bool: except Exception: # במקרה של שגיאה, החזר את הטקסט המקורי return text + + +# ----- כלי יצירת ZIP מרוכז (בטוח מפני Zip-Slip + חסום-מגבלות, טהור וניתן להרצה ב-thread) ----- + +# מגבלות זרימת "יצירת ZIP" בבוט (מספר קבצים וגודל מצטבר) — הגנה מפני צריכת זיכרון/DoS +ZIP_CREATE_MAX_FILES = 50 +ZIP_CREATE_MAX_TOTAL_BYTES = 45 * 1024 * 1024 # 45MB (מתחת למגבלת שליחת document של טלגרם) + + +def safe_zip_entry_name(name, fallback: str = "file") -> str: + """מחזיר שם רשומת ZIP בטוח: basename בלבד, ללא נתיב מוחלט/מקונן/‏‎".."/".‎"; אחרת fallback. + + מונע Zip-Slip: שמות כמו '../../etc/passwd' או '/abs/x' מנורמלים לשם בסיס בטוח. + """ + raw = str(name or "").replace("\\", "/") + base = os.path.basename(raw) # מסיר כל רכיב נתיב (absolute/nested) + cleaned = TextUtils.clean_filename(base) # מסיר תווים אסורים + נקודות מובילות/סוגרות + if not cleaned or cleaned in (".", ".."): + return fallback + return cleaned + + +def build_zip_bytes(items, *, max_files: int = ZIP_CREATE_MAX_FILES, + max_total_bytes: int = ZIP_CREATE_MAX_TOTAL_BYTES) -> bytes: + """בונה ZIP (סינכרוני, טהור) מרשימת פריטים [{'filename': str, 'bytes': bytes}]. + + - מנקה כל שם רשומה דרך safe_zip_entry_name (הגנת Zip-Slip). + - אוכף מגבלת מספר קבצים וגודל מצטבר (הגנה כפולה מעבר לאיסוף). + - מדלג בשקט על פריט בודד שנכשל (שומר על ההתנהגות הקיימת). + מיועד להרצה תחת asyncio.to_thread כדי לא לחסום את לולאת האירועים. + """ + from io import BytesIO + buf = BytesIO() + total = 0 + count = 0 + with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z: + for i, it in enumerate(items or []): + if count >= max_files: + break + try: + data = it.get("bytes") or b"" + if total + len(data) > max_total_bytes: + break + entry = safe_zip_entry_name(it.get("filename"), fallback=f"file_{i + 1}") + z.writestr(entry, data) + total += len(data) + count += 1 + except Exception: + # שמירה על ההתנהגות הקיימת: פריט בעייתי מדולג ולא מפיל את כל ה-ZIP + continue + buf.seek(0) + return buf.getvalue() From 85bcfa91613c368c32f45239bfc636e962e031fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 22:34:18 +0000 Subject: [PATCH 09/22] =?UTF-8?q?fix(bot):=20=D7=94=D7=A7=D7=A9=D7=97?= =?UTF-8?q?=D7=AA=20ZIP=20=D7=A1=D7=91=D7=91=202=20=E2=80=94=20=D7=9E?= =?UTF-8?q?=D7=92=D7=91=D7=9C=D7=95=D7=AA=20=D7=9C=D7=A4=D7=A0=D7=99=20?= =?UTF-8?q?=D7=94=D7=95=D7=A8=D7=93=D7=94,=20=D7=9E=D7=A0=D7=99=D7=A2?= =?UTF-8?q?=D7=AA=20=D7=A9=D7=9E=D7=95=D7=AA=20=D7=9B=D7=A4=D7=95=D7=9C?= =?UTF-8?q?=D7=99=D7=9D,=20=D7=AA=D7=99=D7=A7=D7=95=D7=9F=20docstring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מענה ל-review (סבב 2) ב-PR #3194: - handlers/documents.py: בדיקות מגבלת ה-ZIP (מספר קבצים + גודל לפי document.file_size) הוזזו לפני get_file()/download_to_memory() — לא מורידים לזיכרון קובץ שנדחה מראש. בדיקת len(raw) נשמרה כאימות סופי לפער אפשרי מול הגודל המוצהר. - utils.build_zip_bytes: מניעת שמות רשומה כפולים — הראשון נשמר, הבאים מקבלים סיומת ממספרת (x.txt, x_2.txt) עם שמירת הסיומת. - utils: תיקון docstring (שורה ריקה אחרי רשימת ה-bullets) — מבטל אזהרת docutils/RTD. - tests: חיזוק test_...skips (file_1/file_2 מדויק) + regression לכפילות שמות. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- handlers/documents.py | 20 ++++++++++++++------ tests/test_zip_bundle_utils.py | 15 +++++++++++++-- utils.py | 12 ++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/handlers/documents.py b/handlers/documents.py index 683525c07..1a7cbe80a 100644 --- a/handlers/documents.py +++ b/handlers/documents.py @@ -839,24 +839,32 @@ async def _handle_zip_create(self, update: Update, context: ContextTypes.DEFAULT document.file_name, document.file_size, ) - file = await context.bot.get_file(document.file_id) - buf = BytesIO() - await file.download_to_memory(buf) - raw = buf.getvalue() items = context.user_data.get("zip_create_items") if items is None: items = [] context.user_data["zip_create_items"] = items - # אכיפת מגבלות איסוף לפני צבירה בזיכרון (מספר קבצים + גודל מצטבר) — הגנה מ-DoS/זיכרון + # אכיפת מגבלות מוקדמת — לפני ההורדה לזיכרון (הגנה מ-DoS/זיכרון): מספר קבצים + גודל מוצהר from utils import ZIP_CREATE_MAX_FILES, ZIP_CREATE_MAX_TOTAL_BYTES + limit_mb = ZIP_CREATE_MAX_TOTAL_BYTES // (1024 * 1024) if len(items) >= ZIP_CREATE_MAX_FILES: await update.message.reply_text( f"⚠️ הגעת למקסימום {ZIP_CREATE_MAX_FILES} קבצים ל-ZIP. לחצ/י 'סיום' כדי ליצור." ) return current_total = sum(len(it.get("bytes") or b"") for it in items) + incoming_size = int(getattr(document, "file_size", 0) or 0) + if current_total + incoming_size > ZIP_CREATE_MAX_TOTAL_BYTES: + await update.message.reply_text( + f"⚠️ הקובץ לא נוסף — חריגה מהמגבלה של {limit_mb}MB לכלל ה-ZIP." + ) + return + # הורדה רק לאחר שהמגבלות המוקדמות עברו + file = await context.bot.get_file(document.file_id) + buf = BytesIO() + await file.download_to_memory(buf) + raw = buf.getvalue() + # אימות סופי לפי הגודל בפועל (למקרה של פער מול file_size המוצהר) if current_total + len(raw) > ZIP_CREATE_MAX_TOTAL_BYTES: - limit_mb = ZIP_CREATE_MAX_TOTAL_BYTES // (1024 * 1024) await update.message.reply_text( f"⚠️ הקובץ לא נוסף — חריגה מהמגבלה של {limit_mb}MB לכלל ה-ZIP." ) diff --git a/tests/test_zip_bundle_utils.py b/tests/test_zip_bundle_utils.py index 046640c86..10ec7e70b 100644 --- a/tests/test_zip_bundle_utils.py +++ b/tests/test_zip_bundle_utils.py @@ -68,8 +68,19 @@ def test_build_zip_skips_missing_names_with_fallback(): {"bytes": b"nofield"}, # ללא מפתח filename ⇒ fallback file_2 ] names = _names(build_zip_bytes(items)) - assert len(names) == 2 - assert all(n for n in names) # אין שם ריק + assert names == ["file_1", "file_2"] + + +def test_build_zip_disambiguates_duplicate_names(): + # מקורות שונים שמנורמלים לאותו שם — לא נדרסים; הבאים מקבלים סיומת ממספרת עם שמירת הסיומת + items = [ + {"filename": "a/x.txt", "bytes": b"1"}, + {"filename": "b/x.txt", "bytes": b"2"}, + {"filename": "c/x.txt", "bytes": b"3"}, + ] + names = _names(build_zip_bytes(items)) + assert names == ["x.txt", "x_2.txt", "x_3.txt"] + assert len(set(names)) == 3 def test_defaults_are_sane(): diff --git a/utils.py b/utils.py index dc730984d..d54aa4bfe 100644 --- a/utils.py +++ b/utils.py @@ -1608,13 +1608,16 @@ def build_zip_bytes(items, *, max_files: int = ZIP_CREATE_MAX_FILES, - מנקה כל שם רשומה דרך safe_zip_entry_name (הגנת Zip-Slip). - אוכף מגבלת מספר קבצים וגודל מצטבר (הגנה כפולה מעבר לאיסוף). + - שמות רשומה כפולים מקבלים סיומת ממספרת (x.txt, x_2.txt) לשמירת ייחודיות. - מדלג בשקט על פריט בודד שנכשל (שומר על ההתנהגות הקיימת). + מיועד להרצה תחת asyncio.to_thread כדי לא לחסום את לולאת האירועים. """ from io import BytesIO buf = BytesIO() total = 0 count = 0 + used = set() with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as z: for i, it in enumerate(items or []): if count >= max_files: @@ -1624,6 +1627,15 @@ def build_zip_bytes(items, *, max_files: int = ZIP_CREATE_MAX_FILES, if total + len(data) > max_total_bytes: break entry = safe_zip_entry_name(it.get("filename"), fallback=f"file_{i + 1}") + # מניעת שמות כפולים: משמרים את הראשון, ולבאים מוסיפים סיומת ממספרת (עם שמירת הסיומת) + if entry in used: + stem, ext = os.path.splitext(entry) + n = 2 + entry = f"{stem}_{n}{ext}" + while entry in used: + n += 1 + entry = f"{stem}_{n}{ext}" + used.add(entry) z.writestr(entry, data) total += len(data) count += 1 From 2f84c9f7ea8b194ee7814ccd4551b227c699247a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Jul 2026 10:50:44 +0000 Subject: [PATCH 10/22] =?UTF-8?q?fix(webapp):=20Config=20Inspector=20?= =?UTF-8?q?=E2=80=94=20=D7=94=D7=A4=D7=A8=D7=93=D7=AA=20=D7=A9=D7=99=D7=A8?= =?UTF-8?q?=D7=95=D7=AA=D7=99=D7=9D,=20=D7=A1=D7=98=D7=98=D7=95=D7=A1=20Se?= =?UTF-8?q?t,=20=D7=95=D7=91=D7=99=D7=98=D7=95=D7=9C=20=D7=9E=D7=99=D7=A1?= =?UTF-8?q?=D7=95=D7=9A-=D7=99=D7=AA=D7=A8=20=D7=A9=D7=9C=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit שלושה תיקונים שורשיים ב-Config Inspector (שרץ בתהליך ה-webapp וקורא os.getenv): 1) הפרדה לפי שירות: הצלבנו את כל 236 המשתנים מול עמודת "רכיב" ב- docs/environment-variables.rst וגם מול הקוד עצמו (import-closure סטטי של webapp/bot/mcp + חיפוש הקוראים של כל KEY). 41 משתנים שנקראים רק בבוט (34, כולל ה-webserver הפנימי שרץ בתוך תהליך הבוט), ב-MCP (6) או בסקריפטים (1) הוסטו מהעמוד הראשי ל"עמוד 2" חדש (טאב "שירותים אחרים") שמציג מטא-דאטה בלבד — בלי Status ובלי Active Value, כי ערכיהם חיים בתהליכים אחרים ואינם נגישים מה-webapp. שדה service חדש ב-ConfigDefinition + get_other_services_entries(). OTEL_EXPORTER_* נשארו בעמוד webapp (ה-SDK קורא אותם מה-env בכל תהליך); BOT_USERNAME נשאר webapp ו-BOT_TOKEN נוסף כהגדרה חסרה (נקרא ב-auth_routes). 2) סטטוס "Set" חדש: ערך שהוגדר בסביבה (למשל ברנדר) כשאין ברירת מחדל בקוד אינו "Modified" — אין דיפולט שממנו סטינו. determine_status מחזיר Set במקרה זה (MCP_SERVER_URL, GITHUB_TOKENS, GITHUB_WEBHOOK_SECRET, ALERTMANAGER_WEBHOOK_SECRET, ALERT_TELEGRAM_BOT_TOKEN ודומיהם). נוספו set_count לסקירה, ספירה בכרטיסי הקטגוריות ו-pill כחול (טוקן --info) ב-UI. 3) ביטול מיסוך-יתר: "URL" הוסר מ-SENSITIVE_PATTERNS — כתובת ציבורית (MCP_SERVER_URL, WEBAPP_URL, PROMETHEUS_URL, PUBLIC_BASE_URL...) אינה סוד. URL שמגלם credentials (MONGODB_URL) נשאר ממוסך דרך sensitive=True מפורש, ו-TOKEN/SECRET/URI/KEY ממשיכים להיתפס בתבניות. בנוסף: תוקנו 5 שורות "רכיב" שגויות ב-docs/environment-variables.rst שהתגלו בהצלבה (ENABLE_INTERNAL_SHARE_WEB, SENTRY_WEBHOOK_SECRET, SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS, DUMMY_BOT_TOKEN → Bot; BOT_TOKEN → Bot/WebApp). טסטים: 32 ב-test_config_inspector_service.py (כולל 3 מחלקות חדשות: סטטוס Set, מיסוך URL, הפרדת שירותים) — ירוקים. תחביר Jinja אומת. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- docs/environment-variables.rst | 14 +- services/config_inspector_service.py | 104 ++++++++++++- tests/test_config_inspector_service.py | 102 +++++++++++++ webapp/app.py | 3 + webapp/templates/admin_config_inspector.html | 152 +++++++++++++++++++ 5 files changed, 365 insertions(+), 10 deletions(-) diff --git a/docs/environment-variables.rst b/docs/environment-variables.rst index 91d1deab4..dc10ac6aa 100644 --- a/docs/environment-variables.rst +++ b/docs/environment-variables.rst @@ -17,11 +17,11 @@ - דוגמה - רכיב * - ``BOT_TOKEN`` - - טוקן הבוט מ-BotFather + - טוקן הבוט מ-BotFather (נדרש גם ב-WebApp לאימות Telegram Login) - כן - - - ``123456:ABC-DEF...`` - - Bot + - Bot/WebApp * - ``MONGODB_URL`` - חיבור ל-MongoDB - כן @@ -899,11 +899,11 @@ - ``srv-xxxx`` - Reporter * - ``ENABLE_INTERNAL_SHARE_WEB`` - - הפעלת שירות שיתוף פנימי + - הפעלת שירות שיתוף פנימי (ה-webserver הפנימי שרץ בתוך תהליך הבוט; נקרא ב-main.py) - לא - ``false`` - ``true`` - - WebApp + - Bot * - ``PORT`` - פורט להרצת שירותים פנימיים/בדיקות - לא @@ -1145,13 +1145,13 @@ - לא - "" (ריק) - ``secret123`` - - WebApp + - Bot * - ``SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS`` - חלון דה-דופליקציה (בשניות) להתראות Sentry שמגיעות ב-Webhook כדי למנוע burst (``0`` מנטרל). - לא - ``300`` - ``600`` - - WebApp + - Bot * - ``ALERTMANAGER_IP_ALLOWLIST`` - רשימת IPs (מופרדים בפסיק) שמורשים לצרוך את ה-webhook; נבדק מול ``X-Forwarded-For``/``remote_addr``. - לא @@ -1819,7 +1819,7 @@ - לא - ``dummy_token`` - ``test_token`` - - Bot/WebApp + - Bot * - ``DRIVE_RESCHEDULE_BOOTSTRAP_DELAY`` - כמה שניות להמתין לפני תחילת משימות ה-Drive rescheduler. - לא diff --git a/services/config_inspector_service.py b/services/config_inspector_service.py index 698fc59f0..6a07b6459 100644 --- a/services/config_inspector_service.py +++ b/services/config_inspector_service.py @@ -20,7 +20,8 @@ class ConfigStatus(str, Enum): """סטטוס של משתנה קונפיגורציה.""" DEFAULT = "Default" # משתמש בערך ברירת המחדל - MODIFIED = "Modified" # ערך שונה מברירת המחדל + MODIFIED = "Modified" # ערך שונה מברירת מחדל שקיימת בקוד + SET = "Set" # הוגדר בסביבה (למשל ברנדר) כשאין ברירת מחדל בקוד — לא "שונה" MISSING = "Missing" # משתנה לא מוגדר וגם אין דיפולט @@ -41,6 +42,11 @@ class ConfigDefinition: category: str = "general" sensitive: bool = False # האם להסתיר את הערך required: bool = False # האם המשתנה הכרחי + # לאיזה שירות ב-Render המשתנה שייך. "webapp" (ברירת מחדל) מוצג בעמוד הראשי עם + # Status/Active Value (כי ה-inspector רץ בתהליך ה-webapp וקורא את ה-env שלו); + # "bot"/"mcp"/"scripts" מוצגים בעמוד השירותים האחרים ללא Status/Active Value — + # ה-webapp לא יכול לדעת את ערכיהם בתהליך אחר. + service: str = "webapp" @dataclass @@ -65,6 +71,7 @@ class ConfigOverview: generated_at: str = "" total_count: int = 0 modified_count: int = 0 + set_count: int = 0 missing_count: int = 0 default_count: int = 0 categories: List[str] = field(default_factory=list) @@ -80,13 +87,15 @@ class ConfigService: """ # מילים רגישות בשמות משתנים - ערכים אלו יוסתרו + # הערה: "URL" הוסר בכוונה — כתובת ציבורית (MCP_SERVER_URL, WEBAPP_URL, PROMETHEUS_URL...) + # אינה סוד ומיסוכה מטעה. URL שמכיל credentials (כמו MONGODB_URL) מסומן sensitive=True + # מפורשות בהגדרתו. "URI" נשאר כי URI-חיבור בדרך כלל מגלם סיסמה. SENSITIVE_PATTERNS: tuple[str, ...] = ( "TOKEN", "KEY", "PASSWORD", "SECRET", "URI", - "URL", "CREDENTIALS", "API_KEY", "AUTH", @@ -191,12 +200,14 @@ class ConfigService: ), "DB_RECONNECT_WAIT_BEFORE_POLL": ConfigDefinition( key="DB_RECONNECT_WAIT_BEFORE_POLL", + service="bot", default="120", description="זמן המתנה ראשוני (שניות) להתחברות מחדש ל-DB בעלייה לפני מעבר ל-poll פסיבי", category="database", ), "DB_RECONNECT_POLL_INTERVAL": ConfigDefinition( key="DB_RECONNECT_POLL_INTERVAL", + service="bot", default="30", description="מרווח (שניות) בין בדיקות חיבור ב-poll פסיבי לאחר שחלון ההמתנה הראשוני פג", category="database", @@ -213,6 +224,14 @@ class ConfigService: description="זמן קירור בין בדיקות בריאות (שניות)", category="database", ), + "BOT_TOKEN": ConfigDefinition( + key="BOT_TOKEN", + default="", + description="טוקן הבוט מ-BotFather. נדרש גם בשירות ה-webapp (אימות Telegram Login ב-auth_routes).", + category="telegram", + sensitive=True, + required=True, + ), "BOT_USERNAME": ConfigDefinition( key="BOT_USERNAME", default="my_code_keeper_bot", @@ -222,54 +241,63 @@ class ConfigService: # --- Telegram Polling / Network timeouts (stability against getUpdates conflicts) --- "TELEGRAM_CONNECT_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_CONNECT_TIMEOUT_SECS", + service="bot", default="10.0", description="טיימאאוט התחברות ל-Telegram Bot API (שניות).", category="telegram", ), "TELEGRAM_POOL_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_POOL_TIMEOUT_SECS", + service="bot", default="10.0", description="טיימאאוט המתנה ל-connection מה-pool (שניות) בעת קריאה ל-Telegram Bot API.", category="telegram", ), "TELEGRAM_READ_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_READ_TIMEOUT_SECS", + service="bot", default="30.0", description="טיימאאוט קריאה ל-Telegram Bot API (שניות). מומלץ להיות גבוה מ-TELEGRAM_LONG_POLL_TIMEOUT_SECS.", category="telegram", ), "TELEGRAM_WRITE_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_WRITE_TIMEOUT_SECS", + service="bot", default="30.0", description="טיימאאוט כתיבה ל-Telegram Bot API (שניות).", category="telegram", ), "TELEGRAM_LONG_POLL_TIMEOUT_SECS": ConfigDefinition( key="TELEGRAM_LONG_POLL_TIMEOUT_SECS", + service="bot", default="20", description="timeout של long-polling עבור getUpdates (שניות).", category="telegram", ), "TELEGRAM_POLL_INTERVAL_SECS": ConfigDefinition( key="TELEGRAM_POLL_INTERVAL_SECS", + service="bot", default="0.0", description="poll_interval בין סבבי polling (שניות). 0 = ברירת מחדל של PTB.", category="telegram", ), "TELEGRAM_CONFLICT_BACKOFF_SECS": ConfigDefinition( key="TELEGRAM_CONFLICT_BACKOFF_SECS", + service="bot", default="30", description="זמן המתנה (שניות) לפני retry כאשר מתקבלת שגיאת 409 Conflict ב-getUpdates.", category="telegram", ), "TELEGRAM_CONFLICT_MAX_RETRIES": ConfigDefinition( key="TELEGRAM_CONFLICT_MAX_RETRIES", + service="bot", default="5", description="כמה פעמים לנסות שוב (retry) אחרי 409 Conflict ב-getUpdates לפני יציאה מהתהליך כדי לשחרר lock ולאפשר recovery. 0/שלילי = ללא הגבלה (לא מומלץ).", category="telegram", ), "TELEGRAM_CONFLICT_MAX_SECONDS": ConfigDefinition( key="TELEGRAM_CONFLICT_MAX_SECONDS", + service="bot", default="300", description="חלון זמן מקסימלי (שניות) לרצף conflicts לפני יציאה מהתהליך כדי לשחרר lock ולאפשר recovery. 0/שלילי = ללא הגבלה (לא מומלץ).", category="telegram", @@ -277,90 +305,105 @@ class ConfigService: # --- Distributed Lock (Mongo Lease + Heartbeat) --- "SERVICE_ID": ConfigDefinition( key="SERVICE_ID", + service="bot", default="", description="מזהה ייחודי לשירות/סביבה עבור נעילה מבוזרת (key של מסמך הלוק). אם ריק, נופל ל-LOCK_ID המובנה.", category="locking", ), "RENDER_INSTANCE_ID": ConfigDefinition( key="RENDER_INSTANCE_ID", + service="bot", default="", description="מזהה אינסטנס ב-Render (נשמר במסמך הלוק לצורכי תחקור). ה-owner בפועל הוא מזהה תהליך ייחודי (RENDER_INSTANCE_ID:pid). אם ריק, owner נופל ל-hostname:pid.", category="locking", ), "RENDER_SERVICE_NAME": ConfigDefinition( key="RENDER_SERVICE_NAME", + service="bot", default="", description="שם השירות (label) לצורכי תחקור בלוק (host). אם ריק, נופל ל-HOSTNAME/hostname.", category="locking", ), "LOCK_LEASE_SECONDS": ConfigDefinition( key="LOCK_LEASE_SECONDS", + service="bot", default="10", description="משך ה-lease של הלוק (שניות).", category="locking", ), "LOCK_HEARTBEAT_INTERVAL": ConfigDefinition( key="LOCK_HEARTBEAT_INTERVAL", + service="bot", default="3", description="תדירות heartbeat (שניות) לרענון ה-lease. ברירת מחדל: 3 (מינימום 3).", category="locking", ), "LOCK_WAIT_FOR_ACQUIRE": ConfigDefinition( key="LOCK_WAIT_FOR_ACQUIRE", + service="bot", default="false", description="אם true: המתנה אקטיבית ללוק עם retries קצרים. אם false: המתנה פסיבית עם jitter (ברירת מחדל).", category="locking", ), "LOCK_ACQUIRE_MAX_WAIT": ConfigDefinition( key="LOCK_ACQUIRE_MAX_WAIT", + service="bot", default="0", description="מגבלת זמן (שניות) במצב המתנה אקטיבית. 0 = ללא מגבלה. (אליאס תאימות: LOCK_MAX_WAIT_SECONDS).", category="locking", ), "LOCK_WAIT_MIN_SECONDS": ConfigDefinition( key="LOCK_WAIT_MIN_SECONDS", + service="bot", default="15", description="מינימום זמן המתנה פסיבית עם jitter (שניות).", category="locking", ), "LOCK_WAIT_MAX_SECONDS": ConfigDefinition( key="LOCK_WAIT_MAX_SECONDS", + service="bot", default="45", description="מקסימום זמן המתנה פסיבית עם jitter (שניות).", category="locking", ), "LOCK_RETRY_INTERVAL_SECONDS": ConfigDefinition( key="LOCK_RETRY_INTERVAL_SECONDS", + service="bot", default="1", description="זמן המתנה בין ניסיונות במצב המתנה אקטיבית. (Legacy/תאימות לאחור: שימש גם קודם).", category="locking", ), "LOCK_FAIL_OPEN": ConfigDefinition( key="LOCK_FAIL_OPEN", + service="bot", default="false", description="אם true: במקרה חריגות ברכישת לוק, מאפשר עלייה 'ללא לוק' (לא מומלץ). ברירת מחדל false (fail-closed).", category="locking", ), "LOCK_WAIT_HEALTH_SERVER_ENABLED": ConfigDefinition( key="LOCK_WAIT_HEALTH_SERVER_ENABLED", + service="bot", default="true", description="אם true: בעת המתנה ללוק ותוך קיום PORT, מפעיל שרת HTTP מינימלי ל-/health כדי לעבור health checks.", category="locking", ), "LOCK_PORT_GUARD_ENABLED": ConfigDefinition( key="LOCK_PORT_GUARD_ENABLED", + service="bot", default="false", description="אם true: תופס פורט לוקאלי כדי למנוע שני תהליכים באותו worker. אם הפורט תפוס → יציאה.", category="locking", ), "LOCK_PORT_GUARD_PORT": ConfigDefinition( key="LOCK_PORT_GUARD_PORT", + service="bot", default="9999", description="פורט לוקאלי לשמירה על בלעדיות תהליך (נדרש רק אם LOCK_PORT_GUARD_ENABLED=true).", category="locking", ), "LOCK_COLLECTION": ConfigDefinition( key="LOCK_COLLECTION", + service="bot", default="locks", description="שם קולקציית הלוקים ב-MongoDB (ברירת מחדל legacy: locks).", category="locking", @@ -529,24 +572,28 @@ class ConfigService: ), "MCP_SERVER_NAME": ConfigDefinition( key="MCP_SERVER_NAME", + service="mcp", default="CodeKeeper", description="שם התצוגה של שרת ה-MCP (שם ה-Connector שמוצג ללקוח).", category="mcp", ), "MCP_ALLOWED_HOSTS": ConfigDefinition( key="MCP_ALLOWED_HOSTS", + service="mcp", default="", description="Host מותרים לשרת ה-MCP (CSV, תומך wildcard). ריק = הגנת DNS-rebinding כבויה (מתאים לשרת ציבורי מוגן-טוקן).", category="mcp", ), "MCP_ALLOWED_ORIGINS": ConfigDefinition( key="MCP_ALLOWED_ORIGINS", + service="mcp", default="", description="Origin מותרים לשרת ה-MCP (CSV). רלוונטי רק כשמפעילים הגנה דרך MCP_ALLOWED_HOSTS.", category="mcp", ), "MCP_REPO_DENYLIST_EXTRA": ConfigDefinition( key="MCP_REPO_DENYLIST_EXTRA", + service="mcp", default="", description=( "תבניות glob נוספות (CSV) ל-denylist הסודות של כלי דפדפן הריפו ב-MCP, " @@ -556,6 +603,7 @@ class ConfigService: ), "MCP_REPO_AUTOSYNC": ConfigDefinition( key="MCP_REPO_AUTOSYNC", + service="mcp", default="1", description=( "רענון אוטומטי של ה-mirrors המקומיים בשירות ה-MCP (thread רקע): " @@ -565,6 +613,7 @@ class ConfigService: ), "MCP_REPO_AUTOSYNC_INTERVAL": ConfigDefinition( key="MCP_REPO_AUTOSYNC_INTERVAL", + service="mcp", default="300", description="מרווח בשניות בין מעברי ה-autosync של דפדפן הריפו ב-MCP (מינימום 30).", category="mcp", @@ -862,6 +911,7 @@ class ConfigService: ), "SENTRY_WEBHOOK_SECRET": ConfigDefinition( key="SENTRY_WEBHOOK_SECRET", + service="bot", default="", description="סוד ל-Sentry Webhook", category="monitoring", @@ -869,6 +919,7 @@ class ConfigService: ), "SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS": ConfigDefinition( key="SENTRY_WEBHOOK_DEDUP_WINDOW_SECONDS", + service="bot", default="300", description="חלון dedup ל-Sentry Webhooks (שניות)", category="monitoring", @@ -1373,6 +1424,7 @@ class ConfigService: # --- Diagnostics / sanity checks --- "SANITY_USER_ID": ConfigDefinition( key="SANITY_USER_ID", + service="scripts", default="123", description="משתנה עזר לסקריפט scripts/db_manager_sanity_check.py (לא משפיע על ריצה רגילה)", category="dev", @@ -1403,6 +1455,7 @@ class ConfigService: ), "DUMMY_BOT_TOKEN": ConfigDefinition( key="DUMMY_BOT_TOKEN", + service="bot", default="dummy_token", description="טוקן בדיקה שמשמש סביבות שבהן אין צורך להתחבר לטלגרם (למשל docs build).", category="general", @@ -1410,6 +1463,7 @@ class ConfigService: ), "ENABLE_INTERNAL_SHARE_WEB": ConfigDefinition( key="ENABLE_INTERNAL_SHARE_WEB", + service="bot", default="false", description="הפעלת שירות שיתוף פנימי", category="features", @@ -1460,6 +1514,7 @@ class ConfigService: ), "JOBS_STUCK_THRESHOLD_MINUTES": ConfigDefinition( key="JOBS_STUCK_THRESHOLD_MINUTES", + service="bot", default="20", description="סף (בדקות) לזיהוי הרצות Jobs תקועות והפקת אירוע job_stuck.", category="jobs_monitor", @@ -1556,18 +1611,21 @@ class ConfigService: ), "EMBEDDING_WORKER_BATCH_SIZE": ConfigDefinition( key="EMBEDDING_WORKER_BATCH_SIZE", + service="bot", default="5", description="כמות snippets שה-embedding worker מעבד בכל סבב", category="ai", ), "EMBEDDING_WORKER_POLL_INTERVAL": ConfigDefinition( key="EMBEDDING_WORKER_POLL_INTERVAL", + service="bot", default="300", description="זמן המתנה (שניות) בין סריקות של ה-embedding worker כשהתור ריק", category="ai", ), "EMBEDDING_WORKER_BATCH_COOLDOWN": ConfigDefinition( key="EMBEDDING_WORKER_BATCH_COOLDOWN", + service="bot", default="30", description="זמן המתנה (שניות) בין באצ'ים שעובדו בהצלחה", category="ai", @@ -1684,6 +1742,11 @@ def determine_status( # אין דיפולט אבל לא הכרחי - נחשב Default (ריק) return ConfigStatus.DEFAULT + # יש ערך בסביבה כשאין ברירת מחדל בקוד — המשתנה "הוגדר" (למשל ברנדר), + # לא "שונה": אין דיפולט שממנו אפשר לסטות (למשל GITHUB_TOKENS, MCP_SERVER_URL) + if default_is_empty: + return ConfigStatus.SET + # יש ערך בסביבה - השוואה לדיפולט default_str = str(default_value) if default_value is not None else "" if env_value == default_str: @@ -1770,6 +1833,10 @@ def get_config_overview( categories_set: set[str] = set() for definition in self.CONFIG_DEFINITIONS.values(): + # עמוד ראשי: רק משתני שירות ה-webapp — ה-inspector רץ בתהליך ה-webapp + # ולכן Status/Active Value של שירותים אחרים (bot/mcp) יהיו מטעים + if definition.service != "webapp": + continue entry = self.get_config_entry(definition) categories_set.add(entry.category) @@ -1786,6 +1853,7 @@ def get_config_overview( # חישוב סטטיסטיקות modified_count = sum(1 for e in entries if e.status == ConfigStatus.MODIFIED) + set_count = sum(1 for e in entries if e.status == ConfigStatus.SET) missing_count = sum(1 for e in entries if e.status == ConfigStatus.MISSING) default_count = sum(1 for e in entries if e.status == ConfigStatus.DEFAULT) @@ -1794,11 +1862,36 @@ def get_config_overview( generated_at=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), total_count=len(entries), modified_count=modified_count, + set_count=set_count, missing_count=missing_count, default_count=default_count, categories=sorted(categories_set), ) + def get_other_services_entries(self) -> List[Dict[str, Any]]: + """משתנים של שירותים שאינם webapp (bot/mcp/scripts) — לעמוד 2 של ה-Inspector. + + בלי Status ובלי Active Value: הערכים חיים בתהליכים אחרים (שירותי Render + נפרדים) ואינם נגישים מכאן. מוצגים מטא-דאטה בלבד: מפתח, שירות, קטגוריה, + ברירת מחדל (ממוסכת אם רגיש) ותיאור. + """ + rows: List[Dict[str, Any]] = [] + for definition in self.CONFIG_DEFINITIONS.values(): + if definition.service == "webapp": + continue + default_str = str(definition.default) if definition.default is not None else "" + is_sensitive = self.is_sensitive_key(definition.key) or definition.sensitive + rows.append({ + "key": definition.key, + "service": definition.service, + "category": definition.category, + "default_value": self.mask_value(default_str, definition.key) if is_sensitive else default_str, + "description": definition.description, + "is_sensitive": is_sensitive, + }) + rows.sort(key=lambda r: (r["service"], r["category"], r["key"])) + return rows + def get_category_summary(self) -> Dict[str, Dict[str, int]]: """ קבלת סיכום לפי קטגוריות. @@ -1813,11 +1906,13 @@ def get_category_summary(self) -> Dict[str, Dict[str, int]]: for entry in overview.entries: cat = entry.category if cat not in summary: - summary[cat] = {"total": 0, "modified": 0, "missing": 0, "default": 0} + summary[cat] = {"total": 0, "modified": 0, "set": 0, "missing": 0, "default": 0} summary[cat]["total"] += 1 if entry.status == ConfigStatus.MODIFIED: summary[cat]["modified"] += 1 + elif entry.status == ConfigStatus.SET: + summary[cat]["set"] += 1 elif entry.status == ConfigStatus.MISSING: summary[cat]["missing"] += 1 else: @@ -1837,6 +1932,9 @@ def validate_required(self) -> List[str]: for definition in self.CONFIG_DEFINITIONS.values(): if not definition.required: continue + # משתנה של שירות אחר לא נבדק כאן — הערך שלו לא אמור להיות בסביבת ה-webapp + if definition.service != "webapp": + continue env_value = self.get_env_value(definition.key) default_str = str(definition.default) if definition.default is not None else None diff --git a/tests/test_config_inspector_service.py b/tests/test_config_inspector_service.py index 6a7fadfc6..5baa1a220 100644 --- a/tests/test_config_inspector_service.py +++ b/tests/test_config_inspector_service.py @@ -236,6 +236,108 @@ def test_category_summary(self): for _cat, stats in summary.items(): assert "total" in stats assert "modified" in stats + assert "set" in stats assert "missing" in stats assert "default" in stats + +class TestConfigStatusSet: + """סטטוס Set: ערך שהוגדר בסביבה (למשל ברנדר) כשאין ברירת מחדל בקוד — אינו 'Modified'.""" + + def setup_method(self): + self.service = ConfigService() + + def test_env_without_default_is_set_not_modified(self): + assert self.service.determine_status("some-value", "") == ConfigStatus.SET + assert self.service.determine_status("some-value", None) == ConfigStatus.SET + + def test_env_with_different_default_is_modified(self): + assert self.service.determine_status("custom", "default") == ConfigStatus.MODIFIED + + def test_user_reported_keys_show_set_when_env_configured(self): + # המקרים שדווחו: הוגדרו ברנדר, אין דיפולט בקוד ⇒ Set (לא Modified) + keys = ( + "MCP_SERVER_URL", + "GITHUB_TOKENS", + "GITHUB_WEBHOOK_SECRET", + "ALERTMANAGER_WEBHOOK_SECRET", + "ALERT_TELEGRAM_BOT_TOKEN", + ) + for key in keys: + definition = self.service.CONFIG_DEFINITIONS[key] + with patch.dict(os.environ, {key: "value-set-in-render"}, clear=False): + entry = self.service.get_config_entry(definition) + assert entry.status == ConfigStatus.SET, f"{key}: {entry.status}" + + def test_overview_counts_set(self): + with patch.dict(os.environ, {"MCP_SERVER_URL": "https://mcp.example.com"}, clear=False): + overview = self.service.get_config_overview() + assert overview.set_count >= 1 + assert overview.set_count == sum(1 for e in overview.entries if e.status == ConfigStatus.SET) + + +class TestMaskingUrls: + """מיסוך: URL ציבורי אינו סוד; URL עם credentials ממוסך דרך sensitive=True מפורש.""" + + def setup_method(self): + self.service = ConfigService() + + def test_public_urls_not_masked(self): + assert self.service.mask_value("https://mcp.example.com", "MCP_SERVER_URL") == "https://mcp.example.com" + assert self.service.mask_value("https://app.example.com", "WEBAPP_URL") == "https://app.example.com" + assert self.service.mask_value("https://prom.example.com", "PROMETHEUS_URL") == "https://prom.example.com" + + def test_credential_bearing_values_still_masked(self): + # MONGODB_URL מסומן sensitive=True מפורשות (מכיל סיסמה ב-URI) + assert self.service.mask_value("mongodb://u:p@h/db", "MONGODB_URL") == "********" + # תבניות TOKEN/SECRET/URI ממשיכות לתפוס + assert self.service.mask_value("ghp_x", "GITHUB_TOKENS") == "********" + assert self.service.mask_value("x", "SOME_SECRET") == "********" + assert self.service.mask_value("mongodb://u:p@h", "MONGODB_URI") == "********" + + def test_entry_active_value_visible_for_public_url(self): + definition = self.service.CONFIG_DEFINITIONS["MCP_SERVER_URL"] + with patch.dict(os.environ, {"MCP_SERVER_URL": "https://mcp.example.com"}, clear=False): + entry = self.service.get_config_entry(definition) + assert entry.active_value == "https://mcp.example.com" + assert entry.is_sensitive is False + + +class TestServiceSplit: + """הפרדת משתנים לפי שירות: עמוד 1 = webapp בלבד; עמוד 2 = bot/mcp/scripts בלי ערכים.""" + + def setup_method(self): + self.service = ConfigService() + + def test_overview_contains_only_webapp_definitions(self): + overview = self.service.get_config_overview() + webapp_keys = {k for k, d in self.service.CONFIG_DEFINITIONS.items() if d.service == "webapp"} + assert {e.key for e in overview.entries} == webapp_keys + + def test_other_services_rows_cover_the_rest(self): + rows = self.service.get_other_services_entries() + other_keys = {k for k, d in self.service.CONFIG_DEFINITIONS.items() if d.service != "webapp"} + assert {r["key"] for r in rows} == other_keys + assert all(r["service"] in ("bot", "mcp", "scripts") for r in rows) + # אין Status/Active Value בעמוד 2 — רק מטא-דאטה + assert all("status" not in r and "active_value" not in r for r in rows) + + def test_known_service_assignments(self): + defs = self.service.CONFIG_DEFINITIONS + # לפי אישור המשתמש: BOT_TOKEN/BOT_USERNAME שייכים גם ל-webapp + assert defs["BOT_TOKEN"].service == "webapp" + assert defs["BOT_USERNAME"].service == "webapp" + # MCP_SERVER_URL נקרא בוובאפ (oauth identify) — נשאר עמוד 1 + assert defs["MCP_SERVER_URL"].service == "webapp" + # דוגמאות מובהקות לעמוד 2 + assert defs["TELEGRAM_LONG_POLL_TIMEOUT_SECS"].service == "bot" + assert defs["LOCK_LEASE_SECONDS"].service == "bot" + assert defs["MCP_SERVER_NAME"].service == "mcp" + assert defs["SANITY_USER_ID"].service == "scripts" + + def test_sensitive_default_masked_in_other_services(self): + rows = self.service.get_other_services_entries() + dummy = next(r for r in rows if r["key"] == "DUMMY_BOT_TOKEN") + # יש לו default לא-ריק והוא TOKEN ⇒ הדיפולט חייב להיות ממוסך + assert dummy["default_value"] in ("********", "") + diff --git a/webapp/app.py b/webapp/app.py index 7b803f5b0..e2c9e5e27 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -5658,12 +5658,15 @@ def admin_config_inspector_page(): ) category_summary = service.get_category_summary() missing_required = service.validate_required() + # עמוד 2: משתני שירותים אחרים (bot/mcp/scripts) — מטא-דאטה בלבד, בלי Status/Active Value + other_services = service.get_other_services_entries() return render_template( "admin_config_inspector.html", overview=overview, category_summary=category_summary, missing_required=missing_required, + other_services=other_services, selected_category=category, selected_status=status, statuses=[s.value for s in ConfigStatus], diff --git a/webapp/templates/admin_config_inspector.html b/webapp/templates/admin_config_inspector.html index c67c378e8..40f6cdd58 100644 --- a/webapp/templates/admin_config_inspector.html +++ b/webapp/templates/admin_config_inspector.html @@ -333,10 +333,57 @@ color: #ff627c; } +/* "הוגדר בסביבה" (Set) — נצבע דרך טוקן --info כדי לא להוסיף צבעים קשיחים חדשים */ +.status-pill.set { + background: color-mix(in srgb, var(--info, #4299e1) 18%, transparent); + color: var(--info, #4299e1); +} + .status-pill i { font-size: 0.75rem; } +/* טאבים: עמוד Webapp / עמוד שירותים אחרים */ +.inspector-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} +.inspector-tab { + background: var(--glass, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.2)); + color: inherit; + border-radius: 10px; + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.95rem; + transition: background 0.2s, border-color 0.2s; +} +.inspector-tab:hover { + background: var(--glass-hover, rgba(255, 255, 255, 0.15)); +} +.inspector-tab.active { + background: var(--glass-hover, rgba(255, 255, 255, 0.15)); + border-color: var(--primary, #667eea); + font-weight: 600; +} +.inspector-page { display: none; } +.inspector-page.active { display: block; } + +/* תגית שירות (bot/mcp/scripts) בעמוד השירותים האחרים */ +.service-badge { + display: inline-block; + padding: 0.15rem 0.55rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + background: var(--glass, rgba(255, 255, 255, 0.1)); + border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.2)); + text-transform: uppercase; + letter-spacing: 0.03em; +} + /* Description tooltip */ .config-desc { font-size: 0.8rem; @@ -572,6 +619,18 @@
{% endif %} + +
+ + +
+ +
+
@@ -582,6 +641,10 @@

סה"כ משתנים

שונו מדיפולט

{{ overview.modified_count }}
+
+

הוגדרו בסביבה

+
{{ overview.set_count }}
+

חסרים

{{ overview.missing_count }}
@@ -648,6 +711,9 @@

{{ stats.modified }} + + {{ stats.set }} + {{ stats.missing }} @@ -733,6 +799,8 @@

{% if entry.status.value == 'Modified' %} + {% elif entry.status.value == 'Set' %} + {% elif entry.status.value == 'Missing' %} {% else %} @@ -766,10 +834,94 @@

סה"כ: {{ overview.total_count }} משתנים

+ +
+ + +
+
+ +
+ המשתנים כאן שייכים לשירותי Render אחרים (Bot / MCP / Scripts). הערכים שלהם חיים + בתהליכים נפרדים ואינם נגישים משירות ה-Webapp — לכן אין כאן Status או Active Value. + לצפייה בערכים בפועל יש לבדוק ב-Render Dashboard של השירות הרלוונטי. +
+
+ +
+

+ + משתני Bot / MCP / Scripts +

+ + {% if other_services %} + + + + + + + + + + + {% for row in other_services %} + + + + + + + {% endfor %} + +
KeyשירותDefault Valueתיאור
+
+ {{ row.category }} + {{ row.key }} +
+
+ {{ row.service }} + +
+ {% if row.default_value %} + {{ row.default_value }} + {% if row.is_sensitive %} + + {% endif %} + {% else %} + (ללא) + {% endif %} +
+
+
{{ row.description or '—' }}
+
+ {% else %} +
+

אין משתנים של שירותים אחרים

+
+ {% endif %} +
+
+ {% endblock %} diff --git a/webapp/templates/md_preview.html b/webapp/templates/md_preview.html index 59721efef..2e34362f3 100644 --- a/webapp/templates/md_preview.html +++ b/webapp/templates/md_preview.html @@ -1295,6 +1295,15 @@ opacity: 1; } +/* יעד עוגן מפורש שנוצר מ- שבמקור ה-Markdown (ראו js/md-anchors.js). + בלתי-נראה לחלוטין; scroll-margin-top מונע מהכותרת להיעצר מתחת לסרגל העליון. */ +#md-content .md-explicit-anchor { + display: block; + height: 0; + overflow: hidden; + scroll-margin-top: 80px; +} + /* ──── כפתור צף "העתק כמארקדאון" ──── */ .md-copy-selection-fab { position: absolute; @@ -2101,6 +2110,7 @@

+ + {% endblock %} From e8b669b0198b616d6d22cfdd33a2d39bc05aa9fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:13:41 +0000 Subject: [PATCH 22/22] =?UTF-8?q?fix(webapp):=20=D7=9B=D7=95=D7=AA=D7=A8?= =?UTF-8?q?=D7=95=D7=AA=20Setext=20=D7=91=D7=A2=D7=95=D7=92=D7=A0=D7=99=20?= =?UTF-8?q?Markdown=20+=20=D7=A0=D7=99=D7=AA=D7=95=D7=A7=20=D7=9E=D7=A6?= =?UTF-8?q?=D7=91=20=D7=9E=D7=95=D7=93=D7=95=D7=9C=D7=A8=D7=99=20=D7=91?= =?UTF-8?q?=D7=AA=D7=A6=D7=95=D7=92=D7=94=20=D7=94=D7=97=D7=99=D7=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit סבב review — כל הממצאים אומתו מול הקוד ותוקנו: 1. כותרות Setext (באג נכונות אמיתי, אומת בשחזור): extractExplicitAnchors ספר רק כותרות ATX, אבל markdown-it מייצר h1/h2 גם מכותרות Setext (שורת טקסט ואחריה === או ---). התוצאה: האינדקסים זזו והעוגן הוחל על הכותרת הלא נכונה — במסמך "מבוא\n====" ואחריו "## תקשורת " העוגן נחת על "מבוא". נוסף זיהוי Setext עם שמירה על ההתנהגות ל-ATX, ועם בדיקה שמרנית שמונעת זיהוי שגוי: קו מפריד (hr) אחרי שורה ריקה, שורת הפרדה של טבלה, פריט רשימה, ציטוט, וקוד מוזח — כולם אינם כותרת. נוסף פרמטר setext (ברירת מחדל true) לכיבוי כש-md.disable('lheading') פעיל; אומת שאף צרכן בריפו לא מנטרל אותו. 2. ניתוק המצב המודולרי ב-live-preview: pendingExplicitAnchors המשותף בין render() ל-enhance() אפשר לשני רינדורים עוקבים לדרוס זה את העוגנים של זה — וזה החמיר בגלל ש-repo-browser.js הוא צרכן נוסף של אותו זוג. עכשיו renderWithAnchors() מחזיר { html, anchors } והם מועברים במפורש ל- enhance(root, anchors). render() נשאר עם החתימה הקיימת (מחזיר HTML) כדי לא לשבור קוראים. 3. repo-browser: מנוצל התיקון — כבר היה שם handler תקין לגלילת עוגנים (setupMarkdownAnchorScrolling עם getElementById), רק שהעוגנים מעולם לא נוצרו. מעבר ל-renderWithAnchors + טעינת md-anchors.js ב-base_repo.html משלימים את התמונה גם בעיון בקבצי GitHub. 4. תיעוד config-inspector: פקודות החיפוש הוחלפו ב-rg עם -w (מילה שלמה) ו- -g '!tests/**' — הן תופסות עכשיו גם os.getenv('X') בגרש בודד וגם config.X בפקודה אחת, בלי להיתפס ל-X_OTHER. הודגם בפועל לפני העדכון. בנוסף הובהר שהמיסוך חל רק על ערכים שסווגו כרגישים, ולא על כל ערך. טסטים: 33 ירוקים (נוספו 12 ל-Setext, כולל מקרי אי-זיהוי), ובנוסף הצלבה של כל תשעת המקרים מול ה-DOM ש-markdown-it באמת מייצר — 9/9 תואמים. Sphinx -W עבר, 81 טסטי פייתון ירוקים. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01WNFuSyshwpRcxozVZEui5K --- docs/webapp/config-inspector.rst | 17 ++++--- tests/md-anchors.test.js | 48 ++++++++++++++++++++ webapp/static/js/live-preview.js | 66 ++++++++++++++++------------ webapp/static/js/md-anchors.js | 42 ++++++++++++++++-- webapp/static/js/repo-browser.js | 11 ++--- webapp/templates/repo/base_repo.html | 1 + 6 files changed, 142 insertions(+), 43 deletions(-) diff --git a/docs/webapp/config-inspector.rst b/docs/webapp/config-inspector.rst index 99e7cff2d..4f12445ea 100644 --- a/docs/webapp/config-inspector.rst +++ b/docs/webapp/config-inspector.rst @@ -28,8 +28,10 @@ Config Inspector (סקירת משתני סביבה) GET /admin/config-inspector .. note:: - הדף זמין **רק לאדמינים** (``@admin_required`` ב-``webapp/app.py``). ערכים רגישים - ממוסכים תמיד — ראו :ref:`config-inspector-sensitive`. + הדף זמין **רק לאדמינים** (``@admin_required`` ב-``webapp/app.py``). ערך מוצג ממוסך + (``********``) **רק אם הוא מסווג כרגיש** — לפי שם המשתנה או לפי סימון מפורש + ``sensitive=True``; כל שאר הערכים מוצגים כמות שהם. הסיווג מפורט ב- + :ref:`config-inspector-sensitive`, וחשוב לקרוא אותו לפני הוספת משתנה שהוא סוד. .. _config-inspector-two-pages: @@ -130,7 +132,10 @@ Config Inspector (סקירת משתני סביבה) .. code-block:: bash - grep -rn '"MY_VAR"' --include="*.py" . | grep -v test + rg -n -w 'MY_VAR' -t py -g '!tests/**' + +``-w`` מחפש את שם המשתנה כמילה שלמה, ולכן תופס גם ``os.getenv("MY_VAR")``, גם +``os.getenv('MY_VAR')`` וגם ``config.MY_VAR``, בלי להיתפס ל-``MY_VAR_OTHER``. .. _config-inspector-services: @@ -147,12 +152,12 @@ Config Inspector (סקירת משתני סביבה) **הנוהל (שלושה צעדים):** -1. **למצוא איפה המשתנה נצרך בפועל** — גם ``os.getenv`` וגם גישה דרך אובייקט הקונפיג: +1. **למצוא איפה המשתנה נצרך בפועל** — חיפוש אחד תופס גם ``os.getenv`` (בכל סוג גרשיים) + וגם גישה דרך אובייקט הקונפיג (``config.MY_VAR``): .. code-block:: bash - grep -rn '"MY_VAR"' --include="*.py" . | grep -v test - grep -rn 'config\.MY_VAR' --include="*.py" . | grep -v test + rg -n -w 'MY_VAR' -t py -g '!tests/**' 2. **לזהות לאיזה שירות שייך הקובץ שנמצא** — לפי נקודות הכניסה: diff --git a/tests/md-anchors.test.js b/tests/md-anchors.test.js index 2bc60a9e1..cd3efaf73 100644 --- a/tests/md-anchors.test.js +++ b/tests/md-anchors.test.js @@ -83,6 +83,54 @@ console.log('אינדוקס כותרות'); check('העוגן שויך לכותרת השנייה (index=1)', r.anchors[0].headingIndex === 1, r.anchors); } +console.log('כותרות Setext — חייבות להיספר כדי שהאינדקס יתאים ל-h1..h6 בפועל'); +{ + // markdown-it מייצר כאן h1 (Setext) ואז h2 (ATX) — העוגן שייך לכותרת index=1 + const r = extractExplicitAnchors('מבוא\n====\n\nטקסט.\n\n## תקשורת '); + check('העוגן שויך לכותרת השנייה', r.anchors[0] && r.anchors[0].headingIndex === 1, r.anchors); + check('הכותרת נוקתה מהתגית', r.source.includes('## תקשורת') && !r.source.includes('\n-------\n\nטקסט.'); + check('עוגן בכותרת Setext נאסף', r.anchors[0] && r.anchors[0].ids[0] === 'comm', r.anchors); + check('index=0 לכותרת הראשונה', r.anchors[0].headingIndex === 0, r.anchors); + check('שורת הטקסט נוקתה', r.source.split('\n')[0] === 'תקשורת', r.source); +} +{ + // עוגן בשורה נפרדת לפני כותרת Setext + const r = extractExplicitAnchors('\nמבוא\n===='); + check('שויך לכותרת Setext', r.anchors[0] && r.anchors[0].ids[0] === 'intro', r.anchors); + check('שורת העוגן הוסרה', r.source === 'מבוא\n====', r.source); +} +{ + // ערבוב: Setext, ATX, Setext — שלוש כותרות, העוגן על השלישית + const r = extractExplicitAnchors('A\n===\n\n## B\n\nC \n---'); + check('שלוש כותרות — העוגן על index=2', r.anchors[0].headingIndex === 2, r.anchors); +} +{ + // setext=false (כש-lheading מנוטרל) — לא סופרים כותרות Setext + const r = extractExplicitAnchors('מבוא\n====\n\n## תקשורת ', { setext: false }); + check('בלי Setext האינדקס הוא 0', r.anchors[0].headingIndex === 0, r.anchors); +} + +console.log('Setext — הימנעות מזיהוי שגוי'); +{ + // "---" אחרי שורה ריקה הוא קו מפריד (hr), לא כותרת + const r = extractExplicitAnchors('טקסט.\n\n---\n\n## כותרת '); + check('hr לא נספר ככותרת', r.anchors[0].headingIndex === 0, r.anchors); +} +{ + // שורת הפרדה של טבלה אינה קו תחתון של Setext + const r = extractExplicitAnchors('| a | b |\n|---|---|\n| 1 | 2 |\n\n## כותרת '); + check('טבלה לא נספרת ככותרת', r.anchors[0].headingIndex === 0, r.anchors); +} +{ + // פריט רשימה שאחריו --- אינו כותרת Setext + const r = extractExplicitAnchors('- פריט\n---\n\n## כותרת '); + check('פריט רשימה לא נספר ככותרת', r.anchors[0].headingIndex === 0, r.anchors); +} + console.log('סניטציה של מזהה'); { check('סולמית מובילה מוסרת', sanitizeAnchorId('#intro') === 'intro'); diff --git a/webapp/static/js/live-preview.js b/webapp/static/js/live-preview.js index 97792bdac..4efcb5bf5 100644 --- a/webapp/static/js/live-preview.js +++ b/webapp/static/js/live-preview.js @@ -437,11 +437,8 @@ } } - // עוגנים מפורשים שנשלפו ברינדור האחרון (ראו render למטה) וממתינים להחלה על ה-DOM - let pendingExplicitAnchors = []; - - async function enhance(root) { - applyPendingAnchors(root); + async function enhance(root, anchors) { + applyExplicitAnchors(root, anchors); highlightBlocks(root); enhanceTaskLists(root); lazyLoadImages(root); @@ -451,18 +448,40 @@ // החלת עוגני ה-HTML שנשלפו מהמקור (`## סעיף `). ה-renderer רץ עם // html:false ולכן התגית לא שורדת — ראו webapp/static/js/md-anchors.js. - function applyPendingAnchors(root) { - if (!root || !pendingExplicitAnchors.length) { + // העוגנים מועברים במפורש מהקורא (ולא דרך מצב מודולרי), כדי ששני רינדורים + // עוקבים לא ידרסו זה את העוגנים של זה. + function applyExplicitAnchors(root, anchors) { + if (!root || !anchors || !anchors.length) { return; } try { if (typeof window !== 'undefined' && window.MdAnchors) { - window.MdAnchors.applyExplicitAnchors(root, pendingExplicitAnchors); + window.MdAnchors.applyExplicitAnchors(root, anchors); } } catch (_) { // best-effort — כשל בהחלת עוגנים לא אמור לשבור את התצוגה המקדימה } - pendingExplicitAnchors = []; + } + + // רינדור + שליפת העוגנים המפורשים. משמש גם את render() (שמחזיר HTML בלבד, + // לשמירת תאימות עם קוראים קיימים) וגם את renderWithAnchors(). + function renderMarkdownSource(text) { + const md = ensureRenderer(); + if (!md) { + throw new Error('markdown_renderer_missing'); + } + let source = text || ''; + let anchors = []; + try { + if (typeof window !== 'undefined' && window.MdAnchors) { + const extracted = window.MdAnchors.extractExplicitAnchors(source); + source = extracted.source; + anchors = extracted.anchors; + } + } catch (_) { + // אם השליפה נכשלה — ממשיכים עם המקור המקורי + } + return { html: md.render(source), anchors: anchors }; } return { @@ -475,24 +494,14 @@ isMarkdownFile(value) { return isMarkdownExtension(value); }, + // מחזיר HTML בלבד (חתימה קיימת). הכותרות מנוקות מתגיות העוגן, אך כדי גם + // *להחיל* את העוגנים יש להשתמש ב-renderWithAnchors ולהעביר אותם ל-enhance. async render(text) { - const md = ensureRenderer(); - if (!md) { - throw new Error('markdown_renderer_missing'); - } - // שליפת עוגני HTML מפורשים לפני הרינדור; ההחלה על ה-DOM קורית ב-enhance() - let source = text || ''; - pendingExplicitAnchors = []; - try { - if (typeof window !== 'undefined' && window.MdAnchors) { - const extracted = window.MdAnchors.extractExplicitAnchors(source); - source = extracted.source; - pendingExplicitAnchors = extracted.anchors; - } - } catch (_) { - // אם השליפה נכשלה — ממשיכים עם המקור המקורי - } - return md.render(source); + return renderMarkdownSource(text).html; + }, + // מחזיר { html, anchors } — העוגנים מועברים לאחר מכן ל-enhance(root, anchors) + async renderWithAnchors(text) { + return renderMarkdownSource(text); }, enhance, }; @@ -727,13 +736,14 @@ this.state.inflightHash = payloadHash; this.setStatus(STATUS.LOADING); try { - const html = await MarkdownLiveRenderer.render(content); + const rendered = await MarkdownLiveRenderer.renderWithAnchors(content); + const html = rendered.html; this.setMarkdownContext(true); this.previewCanvas.innerHTML = html || '

אין תוכן להצגה.

'; if (this.styleEl) { this.styleEl.textContent = ''; } - await MarkdownLiveRenderer.enhance(this.previewCanvas); + await MarkdownLiveRenderer.enhance(this.previewCanvas, rendered.anchors); this.applyPreviewTheme('markdown'); const duration = typeof performance !== 'undefined' && typeof performance.now === 'function' ? Math.max(1, Math.round(performance.now() - started)) diff --git a/webapp/static/js/md-anchors.js b/webapp/static/js/md-anchors.js index d11bbc68b..003a78676 100644 --- a/webapp/static/js/md-anchors.js +++ b/webapp/static/js/md-anchors.js @@ -29,6 +29,26 @@ // גדר בלוק קוד: ``` או ~~~ (עם שפה אופציונלית) var FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/; + // קו תחתון של כותרת Setext: שורה שכולה "=" (h1) או "-" (h2) + var SETEXT_UNDERLINE_RE = /^\s{0,3}(=+|-+)\s*$/; + + /** + * האם השורה יכולה לשמש כטקסט של כותרת Setext (כלומר פסקה רגילה)? + * הבידוק שמרני בכוונה: כל דבר שהוא בלוק אחר (רשימה, ציטוט, טבלה, קוד מוזח) + * לא יוצר כותרת גם כשאחריו "---" — שם ה-"---" הוא קו מפריד (hr), לא כותרת. + */ + function canBeSetextText(line) { + if (!line || !line.trim()) return false; // שורה ריקה + if (SETEXT_UNDERLINE_RE.test(line)) return false; // קו תחתון בעצמה + if (HEADING_RE.test(line)) return false; // כותרת ATX + if (FENCE_RE.test(line)) return false; // גדר קוד + if (/^\s{0,3}>/.test(line)) return false; // ציטוט + if (/^\s{0,3}([-*+]|\d{1,9}[.)])\s/.test(line)) return false; // פריט רשימה + if (/^\s{4,}\S/.test(line)) return false; // בלוק קוד מוזח + if (/^\s{0,3}\|/.test(line)) return false; // שורת טבלה + return true; + } + /** * ניקוי מזהה עוגן. ה-id מוצב דרך element.id (DOM property) ולא דרך innerHTML, * ולכן אין וקטור הזרקה — הניקוי כאן נועד למנוע מזהים מוזרים/שבורים בלבד. @@ -47,12 +67,17 @@ * עיבוד מקדים של מקור ה-Markdown: שליפת עוגנים מפורשים והסרתם מהטקסט. * * @param {string} source מקור ה-Markdown הגולמי + * @param {{setext?: boolean}} [options] setext=false אם ה-renderer מנטרל כותרות Setext + * (md.disable('lheading')). ברירת מחדל true — כמו ההתנהגות הרגילה של markdown-it. * @returns {{source: string, anchors: Array<{headingIndex: number, ids: string[]}>}} * source — המקור אחרי ניקוי תגיות העוגן - * anchors — לכל כותרת (לפי סדר הופעתה, 0-based) רשימת המזהים שהוגדרו לה + * anchors — לכל כותרת (לפי סדר הופעתה, 0-based) רשימת המזהים שהוגדרו לה. + * חשוב: הספירה חייבת לכלול גם כותרות Setext, אחרת האינדקס לא יתאים לאלמנטי + * ה-h1..h6 בפועל והעוגן יוחל על הכותרת הלא נכונה. */ - function extractExplicitAnchors(source) { + function extractExplicitAnchors(source, options) { var text = (source == null) ? '' : String(source); + var allowSetext = !(options && options.setext === false); if (!text || text.indexOf('ברוכים הבאים לדפדפן הקוד

+ {% endblock %}