Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4fdfcc7
feat(mcp): פתקים דביקים — צפייה, יצירה ועריכה דרך ה-MCP
claude Jul 20, 2026
fd6b706
feat(webapp): קיצור דרך לעריכת תיאור מתפריט ⋮ בעמוד הקובץ
claude Jul 20, 2026
e97bee5
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 20, 2026
9218b77
fix(webapp): תבנית חדשה מגיעה מיד אחרי deploy (ETag כולל גרסת deploy)
claude Jul 20, 2026
e745b7e
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 20, 2026
71e3264
feat(webapp): אייקון תיאור על קבצים באוסף + מודאל תצוגה
claude Jul 20, 2026
7ea470a
feat(webapp): הזזת אייקון תיאור בכרטיס אוסף + ארכיון לאוספים
claude Jul 20, 2026
26b25b6
Merge origin/main: יישור הענף מול main + פתרון קונפליקט collections.css
claude Jul 20, 2026
991b1ad
fix(webapp): המרת צבעים קשיחים לטוקני ערכה במודאל התיאור ובכפתור הארכיון
claude Jul 20, 2026
afa7b6d
Merge origin/main: יישור מול main אחרי squash של #3192 + פתרון קונפלי…
claude Jul 20, 2026
c7c49a3
fix(bot): תיקון מספר קבצים בהודעות ZIP + שלב בחירת שם ל-ZIP
claude Jul 24, 2026
bab0701
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 24, 2026
fd88aa0
fix(bot): הקשחת יצירת ZIP — ניקוי שמות (Zip-Slip), מגבלות איסוף, to_t…
claude Jul 24, 2026
85bcfa9
fix(bot): הקשחת ZIP סבב 2 — מגבלות לפני הורדה, מניעת שמות כפולים, תיק…
claude Jul 24, 2026
2f84c9f
fix(webapp): Config Inspector — הפרדת שירותים, סטטוס Set, וביטול מיסו…
claude Jul 26, 2026
552bcf4
Merge branch 'main' into claude/mcp-codekeeper-webapp-ldnzsg
amirbiron Jul 26, 2026
78c0a04
fix(webapp): Config Inspector v2 — webserver כשירות נפרד, משתנים משות…
claude Jul 26, 2026
606a1c1
feat(bot): סוג קובץ חדש "סקיל" — אחסון נפרד מגיבויים
claude Jul 26, 2026
b4f4844
fix(bot,webapp): סבב review לסקילים — כשלי CI, אבטחה וביצועים
claude Jul 26, 2026
352247a
fix(bot): שמירת סקילים נכשלה — pymongo Database לא תומך ב-bool()
claude Jul 26, 2026
ace434f
Merge origin/main: יישור מול main אחרי squash של #3197 ו-#3198 (פתרון…
claude Jul 26, 2026
49ca0a2
fix(webapp): Config Inspector — שחזור רווחים, סינון בעמוד 2, ודיוק שי…
claude Jul 26, 2026
0ed5fd9
fix(bot): סבב review לסקילים — PII בלוגים, ניקוי pending, מכסות, ושאי…
claude Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions conversation_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,22 @@ def _cleanup_zip_state(context: ContextTypes.DEFAULT_TYPE) -> None:
context.user_data.pop(key, None)


def _count_and_save_skill(raw: bytes, user_id: int, original_name: str):
"""סופר קבצים ב-ZIP ושומר כסקיל — הכל בפעולה אחת שרצה ב-thread (לא חוסם את ה-event loop).

הספירה לתצוגה בלבד (קריאה; אינה משנה את ה-bytes הנשמרים); בכשל ספירה נופלים ל-0.
"""
file_count = 0
try:
import zipfile as _zipfile
with _zipfile.ZipFile(BytesIO(raw)) as _zf:
file_count = sum(1 for n in _zf.namelist() if not n.endswith("/"))
except Exception:
file_count = 0
md = {"user_id": user_id, "original_name": original_name, "file_count": file_count}
return skill_manager.save_skill_bytes(raw, md)


async def _handle_zip_route(update: Update, context: ContextTypes.DEFAULT_TYPE, data: str) -> None:
"""מטפל בבחירת יעד ל-ZIP שהועלה: '📝 סקיל' (אחסון קבוע as-is) או '📦 גיבוי' (רשימת הגיבויים).

Expand Down Expand Up @@ -908,16 +924,7 @@ async def _handle_zip_route(update: Update, context: ContextTypes.DEFAULT_TYPE,
original_name = entry.get("original_name") or "upload.zip"

if data.startswith("zip_route_skill:"):
# ספירת קבצים לתצוגה בלבד (קריאה; אינה משנה את ה-bytes הנשמרים)
file_count = 0
try:
import zipfile as _zipfile
with _zipfile.ZipFile(BytesIO(raw)) as _zf:
file_count = sum(1 for n in _zf.namelist() if not n.endswith("/"))
except Exception:
file_count = 0
md = {"user_id": user_id, "original_name": original_name, "file_count": file_count}
skill_id = await asyncio.to_thread(skill_manager.save_skill_bytes, raw, md)
skill_id = await asyncio.to_thread(_count_and_save_skill, raw, user_id, original_name)
if skill_id:
# ניקוי רק לאחר שמירה מוצלחת — בכשל שומרים את ה-token/bytes כדי לאפשר retry
cleanup_pending_zip(entry.get("path", ""))
Expand Down
16 changes: 14 additions & 2 deletions docs/environment-variables.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1759,13 +1759,25 @@
- לא
- ``mongo``
- ``fs``
- Bot/WebApp
- Bot
* - ``BACKUPS_DIR``
- נתיב גיבויים בלוקאל (אם ``BACKUPS_STORAGE=fs``); אחרת נבחר אוטומטית.
- לא
- נתיב ברירת מחדל (``/app/backups``)
- ``/var/lib/codebot/backups``
- Bot/WebApp
- Bot
* - ``SKILLS_MAX_PER_USER``
- מכסת מספר סקילים מרבי למשתמש (``0`` = בלי מגבלה); לסקילים אין retention.
- לא
- ``100``
- ``50``
- Bot
* - ``SKILLS_MAX_TOTAL_BYTES``
- מכסת נפח סקילים כוללת למשתמש בבייטים (``0`` = בלי מגבלה).
- לא
- ``1073741824`` (1GB)
- ``536870912``
- Bot
* - ``BACKUPS_SHOW_ALL_IF_EMPTY``
- כאשר ``true`` מאפשר לממשק להציג את כל הקבצים גם כשאין פילטר (שימושי ל-ops).
- לא
Expand Down
182 changes: 137 additions & 45 deletions file_manager.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from typing import Any, BinaryIO, Dict, List, Optional, Tuple, Set
from dataclasses import dataclass
import os
import tempfile
import zipfile
Expand Down Expand Up @@ -428,7 +429,9 @@ def _get_gridfs(self):
mongo_db = None
if get_files_facade is not None:
mongo_db = get_files_facade().get_mongo_db()
if not mongo_db:
# חשוב: השוואה ל-None בלבד (ולא bool). אובייקט Database של pymongo זורק
# NotImplementedError על bool()/not — היה נבלע ב-except ומחזיר None בשקט.
if mongo_db is None:
return None
# אוסף ייעודי "backups"
return gridfs.GridFS(mongo_db, collection="backups")
Expand Down Expand Up @@ -1274,19 +1277,17 @@ def delete_backup(self, backup_id: str, user_id: int) -> bool:
return False


@dataclass
class SkillInfo:
"""מידע על סקיל שמור (ארכיון קוד לטווח ארוך, נפרד לגמרי מגיבויים)"""
def __init__(self, skill_id: str, user_id: int, created_at: datetime, file_count: int,
total_size: int, original_name: str, file_name: str,
metadata: Optional[Dict[str, Any]]):
self.skill_id = skill_id
self.user_id = user_id
self.created_at = created_at
self.file_count = file_count
self.total_size = total_size
self.original_name = original_name # השם המקורי המלא (לתצוגה)
self.file_name = file_name # שם ה-GridFS הייחודי בפועל
self.metadata = metadata
skill_id: str
user_id: int
created_at: datetime
file_count: int
total_size: int
original_name: str # השם המקורי המלא (לתצוגה)
file_name: str # שם ה-GridFS הייחודי בפועל
metadata: Optional[Dict[str, Any]]


class SkillManager:
Expand All @@ -1298,6 +1299,8 @@ class SkillManager:
- אין מחיקת retention ואין restore — סקיל לא נמחק לבד לעולם.
"""

_indexes_ensured = False # דגל תהליכי — יצירת האינדקסים מנוסה פעם אחת בלבד

def _get_skills_gridfs(self):
"""מחזיר GridFS על קולקציית "skills" (מבודדת מ-"backups"), או None אם אין חיבור מונגו."""
if gridfs is None:
Expand All @@ -1306,13 +1309,46 @@ def _get_skills_gridfs(self):
mongo_db = None
if get_files_facade is not None:
mongo_db = get_files_facade().get_mongo_db()
if not mongo_db:
# חשוב: השוואה ל-None בלבד. אובייקט Database של pymongo זורק NotImplementedError
# על bool()/not, וזה היה נבלע ב-except ומחזיר None ("GridFS 'skills' לא זמין").
if mongo_db is None:
return None
self._ensure_indexes(mongo_db)
# אוסף ייעודי "skills" — מבודד לחלוטין; cleanup_expired_backups לעולם לא נוגע בו
return gridfs.GridFS(mongo_db, collection="skills")
except Exception:
return None

@classmethod
def _ensure_indexes(cls, mongo_db) -> None:
"""אינדקסים על skills.files לשאילתות המנהל: לפי בעלים (list/delete) ולפי skill_id (הורדה)."""
if cls._indexes_ensured:
return
cls._indexes_ensured = True
try:
coll = mongo_db["skills.files"]
coll.create_index(
[("metadata.user_id", 1), ("metadata.skill_id", 1)],
name="skills_user_skill_idx", background=True,
)
coll.create_index([("metadata.skill_id", 1)], name="skills_skill_id_idx", background=True)
except Exception:
# best-effort — היעדר אינדקס לא חוסם שמירה/שליפה
logger.warning("skills: יצירת אינדקסים נכשלה", exc_info=True)

@staticmethod
def _limits() -> Tuple[int, int]:
"""מכסות פר-משתמש (0 = בלי מגבלה): מספר סקילים מרבי וסך בייטים מרבי."""
try:
max_count = int(os.getenv("SKILLS_MAX_PER_USER", "100") or 0)
except Exception:
max_count = 100
try:
max_bytes = int(os.getenv("SKILLS_MAX_TOTAL_BYTES", str(1024 ** 3)) or 0)
except Exception:
max_bytes = 1024 ** 3
return max_count, max_bytes

@staticmethod
def _unique_filename(original_name: str) -> str:
"""שם קובץ ייחודי לאחסון: השם המקורי אחרי סניטציה + סיומת ייחוד קצרה.
Expand Down Expand Up @@ -1348,8 +1384,20 @@ def save_skill_bytes(self, data: bytes, metadata: Dict[str, Any]) -> Optional[st
try:
user_id = int(raw_uid)
except (TypeError, ValueError):
logger.warning("save_skill_bytes: user_id לא תקין (%r)", raw_uid)
# בלי הערך הגולמי — מזהה משתמש הוא PII; סוג הערך מספיק לדיבוג
logger.warning("save_skill_bytes: user_id לא תקין (type=%s)", type(raw_uid).__name__)
return None
# אכיפת מכסות פר-משתמש לפני כתיבה (אין retention — בלי מכסה האחסון גדל ללא גבול)
max_count, max_bytes = self._limits()
if max_count > 0 or max_bytes > 0:
existing = list(fs.find({"metadata.user_id": user_id}))
if max_count > 0 and len(existing) >= max_count:
logger.info("save_skill_bytes: חריגה ממכסת מספר סקילים (%d)", max_count)
return None
total = sum(int(getattr(d, "length", 0) or 0) for d in existing)
if max_bytes > 0 and total + len(data) > max_bytes:
logger.info("save_skill_bytes: חריגה ממכסת נפח סקילים (%d bytes)", max_bytes)
return None
original_name = metadata.get("original_name") or "skill.zip"
# מזהה לוגי ייחודי מובטח: timestamp לקריאות + uuid קצר למניעת התנגשות (כולל אותה מילישנייה)
skill_id = metadata.get("skill_id") or f"skill_{user_id}_{int(time.time())}_{uuid.uuid4().hex[:6]}"
Expand All @@ -1368,50 +1416,89 @@ def save_skill_bytes(self, data: bytes, metadata: Dict[str, Any]) -> Optional[st
logger.warning(f"save_skill_bytes failed: {e}")
return None

@staticmethod
def _normalize_user_id(user_id) -> Optional[int]:
"""נרמול user_id ל-int (השמירה תמיד כ-int; קלט str לא היה מוצא כלום בשאילתות)."""
try:
return int(user_id)
except (TypeError, ValueError):
return None

@staticmethod
def _doc_to_skill_info(fdoc, user_id: int) -> Optional[SkillInfo]:
"""בונה SkillInfo ממסמך GridFS (משותף ל-list_skills ול-get_skill_info)."""
md = getattr(fdoc, 'metadata', None) or {}
skill_id = md.get("skill_id") or str(getattr(fdoc, "_id", ""))
if not skill_id:
return None
created_at = None
created_str = md.get("created_at")
if created_str:
with suppress(Exception):
created_at = datetime.fromisoformat(created_str)
if not created_at:
created_at = getattr(fdoc, 'uploadDate', None)
if not created_at:
created_at = datetime.now(timezone.utc)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
return SkillInfo(
skill_id=skill_id,
user_id=user_id,
created_at=created_at,
file_count=int(md.get("file_count") or 0),
total_size=int(getattr(fdoc, 'length', 0) or 0),
original_name=md.get("original_name") or (getattr(fdoc, 'filename', None) or skill_id),
file_name=getattr(fdoc, 'filename', None) or "",
metadata=md,
)

def list_skills(self, user_id: int) -> List[SkillInfo]:
"""מחזיר את כל הסקילים של המשתמש (מטא-דאטה בלבד — Smart Projection, בלי משיכת bytes)."""
results: List[SkillInfo] = []
uid = self._normalize_user_id(user_id)
if uid is None:
return results
try:
fs = self._get_skills_gridfs()
if fs is None:
return results
# שאילתה ממוקדת לפי בעלים (user_id נשמר תמיד כ-int בשמירה)
for fdoc in fs.find({"metadata.user_id": user_id}):
for fdoc in fs.find({"metadata.user_id": uid}):
try:
md = getattr(fdoc, 'metadata', None) or {}
skill_id = md.get("skill_id") or str(getattr(fdoc, "_id", ""))
if not skill_id:
continue
created_at = None
created_str = md.get("created_at")
if created_str:
with suppress(Exception):
created_at = datetime.fromisoformat(created_str)
if not created_at:
created_at = getattr(fdoc, 'uploadDate', None)
if not created_at:
created_at = datetime.now(timezone.utc)
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
results.append(SkillInfo(
skill_id=skill_id,
user_id=user_id,
created_at=created_at,
file_count=int(md.get("file_count") or 0),
total_size=int(getattr(fdoc, 'length', 0) or 0),
original_name=md.get("original_name") or (getattr(fdoc, 'filename', None) or skill_id),
file_name=getattr(fdoc, 'filename', None) or "",
metadata=md,
))
info = self._doc_to_skill_info(fdoc, uid)
if info is not None:
results.append(info)
except Exception:
continue
results.sort(key=lambda s: s.created_at, reverse=True)
except Exception as e:
logger.warning(f"list_skills failed: {e}")
return results

def get_skill_info(self, user_id: int, skill_id: str) -> Optional[SkillInfo]:
"""מטא-דאטה של סקיל בודד בשאילתה ממוקדת (בלי סריקת כל הסקילים ובלי משיכת bytes)."""
uid = self._normalize_user_id(user_id)
if uid is None or not skill_id:
return None
try:
fs = self._get_skills_gridfs()
if fs is None:
return None
for fdoc in fs.find({"metadata.user_id": uid, "metadata.skill_id": skill_id}):
info = self._doc_to_skill_info(fdoc, uid)
if info is not None:
return info
return None
except Exception as e:
logger.warning(f"get_skill_info failed: {e}")
return None

def get_skill_bytes(self, user_id: int, skill_id: str) -> Optional[bytes]:
"""מחזיר את ה-bytes המדויקים של הסקיל (אחרי אימות בעלות), או None אם לא נמצא/לא שייך."""
uid = self._normalize_user_id(user_id)
if uid is None:
return None
try:
fs = self._get_skills_gridfs()
if fs is None:
Expand All @@ -1421,7 +1508,7 @@ def get_skill_bytes(self, user_id: int, skill_id: str) -> Optional[bytes]:
owner = md.get("user_id")
if isinstance(owner, str) and owner.isdigit():
owner = int(owner)
if owner != user_id:
if owner != uid:
continue
return fs.get(fdoc._id).read()
return None
Expand All @@ -1432,17 +1519,22 @@ def get_skill_bytes(self, user_id: int, skill_id: str) -> Optional[bytes]:
def delete_skills(self, user_id: int, skill_ids: List[str]) -> Dict[str, Any]:
"""מוחק סקילים לפי skill_id (רק של המשתמש הנוכחי). מחזיר {deleted, errors}."""
result: Dict[str, Any] = {"deleted": 0, "errors": []}
uid = self._normalize_user_id(user_id)
if uid is None:
result["errors"].append("invalid user_id")
return result
try:
fs = self._get_skills_gridfs()
if fs is None:
result["errors"].append("GridFS 'skills' unavailable")
return result
wanted = set(skill_ids or [])
for fdoc in list(fs.find({"metadata.user_id": user_id})):
if not wanted:
return result
# סינון כבר ב-DB (בעלים + skill_id) — בלי לסרוק את כל הסקילים של המשתמש בפייתון
query = {"metadata.user_id": uid, "metadata.skill_id": {"$in": list(wanted)}}
for fdoc in list(fs.find(query)):
try:
md = getattr(fdoc, 'metadata', None) or {}
if md.get("skill_id") not in wanted:
continue
fs.delete(fdoc._id)
result["deleted"] += 1
except Exception as e:
Expand Down
30 changes: 19 additions & 11 deletions handlers/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -1009,9 +1009,10 @@ async def _maybe_store_zip_copy(

original_name = document.file_name or "upload.zip"

# ניקוי עצל: קבצים ממתינים ישנים (שלא נבחרו) — גם על הדיסק וגם ב-user_data
# ניקוי עצל: קבצים ממתינים ישנים (שלא נבחרו) — גם על הדיסק וגם ב-user_data.
# הסריקה סינכרונית (glob+stat) — רצה ב-thread כדי לא לחסום את לולאת האירועים.
try:
cleanup_stale_pending_zips()
await asyncio.to_thread(cleanup_stale_pending_zips)
except Exception:
pass
pending = context.user_data.setdefault("pending_zip", {})
Expand All @@ -1036,7 +1037,8 @@ async def _maybe_store_zip_copy(

token = _uuid.uuid4().hex
try:
path = stash_pending_zip_bytes(raw_bytes, token)
# כתיבה לדיסק — ב-thread כדי לא לחסום את לולאת האירועים
path = await asyncio.to_thread(stash_pending_zip_bytes, raw_bytes, token)
except Exception as err:
logger.warning("Failed to stash pending ZIP: %s", err)
return False
Expand All @@ -1053,14 +1055,20 @@ async def _maybe_store_zip_copy(
InlineKeyboardButton("📦 גיבוי", callback_data=f"zip_route_backup:{token}"),
]
])
await update.message.reply_text(
f"📦 קיבלתי קובץ ZIP: <code>{html_escape(original_name)}</code>\n"
"איך לשמור אותו?\n\n"
"📝 <b>סקיל</b> — אחסון קבוע, בדיוק כמו שהוא (byte-for-byte), בלי מחיקה אוטומטית.\n"
"📦 <b>גיבוי</b> — נשמר לרשימת הגיבויים (כפוף למדיניות ניקוי).",
reply_markup=keyboard,
parse_mode=ParseMode.HTML,
)
try:
await update.message.reply_text(
f"📦 קיבלתי קובץ ZIP: <code>{html_escape(original_name)}</code>\n"
"איך לשמור אותו?\n\n"
"📝 <b>סקיל</b> — אחסון קבוע, בדיוק כמו שהוא (byte-for-byte), בלי מחיקה אוטומטית.\n"
"📦 <b>גיבוי</b> — נשמר לרשימת הגיבויים (כפוף למדיניות ניקוי).",
reply_markup=keyboard,
parse_mode=ParseMode.HTML,
)
except Exception:
# בלי כפתורים אין דרך לממש את הבחירה — מנקים את הרשומה והקובץ שנוצרו עבור ה-ZIP הזה
cleanup_pending_zip(path)
pending.pop(token, None)
raise
return True
except Exception:
pass
Expand Down
Loading
Loading