רב תחומי עמל הולץ חיל האוויר

Cloud Storage

מערכת אחסון קבצים בענן עם ניהול הרשאות ושיתוף
שם העבודה: Cloud Storage – אחסון ושיתוף קבצים
שם התלמידה: מאי
ת.ז. התלמידה: [ת.ז.]
שם המנחה: דוד פרוכט
שם החלופה: הגנת סייבר ומערכות הפעלה
סמל מקצוע: 883589
תאריך ההגשה: [תאריך ההגשה]

תוכן עניינים

  1. מבוא
    1. ייזום הפרויקט
    2. פירוט תיאור המערכת (אפיון)
    3. תיאור תחום הידע
  2. מבנה / ארכיטקטורה
    1. ארכיטקטורת חומרה
    2. סקירת טכנולוגיות
    3. תרשימי זרימת מידע
    4. תיאור אלגוריתמים מרכזיים
    5. סביבת פיתוח
    6. פרוטוקול תקשורת
    7. מסכי המערכת ותרשים זרימת מסכים
    8. מבני נתונים ומסדי נתונים
    9. סקירת חולשות ואיומים
  3. מימוש הפרויקט
    1. חלק א' – סקירת מודולים ומחלקות
    2. חלק ב' – קטעי קוד מרכזיים
    3. חלק ג' – מסמך בדיקות מלא
  4. מדריך למשתמש
    1. עץ קבצי המערכת
    2. התקנת המערכת
    3. הפעלה לפי סוג משתמש
  5. סיכום אישי / רפלקציה
  6. ביבליוגרפיה
  7. נספחים
    1. נספח א' – הסבר טכנולוגיות
    2. נספח ב' – צירוף קוד הפרויקט המלא

מבוא

ייזום הפרויקט

תקציר ורציונל

Cloud Storage הוא יישום-רשת מלא (Full-Stack) המאפשר למשתמשים לאחסן, לארגן, לחפש ולשתף קבצים בענן באמצעות דפדפן רגיל. הפרויקט נבנה כשירות אחסון ענן פרטי ברוח Google Drive או Dropbox, אך בקנה מידה לימודי, תוך שימוש ב-Flask (Python) בצד השרת, ב-Google Firestore כבסיס נתונים מסוג NoSQL, וב-Google Cloud Storage (GCS) כאחסון אובייקטים בענן. ממשק המשתמש נבנה בדפדפן באמצעות HTML, CSS ו-JavaScript נטולי framework, מתוך החלטה מודעת ללמוד את הטכנולוגיות הבסיסיות לעומקן.

המוצר המוגמר מאפשר למשתמש להירשם, להתחבר, להעלות ולהוריד קבצים, ליצור היררכיית תיקיות, לשתף קבצים עם משתמשים אחרים, לסמן מועדפים, לחפש בקבצים ובתיקיות באופן רקורסיבי, לבצע פעולות בכמות (Bulk) של מחיקה / העברה / העתקה / הורדה ל-ZIP, להחזיר פריטים שנמחקו מסל מיחזור (Recycle Bin) בעל תוקף של 30 יום, ולנהל פרופיל אישי הכולל תמונת פרופיל. בנוסף, קיים אזור ניהול (Admin Dashboard) הזמין רק למשתמש המערכת admin, ומציג סטטיסטיקות מערכת וניהול משתמשים.

הבחירה בפרויקט נעשתה משום שהוא משלב מספר תחומי לימוד מרכזיים של החלופה: רשתות תקשורת (בקשות HTTP על TCP/IP, פרוטוקול REST, JWT), מערכות הפעלה (קבצים, תהליכים, מערכת קבצים מבוזרת בענן, ניהול זיכרון בעת יצירת ZIP-in-memory), ו-הגנת סייבר (אימות זהות, הצפנת סיסמאות, בקרת גישה, הצפנה בתעבורה, ניהול אסימוני JWT). האתגרים שצפיתי – ושנפגשתי איתם בפועל – כללו את ניהול ההזדהות מול שרת ועקירה של אסימוני JWT שפגו, מניעת התנגשויות בשמות קבצים בענן, מימוש מחיקה רכה (soft delete) עם שחזור, ומימוש פעולות רקורסיביות (הורדת תיקייה כ-ZIP כולל תתי-תיקיות) באופן יעיל.

הגדרת הלקוח

המערכת מיועדת לכל משתמש קצה שמעוניין באחסון קבצים אישי בענן, באופן דומה לשירותי אחסון ענן מסחריים, וכן לארגונים קטנים שזקוקים לפתרון אחסון פנימי המאפשר שיתוף קבצים בתוך הצוות. הלקוח האופייני הוא משתמש שאינו טכני, ולכן המערכת תוכננה עם דגש על ממשק נקי, בהיר, רספונסיבי, ועם מצבי בהיר/כהה (Dark / Light Theme). המערכת תומכת בשלושה סוגי משתמשים:

יעדים ומטרות

בעיות, תועלות וחסכונות

הבעיה המרכזית שהפרויקט פותר היא הצורך באחסון קבצים מרוחק, נגיש מכל מכשיר, ללא תלות במחשב מקומי, אך עדיין פרטי לאדם הספציפי. תועלות שהמשתמש מקבל:

סקירת פתרונות קיימים

מאפיין Google Drive Dropbox OneDrive Cloud Storage (פרויקט זה)
סוג אחסוןענן (Google)ענן (AWS)ענן (Azure)ענן (GCS)
קוד פתוחלאלאלאכן – מימוש לימודי מלא
שיתוף לפי שם משתמשלפי דוא"ללפי דוא"ללפי דוא"ללפי שם משתמש
סל מיחזור30 יום30 יום30 יום30 יום
מועדפיםישישישיש
חיפוש רקורסיביישישישיש
פעולות Bulkישישישיש (Download/Move/Copy/Delete)
פאנל ניהוללמנהל ארגוןלמנהל ארגוןלמנהל ארגוןלמשתמש admin

ההשוואה ממחישה שהפרויקט מכסה את כל היכולות המרכזיות של פתרונות מסחריים מובילים, אם כי בקנה מידה לימודי. ההבדל הבולט הוא העובדה שכאן ניתן לבחון את הקוד המלא וללמוד כיצד כל יכולת ממומשת – דבר שלא ניתן בפתרונות ה-Closed-Source.

סקירת טכנולוגית הפרויקט

הפרויקט אינו עושה שימוש בטכנולוגיה בלתי-מוכרת, אלא משלב מספר רב של טכנולוגיות בוגרות וסטנדרטיות. למרות זאת, חלק מהבחירות הציבו אתגרים לימודיים משמעותיים: Firebase Admin SDK ו-Google Cloud Storage חייבו לימוד מודל הזדהות מבוסס Service Account ומודל המסמכים של Firestore (בניגוד למודל הטבלאות הרלציוני). PyJWT חייב הבנה של אופן החתימה וההצפנה של אסימוני JWT באלגוריתם HS256. Werkzeug security שימש לגיבוב סיסמאות באמצעות pbkdf2:sha256. מגבלת קצה מרכזית היא הצורך בקובץ Service-Account-Key פעיל של Firebase, ללא קובץ זה השרת אינו עולה.

תיחום הפרויקט

הפרויקט מתמקד בבניית שירות אחסון בענן עם דגש מובהק על רשתות תקשורת (HTTP/REST על TCP, ניהול חיבורי Socket בשרת Flask, פרוטוקול CORS) ו-מערכות הפעלה (קבצים בענן, ניהול נתיבים, שימוש בזיכרון בעת יצירת ZIP-in-memory, soft delete במערכת הקבצים). תחומים שאינם בטיפול המערכת:

פירוט תיאור המערכת (אפיון)

תיאור מפורט

המערכת מורכבת משני רכיבים מרכזיים: שרת REST מבוסס Flask המפעיל 11 Blueprints (מודולי ניתוב), המאזין לפורט 5000, ו-לקוח דפדפן בעל 6 עמודים נפרדים (Index, Login, Signup, Home, Profile, Admin), כל אחד עם HTML / CSS / JS משלו. הלקוח שומר את אסימון ה-JWT ב-localStorage ומצרף אותו ככותרת Authorization: Bearer <token> לכל בקשה מוגנת. השרת מאמת את האסימון בעזרת מעטפת @token_required (או @admin_required למסלולים שמורים למנהל) ומחזיר 401/403 אם האסימון פג או לא קיים. כל הנתונים מאוחסנים ב-Firestore (NoSQL) ובקבצים עצמם ב-GCS bucket בשם clientstorage-6978a.firebasestorage.app.

יכולות לפי סוג משתמש

סוג משתמשיכולות זמינות
אורחצפייה בעמוד הראשי, הרשמה (Signup), התחברות (Login).
משתמש רשום ניהול קבצים אישיים (העלאה, הורדה, מחיקה רכה), ניהול תיקיות (יצירה, מחיקה רכה, הורדה כ-ZIP), חיפוש רקורסיבי, מועדפים (סימון / ביטול), שיתוף עם משתמשים אחרים, פעולות Bulk (הורדה/העברה/העתקה/מחיקה), סל מיחזור (צפייה, שחזור, ריקון), ניהול פרופיל (שם פרטי, שם משפחה, תמונה), צפייה בפריטים ששותפו איתו, התנתקות (Logout).
admin כל הפעולות של משתמש רגיל + צפייה בסטטיסטיקות מערכת (סך משתמשים, סך קבצים, סך אחסון, סך שיתופים), צפייה בכל המשתמשים הרשומים והפרטים שלהם, צפייה בכל הקבצים במערכת ומחיקת משתמשים (כולל כל הקבצים, התיקיות, השיתופים והמועדפים שלהם).

תכנון בדיקות (קופסה שחורה)

תוכננו לפרויקט 28 בדיקות עיקריות שאמורות לכסות את כל היכולות. בפרק 5.3 (מסמך הבדיקות) מובא הפירוט המלא של כל בדיקה: מטרה, מה בוצע, תוצאות, ובעיות שהתגלו וכיצד נפתרו. תקציר הבדיקות:

  1. הרשמה תקינה
  2. הרשמה עם שם משתמש קיים
  3. הרשמה עם סיסמה קצרה מ-6 תווים
  4. התחברות עם פרטים נכונים
  5. התחברות עם פרטים שגויים
  6. אסימון JWT שפג תוקף
  7. גישה לעמוד מוגן ללא token
  8. העלאת קובץ תקין
  9. העלאת קובץ בשם זהה לקובץ קיים
  10. העלאת קובץ גדול מהמותר (16MB)
  11. יצירת תיקייה חדשה
  12. יצירת תיקייה בשם זהה לקיימת
  13. ניווט להיררכיית תתי-תיקיות
  14. הורדת קובץ בודד
  15. הורדת תיקייה כ-ZIP (כולל תתי-תיקיות)
  16. מחיקת קובץ → מעבר לסל מיחזור
  17. שחזור מסל מיחזור
  18. ריקון סל מיחזור
  19. שיתוף קובץ עם משתמש קיים
  20. ניסיון שיתוף עם משתמש לא קיים
  21. הסרת שיתוף
  22. חיפוש רקורסיבי
  23. סימון / ביטול מועדפים
  24. פעולת Bulk Download
  25. פעולת Bulk Move עם בדיקת loop
  26. העלאת תמונת פרופיל
  27. גישה ל-Admin עם משתמש שאינו admin
  28. מחיקת משתמש דרך פאנל ה-Admin

תכנון וניהול לו"ז הפיתוח

הפרויקט פותח במהלך כשנת לימודים. הטבלה הבאה מציגה את אבני הדרך המרכזיות, על בסיס היסטוריית ה-commit-ים שתועדה ב-Git:

אבן דרךתכולהסטטוס בפועל
1. תשתית בסיסיתהקמת שרת Flask, עמוד Login, אימות בסיסיהושלמה (commits 695d09f, 1c564b9, 8dc170b, 8ed4e6c)
2. מעבר ל-Firestoreמעבר מ-MongoDB המתוכנן ל-Firestore + GCSהושלמה (commits c3e1d60, 1284afb, fd268c8) – שינוי טכנולוגיה משמעותי
3. ארגון מבנה הלקוחהפרדה לתיקיות לפי עמודהושלמה (commit f3af574 + תיקון 196f8a2)
4. קבצים ותיקיותטבלת קבצים, מחיקה, drag-and-drop, GCS upload, יצירת תיקיות, ZIPהושלמה (commits 0f4c7fb, c22c79f, eda1cd8, e3d8d7e, 0a81ac9)
5. ניהול חשבוןעמוד הרשמה, פרופיל אישיהושלמה (commits 0acb4b9, 2664335)
6. תיקון בעיות הזדהותבעיית redirect-loop בין Login ל-Homeהושלמה (commits 77674c5, 7c6e442) – באג מאתגר במיוחד
7. שיתוף קבציםשיתוף עם משתמשים אחרים, שיתוף תיקיותהושלמה (commit c0efd6c + 5505703)
8. מועדפים, חיפוש, סל מיחזורחיפוש רקורסיבי, סימון מועדפים, soft delete 30 ימיםהושלמה (commits b4daed8, 9a4407b, 42826d1, 6e1fbf9)
9. עיצוב מחודשDark/Light themeהושלמה (commit c1c39ca)
10. מיון, Bulk, ניהולמיון עמודות, פעולות Bulk, פאנל מנהלהושלמה (commits 85b97c8, d1e821a, 88b7ef4)
11. שיפורי איכותRefactor של app.py למבנה Blueprints, מירכוז Modal, טיפול בשגיאות uploadהושלמה (commits 8298772, 2be6774, b6e4cda)

ניהול סיכונים

סיכוןתוכנית מקוריתביצוע בפועל
אובדן מפתח Firebase (Service Account Key) גיבוי מקומי + תיעוד הוספת הקובץ ל-.gitignore בסניף הציבורי, גיבוי אישי. (הערה: בפועל הקובץ קיים ברפו, ראו פרק 4.9.)
פגיעה במקביליות (Race condition) בהעלאות שם זהה בדיקת קיום שם זהה לפני העלאה מימוש בדיקת duplicate filename ב-routes/files.py לפני כתיבה ל-GCS, וגם prefix של חותמת זמן ב-blob path למניעת התנגשות פיזית.
אסימון JWT דולף או נגנב תפוגה קצרה + HTTPS בלבד תפוגה של 24 שעות; אכיפת HTTPS לא מתבצעת בסביבת הפיתוח – פערים מתועדים בפרק 4.9.
איבוד נתונים בגלל מחיקה בטעות אישור מחיקה ב-UI בנוסף ל-Confirm dialog, מומש סל מיחזור עם 30 יום שחזור (commit 6e1fbf9).
שגיאות נטוורק מצד הלקוח הצגת הודעה למשתמש מומש try/catch סביב כל fetch + הצגת error message בעברית/אנגלית עם אפשרות לרענון.
הצפת המערכת בגלל קבצים גדולים מאוד הגבלת גודל מקסימלי מומש ב-config.py: MAX_CONTENT_LENGTH = 16MB.

תיאור תחום הידע (ניתוח יכולות)

חלק זה מציג את היכולות בשני הצדדים — שרת ולקוח — בפורמט מובנה. עבור כל יכולת מצוין שמה, מהותה, אוסף תת-היכולות הנדרשות למימושה, והאובייקטים העיקריים שמעורבים בה.

יכולות בצד שרת

1. הרשמה (Signup)

מהות: רישום משתמש חדש – קליטת firstName / lastName / username / password, אימות שדות, גיבוב סיסמה, יצירת מסמך משתמש ב-Firestore, ויצירת JWT.

תת-יכולות: אימות שדות (אורך, אלפא-נומרי), בדיקת ייחודיות שם משתמש, גיבוב pbkdf2:sha256, הוספה ל-collection users, חתימת JWT HS256 עם תפוגה.

אובייקטים נחוצים: Flask request, users_collection, generate_password_hash, jwt.encode.

2. התחברות (Login)

מהות: אימות username + password אל מול Firestore, וייצור אסימון JWT.

תת-יכולות: שליפת המסמך לפי שם משתמש, השוואת סיסמה ב-check_password_hash, יצירת JWT עם exp = now + 24h, החזרה ללקוח.

אובייקטים נחוצים: request, users_collection, check_password_hash, jwt.encode.

3. אימות אסימון (Verify Token)

מהות: בדיקה האם JWT שנשלח בכותרת Authorization תקף; כל בקשה למסלול מוגן עוברת דרך המעטפת.

תת-יכולות: חילוץ Bearer Token מה-header, פענוח JWT, איתור המסמך של המשתמש, החזרת current_user לפונקציה העטופה.

אובייקטים נחוצים: @token_required, jwt.decode, users_collection.

4. העלאת קובץ (Upload File)

מהות: קבלת multipart/form-data, אימות תיקיית יעד, בדיקת שם כפול, העלאה ל-GCS, ושמירת מטא-דאטה ב-Firestore.

תת-יכולות: secure_filename, בדיקת בעלות על directory_id, סריקת duplicate filename ב-collection files, יצירת נתיב GCS עם timestamp prefix, blob.upload_from_string, files_collection.add(metadata).

אובייקטים נחוצים: gcs_bucket, db.collection('files'), directories_collection, datetime.

5. הורדת תיקייה כ-ZIP (רקורסיבית)

מהות: איסוף רקורסיבי של כל הקבצים והתתי-תיקיות, הורדה מ-GCS לזיכרון, וייצוא ZIP יחיד.

תת-יכולות: סריקת files לפי directory_id, מעבר רקורסיבי לתתי-תיקיות, blob.download_as_bytes, יצירת zipfile.ZipFile ב-io.BytesIO, send_file.

אובייקטים נחוצים: directories_collection, files_collection, gcs_bucket, zipfile, io.BytesIO.

6. שיתוף פריט (Share)

מהות: רישום זוג (item, target_user) ב-collection shares לאחר אימות בעלות, אימות שהנמען קיים, ובדיקת כפילות.

תת-יכולות: חיפוש קובץ/תיקייה, אימות בעלות, חיפוש משתמש יעד, בדיקת share קיים, יצירת מסמך share חדש.

אובייקטים נחוצים: shares_collection, users_collection, files_collection/directories_collection.

7. מחיקה רכה (Soft Delete)

מהות: במקום מחיקה פיזית, יצירת רשומת recycle-bin עם expires_at = now + 30d; הקובץ הפיזי ב-GCS נשאר עד פקיעת התוקף.

תת-יכולות: אימות בעלות, חישוב expiration_date, יצירת מסמך ב-recycle_bin כולל original_data, מחיקת המסמך מה-collection המקורי.

אובייקטים נחוצים: recycle_bin_collection, files_collection/directories_collection, datetime.timedelta(days=30).

8. ניקוי פריטים שפגו (cleanup_expired_items)

מהות: מחיקה פיזית של פריטים שב-recycle_bin שפג תוקפם — כולל מחיקה מ-GCS. רצה אופורטוניסטית בכניסה ל-endpoints של ה-recycle-bin.

תת-יכולות: שאילתה where('expires_at', '<', now), איטרציה על פריטים, מחיקת ה-blob מ-GCS, מחיקה מ-Firestore.

9. חיפוש רקורסיבי (Search)

מהות: חיפוש בקבצים ובתיקיות לפי תת-מחרוזת, עם הצגת הנתיב המלא לכל תוצאה.

תת-יכולות: שליפת כל הקבצים והתיקיות של המשתמש, בניית map של תיקיות, חישוב נתיב רקורסיבי לפי parent_directory_id, סינון בפייתון לפי q in name.lower().

10. Bulk Download → ZIP

מהות: קבלת רשימת פריטים מעורבת (קבצים ותיקיות), איסוף קבצים רקורסיבית מתוך תיקיות, ויצירת ZIP יחיד.

11. ניהול פרופיל (תמונה)

מהות: קליטת תמונה, אימות סיומת תמונה, מחיקת תמונה קודמת מ-GCS, העלאת תמונה חדשה, עדכון profilePicture במסמך המשתמש.

12. סטטיסטיקות מערכת (Admin Stats)

מהות: ספירת משתמשים, קבצים, תיקיות, שיתופים, וסכום סך נפח אחסון — לצורך תצוגה ב-Dashboard.

יכולות בצד לקוח

1. בדיקת הזדהות בעלייה (Auth-on-load)

מהות: בכל עמוד מוגן (home/profile/admin), ב-DOMContentLoaded הלקוח בודק קיום authToken ב-localStorage; אם חסר, מבצע window.location.replace('/login.html?from=home'). אם קיים, שולח GET /api/auth/verify כדי לוודא תוקף; במקרה של 401 — redirect ל-login.

אובייקטים: localStorage, fetch, window.location.

2. טופס Login / Signup עם ולידציה

מהות: בלוק ולידציה לקליינט (blur, input events) לפני שליחת fetch ל-/api/auth/login או /api/auth/signup. מציג שדה-שדה הודעת שגיאה ספציפית עם ARIA live region לנגישות.

3. Drag & Drop העלאת קובץ

מהות: רכיב uploadZone מאזין ל-dragenter/dragover/dragleave/drop, מציג סטטוס visual highlight, ושולח FormData עם הקובץ ו-directory_id ל-POST /api/upload.

4. ניהול לשוניות (Tabs)

מהות: ארבע לשוניות בעמוד הראשי — My Files / Shared With Me / Favorites / Recycle Bin. החלפה דינמית של תוכן ה-section המוצג ו-fetch של הנתונים המתאימים.

5. ניווט בהיררכיית תיקיות (Breadcrumbs)

מהות: מערך directoryPath שמרכיב breadcrumbs דינמי; לחיצה על breadcrumb מובילה לתיקייה זו ומקצרת את המערך.

6. דיאלוג שיתוף עם חיפוש משתמשים

מהות: בעת הקלדה בתיבת החיפוש, fetch ל-/api/users/search?q=..., הצגת רשימת תוצאות עם אווטאר ושם מלא, הוספה לרשימת המשותפים בלחיצה.

7. בחירה מרובה ופעולות Bulk

מהות: checkboxes לכל שורה, "Select All" בכותרת, מערך selectedItems ב-state, סרגל פעולות (Download ZIP / Move / Copy / Delete) שמופיע כשהבחירה אינה ריקה.

8. מיון עמודות אינטראקטיבי

מהות: לחיצה על כותרת עמודה מסמנת אותה כמיון פעיל, מציגה חץ למעלה/למטה, ומסדרת את הטבלה לפי הערכים. מיון נתמך בכל ארבע הלשוניות.

9. החלפת ערכת נושא (Dark / Light)

מהות: מודול theme.js משותף; כפתור Toggle בכל עמוד; שמירה ב-localStorage תחת המפתח cloudStorageTheme; מצב ברירת מחדל = dark.

10. ניהול תמונת פרופיל

מהות: בעמוד profile.html — העלאה / מחיקה / עריכת שם פרטי ושם משפחה. שולח PUT /api/user/profile ו-POST /api/user/profile/picture.

11. תצוגת Admin Dashboard

מהות: כרטיסי סטטיסטיקות, טבלת משתמשים, טבלת כל הקבצים, ומחיקת משתמש עם confirm modal. גישה רק עבור משתמש admin.

מבנה / ארכיטקטורה של הפרויקט

ארכיטקטורת חומרה

המערכת מורכבת מרכיבי חומרה שמתקשרים ברשת. הלקוח הראשי – דפדפן רגיל – מתקשר ב-HTTP/HTTPS עם שרת Flask, וזה מתקשר מצידו עם שירותי Google Cloud (Firestore + GCS). בנוסף קיים לקוח שולחני (אפליקציית התראות) המתחבר ב-socket TCP גולמי אל אותו תהליך שרת בפורט נפרד (5001) ומקבל דחיפת התראות בזמן אמת. השרטוט הבא מציג את הקישורים:

דפדפן (Client) HTML / CSS / JS localStorage: authToken Chrome / Edge / Firefox Flask Server Python 3.11 + Flask 3.0 localhost:5000 11 Blueprints, JWT, CORS Firestore NoSQL (Document) Metadata storage Google Cloud Storage Object Storage File blobs תחנת המשתמש Windows / Mac / Linux 4GB RAM, חיבור אינטרנט HTTPS / REST JSON + JWT gRPC / TLS HTTPS / TLS מריץ את הדפדפן
איור 4.1 — ארכיטקטורת החומרה והקשרים בין הרכיבים

תיאור הרכיבים:

טופולוגיית התקשורת (כולל ערוץ ה-Push):
Browser   ──REST (HTTP/HTTPS :5000)──►  Flask app  ──┐
                                                     │  אותו תהליך (same process)
Desktop   ──TCP socket (:5001)────────►  NotificationHub (socket_server.py)
Desktop   ◄──push events──────────────  (registry: username → open sockets)
Desktop   ──REST download─────────────►  Flask app

Flask app ──gRPC/TLS──► Firestore (metadata + wrapped keys)
Flask app ──HTTPS/TLS─► Google Cloud Storage (encrypted blobs)
איור 4.1ב — הוספת הלקוח השולחני וערוץ ה-push (socket TCP, פורט 5001) לטופולוגיה

סקירת טכנולוגיות

שכבהטכנולוגיהגרסהתפקיד
שפת תכנות שרתPython3.11+שפת השרת
Web frameworkFlask3.0.0שרת REST API ושירות קבצים סטטיים
CORSflask-cors4.0.0מתן הרשאות Cross-Origin לקריאות מהלקוח
אימותPyJWT2.8.0חתימה ופענוח של אסימוני JWT באלגוריתם HS256
גיבוב סיסמאותWerkzeug.security3.0.1generate_password_hash / check_password_hash (pbkdf2:sha256)
הצפנהcryptographyFernet (AES-128-CBC + HMAC-SHA256) להצפנת קבצים, RSA-2048 / OAEP-SHA256 לעטיפת מפתחות בשיתוף
בסיס נתוניםGoogle Firestore(via firebase-admin 6.5.0)NoSQL document store בענן
אחסון קבציםGoogle Cloud Storage(via google-cloud-storage 2.14.0)אחסון אובייקטים (blobs) — מוצפנים בשכבת מעטפה (envelope) של האפליקציה
שפת לקוחHTML5 / CSS3 / ES6 JavaScriptממשק משתמש (ללא framework)
לקוח שולחניCustomTkinter (Python)5.2.0+אפליקציית התראות שולחנית עם push בזמן אמת
תקשורת RESTHTTP/1.1 + Fetch APIפרוטוקול REST על-גבי TCP (פורט 5000); HTTPS/TLS נתמך בפיתוח דרך mkcert
תקשורת PushRaw TCP Socketפרוטוקול JSON תחום-שורות מתוצרת עצמית (פורט 5001) לדחיפת התראות לשרת↔לקוח שולחני
איחסון לקוחיlocalStorageשמירת authToken, username, cloudStorageTheme
בקרת גרסאותGitניהול קוד והיסטוריה

תחומי עניין מרכזיים: רשתות (HTTP/REST/TCP, socket גולמי דו-כיווני), מערכות הפעלה (קבצים, תהליכים, threads, ניהול זיכרון בעת ZIP), הגנת סייבר (אימות, הצפנה סימטרית ואסימטרית, ניהול מפתחות, בקרת גישה, JWT), בסיסי נתונים (NoSQL Document Model), ארכיטקטורת שרתים מבוססת בלוקים (Blueprints) ושכבת מודלים (OOP).

תרשימי זרימת מידע

זרימת התחברות (Login Flow)

Client Flask Server Firestore POST /api/auth/login {username,password} where('username','==',u) user document check_password_hash → JWT.encode 200 OK + {token, username} localStorage.setItem(authToken)
איור 4.2 — זרימת התחברות (Login)

זרימת העלאת קובץ (Upload Flow)

Client Flask /api/upload Firestore GCS POST multipart + Bearer JWT @token_required decorator check duplicate filename existing files secure_filename + timestamp prefix blob.upload_from_string(content) files_collection.add(metadata) 200 OK + {filename, stored_filename}
איור 4.3 — זרימת העלאת קובץ ל-GCS עם רישום מטא-דאטה ב-Firestore

זרימת מחיקה רכה (Soft Delete Flow)

Client DELETE /api/files/<id> Firestore DELETE + Bearer JWT verify ownership + expires_at=now+30d recycle_bin.add({original_data}) files.delete(id) הקובץ נשאר ב-GCS עד שתפוג הרשומה ב-recycle_bin 200 OK "moved to recycle bin"
איור 4.4 — מחיקה רכה: רישום ב-recycle_bin + מחיקה מקולקציית files (ה-blob נשאר ב-GCS)

זרימת העלאה מוצפנת (Encrypted Upload Flow)

החל מהוספת הצפנת המעטפה (envelope encryption), בייט הקובץ אינו נכתב ל-GCS בטקסט גלוי. כל קובץ מקבל מפתח נתונים ייעודי (DEK), והקובץ מוצפן תחתיו; ה-DEK עצמו "נעטף" (מוצפן) תחת מפתח המסטר (KEK) שהאפליקציה מחזיקה, ונשמר על מסמך הקובץ ב-Firestore.

1.  Client ──POST multipart + Bearer JWT──►  Flask /api/upload
2.  Server: dek = generate_dek()                 # מפתח Fernet אקראי טרי לקובץ זה
3.  Server: ciphertext = encrypt_with_dek(file_bytes, dek)   # AES-128-CBC + HMAC
4.  Server: wrapped_dek = wrap_dek_with_master(dek)          # עטיפת ה-DEK תחת ה-KEK
5.  Server ──blob.upload_from_string(ciphertext)──►  GCS     # נשמר רק הצופן
6.  Server ──files.add({..., wrapped_dek})──►  Firestore     # ה-DEK העטוף נשמר במטא-דאטה
7.  Server ──200 OK──►  Client
    בהורדה: unwrap_dek_with_master(wrapped_dek) → decrypt_with_dek(ciphertext, dek)
איור 4.5 — העלאה מוצפנת: DEK-לכל-קובץ עטוף תחת ה-KEK (envelope encryption)

זרימת שיתוף קריפטוגרפי ו-Push (Cryptographic Share + Push)

בעת שיתוף קובץ, ה-DEK של הקובץ נעטף מחדש תחת המפתח הציבורי (RSA) של הנמען ונשמר על מסמך השיתוף. כך רק הנמען — המחזיק במפתח הפרטי המתאים — יכול לחלץ את ה-DEK ולפענח את הקובץ. במקביל נשלחת התראת push דרך ה-socket אם הנמען מחובר בלקוח השולחני.

1.  Owner ──POST /api/share {item_id, share_with}──►  Flask
2.  Server: dek = unwrap_dek_with_master(file.wrapped_dek)        # חילוץ ה-DEK תחת ה-KEK
3.  Server: share.wrapped_dek = rsa_wrap_dek(dek, recipient.public_key)   # עטיפה למפתח הנמען
4.  Server ──shares.add({owner, shared_with, wrapped_dek})──►  Firestore
5.  Server ──notification_hub.push_to_user(recipient, {"type":"shared", ...})──►  socket
6.  Desktop (אם מחובר): מציג התראה מיידית "שותף איתך קובץ"
    בהורדת הקובץ המשותף:
       private_pem = unwrap_private_key(recipient.wrapped_private_key)   # תחת ה-KEK
       dek = rsa_unwrap_dek(share.wrapped_dek, private_pem)             # RSA-OAEP
       plaintext = decrypt_with_dek(ciphertext, dek)
איור 4.6 — שיתוף קריפטוגרפי: עטיפת ה-DEK תחת RSA הציבורי של הנמען + דחיפת התראה ב-socket

תיאור אלגוריתמים מרכזיים

אלגוריתם 1: גיבוב סיסמאות (Password Hashing)

ניסוח הבעיה: יש לאחסן סיסמאות באופן שלא יאפשר לאף אחד (כולל מנהל המערכת) לשחזר אותן, אך עם זאת לאפשר אימות שהמשתמש הקליד את הסיסמה הנכונה בעת התחברות.

סקירת פתרונות קיימים:

הפתרון הנבחר: pbkdf2:sha256 דרך werkzeug.security.generate_password_hash. הבחירה נומקה ב-(א) זמינות מובנית בספריית התשתית של Flask – ללא תלות נוספת; (ב) תקן NIST מומלץ; (ג) Salt אקראי המוטמע אוטומטית בפלט הגיבוב; (ד) פלט הכולל את האלגוריתם, ה-salt וה-iterations באותה מחרוזת — נוח לאחסון. אלטרנטיבה (bcrypt / Argon2) היתה דורשת התקנת ספרייה נוספת.

מקור: Werkzeug Documentation, Security Helpers.

אלגוריתם 2: חתימה ופענוח JWT (HS256)

ניסוח: יש לתת ללקוח אסימון שיוכל להוכיח באמצעותו את זהותו לכל בקשה עתידית, מבלי שהשרת יצטרך לשמור state פר-משתמש.

פתרונות: Session Cookies (state בשרת), JWT (stateless), OAuth (תלוי בספק חיצוני).

הפתרון הנבחר: JWT עם אלגוריתם HMAC-SHA256 (HS256) דרך ספריית PyJWT. ה-payload כולל username ו-exp (תפוגה לאחר 24 שעות). ה-Secret Key נטען מ-Environment Variable או fallback קבוע (ראו פרק 4.9 – אזור לשיפור בייצור). הבחירה ב-HS256 (ולא ב-RS256) נומקה בכך ששרת בודד מטפל גם בחתימה וגם באימות, ואין צורך באסימטריה.

אלגוריתם 3: הורדה רקורסיבית של תיקייה כ-ZIP

ניסוח: משתמש לחץ "הורד" על תיקייה שמכילה תתי-תיקיות וקבצים – יש לארוז את כל המבנה לקובץ ZIP יחיד.

פתרונות שנשקלו: (א) הורדה של כל קובץ בנפרד וייצוא ZIP ב-client (קשה לסנכרון, מסורבל); (ב) שמירת ZIP זמני בדיסק ב-server (צריך לנקות, רגיש לחיבורים מקבילים); (ג) ZIP-in-memory עם io.BytesIO ו-zipfile.ZipFile ו-send_file.

הפתרון הנבחר: (ג). האלגוריתם:

  1. שאילתת כל הקבצים והתיקיות של המשתמש.
  2. פונקציה רקורסיבית get_all_files_in_directory(dir_id, base_path) שמרכיבה רשימת קבצים שטוחה ולכל אחד את ה-relative path המלא.
  3. איטרציה: עבור כל קובץ, blob.download_as_bytes() ו-zip_file.writestr(arcname, content).
  4. החזרה ללקוח דרך send_file(zip_buffer, as_attachment=True).

היתרון: אין כתיבה לדיסק, אין concurrency issues, וזמן ההורדה מתחיל מיד עם תחילת ה-stream. החיסרון: שימוש זיכרון פרופורציונלי לגודל ה-ZIP – הגביל אותנו לתיקיות בנות עד מאות MB. בייצור היה ראוי לעבור ל-streaming ZIP אמיתי (zipstream).

מקור: Python zipfile module documentation, Flask send_file.

אלגוריתם 4: ניקוי פריטים שפגו מסל המיחזור

ניסוח: פריטים בסל המיחזור צריכים להימחק פיזית 30 יום אחרי המחיקה הלוגית. אין Cron Job בענן, אז יש להפעיל את הניקוי באופן אופורטוניסטי.

הפתרון הנבחר: פונקציה cleanup_expired_items(username) שמופעלת בתחילת כל GET ל-/api/recycle-bin. השאילתה: where('expires_at', '<', now.isoformat()) מסתמכת על העובדה ש-ISO 8601 mortgage לקסיקוגרפי תואם לזמן כרונולוגי. עבור כל פריט: מחיקה מ-GCS, מחיקה מ-Firestore.

אלגוריתם 5: זיהוי לולאת תיקיות ב-Bulk Move

ניסוח: אסור להעביר תיקייה לתוך תת-תיקייה שלה (לולאה אינסופית בעץ).

הפתרון: פונקציה רקורסיבית is_subdirectory(parent_id, check_id) שמטפסת מ-check_id בעץ ה-parents עד שמגיעה ל-parent_id (return True) או ל-root (return False).

אלגוריתם 6: הצפנת מעטפה וניהול מפתחות (Envelope Encryption)

ניסוח: רוצים להצפין כל קובץ במנוחה תחת מפתח של האפליקציה (מעבר להצפנת ברירת-המחדל של הענן), ולאפשר רוטציה זולה של מפתח המסטר מבלי להצפין מחדש כל קובץ. בנוסף, שיתוף קובץ עם משתמש מסוים צריך לאפשר לאותו נמען בלבד לפענח אותו.

הפתרון הנבחר — שלוש שכבות מפתחות:

גבול מודל האיום (דיווח כן): ההגנה עומדת מול גניבה של דלי ה-GCS או של בסיס הנתונים בלבד — התוקף רואה צופן בלבד. היא אינה מגינה מפני פריצה למארח ה-API עצמו (שם יושב ה-KEK ויכול לפתוח כל DEK וכל מפתח פרטי). הצפנה End-to-End אמיתית הייתה דורשת החזקת מפתחות פרטיים בדפדפן — דבר ששובר תצוגה מקדימה/חיפוש/ZIP בצד שרת, ולכן הוצא מהיקף הפרויקט במכוון. תמונות פרופיל ו-blobs ישנים (מלפני ההצפנה) מוצפנים/נקראים דרך מסלול המפתח הגלובלי, עם נפילה-לאחור לבייטים גולמיים לתאימות.

מקור: cryptography (Fernet, RSA-OAEP) — pyca/cryptography documentation; NIST SP 800-57 (Key Management).

אלגוריתם 7: פרוטוקול Push על Socket גולמי (Newline-delimited JSON)

ניסוח: HTTP/REST אינו מאפשר לשרת לדחוף אירוע ללקוח ללא polling. רצינו ערוץ דו-כיווני שבו השרת יודיע מיידית "שותף איתך קובץ".

הפתרון: שרת socket TCP גולמי (מודול socket של ספריית התקן בלבד) הרץ באותו תהליך כמו Flask, בפורט 5001. כל הודעה היא אובייקט JSON אחד ב-UTF-8 המסתיים ב-\n; המקבל צובר בייטים ומפצל על \n (מחלקה _LineReader). ההודעה הראשונה מהלקוח חייבת להיות {"type":"auth","token":"<jwt>"} — נבדקת דרך אותה לוגיקת utils/auth.py של ה-REST, כך שאין מערכת התחברות נפרדת. NotificationHub מחזיק registry של username → set(sockets) (משתמש יכול לפתוח כמה sessions), מוגן ב-Lock; thread נפרד לכל חיבור. פונקציית route ב-Flask קוראת push_to_user(username, event) שכותב את האירוע לכל socket חי של אותו משתמש (best-effort: socket שנכשל מנותק).

מקור: Python socket + threading documentation.

סביבת פיתוח

כלי פיתוח

סביבה וכלים לבדיקות

פרוטוקול תקשורת

המערכת משתמשת ב-HTTP/1.1 על TCP כפרוטוקול תקשורת בסיסי. ההודעות הן REST בפורמט JSON (או multipart/form-data להעלאות קבצים). כל בקשה למסלול מוגן כוללת את הכותרת Authorization: Bearer <JWT>. תגובות מוחזרות עם status codes מקובלים (200, 201, 400, 401, 403, 404, 500). הטבלה הבאה מציגה את כל ההודעות שזורמות במערכת:

טבלת כל ההודעות (API Endpoints)

Authentication (auth_bp / /api/auth)

שם הודעהMethod + Pathנשלחת מ→אלשדות בקשהשדות תגובה
הרשמהPOST /api/auth/signupClient → ServerfirstName, lastName, username, passwordtoken, username, message
התחברותPOST /api/auth/loginClient → Serverusername, passwordtoken, username, message
אימות אסימוןGET /api/auth/verifyClient → ServerHeader: Bearer JWTvalid (bool), username
התנתקותPOST /api/auth/logoutClient → ServerHeader: Bearer JWTmessage

User Profile (user_bp / /api/user, users_bp / /api/users)

שם הודעהMethod + Pathשדות בקשהשדות תגובה
פרופיל אישיGET /api/user/profileJWTusername, firstName, lastName, profilePicture
עדכון פרופילPUT /api/user/profilefirstName?, lastName?, profilePicture?profile object
העלאת תמונהPOST /api/user/profile/picturemultipart fileprofilePicture (URL)
הורדת תמונת פרופילGET /api/user/profile/picture/<filename>filenameimage bytes
תמונת משתמש אחרGET /api/users/<username>/profile-pictureusernameimage bytes

Files (files_bp / /api)

שם הודעהMethod + Pathשדות בקשהשדות תגובה
העלאת קובץPOST /api/uploadmultipart file, directory_id?filename, stored_filename, directory_id, message
רשימת קבציםGET /api/files?directory_id=...directory_id?files[], directories[]
חיפוש רקורסיביGET /api/files/search?q=...qfiles[], directories[] (עם path מלא)
הורדת קובץGET /api/files/<id>/downloadfile_idfile bytes (Content-Disposition: attachment)
מחיקה רכהDELETE /api/files/<id>file_idmessage "moved to recycle bin"

Directories (directories_bp / /api/directories)

שם הודעהMethod + Pathשדות בקשהשדות תגובה
רשימת תיקיותGET /api/directoriesdirectories[]
יצירת תיקייהPOST /api/directoriesname, parent_directory_id?directory object
הורדת תיקייה כ-ZIPGET /api/directories/<id>/downloaddirectory_idZIP bytes
מחיקת תיקייהDELETE /api/directories/<id>directory_idmessage

Sharing (sharing_bp / /api)

שם הודעהMethod + Pathשדות בקשהשדות תגובה
חיפוש משתמשיםGET /api/users/search?q=...q (min 2 chars)users[] (max 10)
שיתוף פריטPOST /api/shareitem_id, item_type, share_withshare object
הסרת שיתוףDELETE /api/share/<item_id>?username=...item_id, usernamemessage
שותפים נוכחייםGET /api/share/<item_id>/usersitem_idusers[]
ששותף איתיGET /api/shared-with-mefiles[], directories[]
הורדת קובץ משותףGET /api/shared-files/<id>/downloadfile_idfile bytes
הורדת תיקייה משותפתGET /api/shared-directories/<id>/downloaddirectory_idZIP bytes

Favorites / Recycle Bin / Bulk / Admin

שם הודעהMethod + Pathתפקיד
סימון מועדףPOST /api/favoritesהוספת פריט למועדפים
הסרת מועדףDELETE /api/favorites/<item_id>הסרת פריט מהמועדפים
רשימת מועדפיםGET /api/favoritesקבלת files+directories שסומנו
סל מיחזורGET /api/recycle-binפריטים שנמחקו (כולל days_remaining)
שחזור פריטPOST /api/recycle-bin/<bin_id>/restoreהחזרה מהסל (כולל ילדים רקורסיבית)
ריקון הסלPOST /api/recycle-bin/emptyמחיקה פיזית של כל הפריטים
Bulk DeletePOST /api/bulk/deleteמחיקה רכה מרובת פריטים
Bulk MovePOST /api/bulk/moveהעברה מרובת פריטים עם בדיקת לולאה
Bulk CopyPOST /api/bulk/copyהעתקה רקורסיבית של קבצים ותיקיות ב-GCS
Bulk Download ZIPPOST /api/bulk/downloadהורדה משולבת של פריטים נבחרים
סטטיסטיקות מנהלGET /api/admin/statsusers / files / storage / shares (admin only)
כל המשתמשיםGET /api/admin/usersusers[] עם file_count + total_storage (admin)
כל הקבציםGET /api/admin/filesfiles[] (admin)
מחיקת משתמשDELETE /api/admin/users/<id>מחיקה מלאה של משתמש וכל נכסיו (admin)
Health CheckGET /api/healthhealthy / message

סה"כ ~40 endpoints מפוצלים ל-11 Blueprints.

פרוטוקול ה-Push על Socket גולמי (פורט 5001)

לצד ה-REST, השרת מפעיל ערוץ שני: socket TCP גולמי בפורט 5001 לדחיפת התראות בזמן אמת ללקוח השולחני. הפרוטוקול הוא JSON תחום-שורות — כל הודעה היא אובייקט JSON אחד ב-UTF-8 המסתיים ב-\n. ההודעה הראשונה מהלקוח חייבת לאמת עם JWT (אותו אסימון של הדפדפן). הטבלה מציגה את כל סוגי ההודעות:

כיווןהודעה (type)תוכן / משמעות
Client → Serverauth{"type":"auth","token":"<jwt>"} — חייבת להיות ההודעה הראשונה
Client → Serverpingkeep-alive / זיהוי ניתוק
Server → Clientauth_ok{"type":"auth_ok","username":"bob"}
Server → Clientauth_errorאסימון לא תקף — השרת סוגר את החיבור מיד אחרי
Server → Clientpongתגובה ל-ping
Server → Clientsharedפריט שותף איתי: item_name, item_type, item_id, owner, shared_at
Server → Clientfile_changedאחד מהקבצים שלי השתנה: filename, directory_id

מסכי המערכת ותרשים זרימת מסכים

תרשים זרימת מסכים (Screen Flow Diagram)

index.html login.html signup.html home.html profile.html My Files / Shared Favorites / Bin admin.html redirect (auto) click "Sign up" click "Log in" login success signup success 401 / no token → redirect קווים שחורים = ניווט רגיל • קו אדום מקווקו = redirect-on-missing-token (חוזר ל-login)
איור 4.5 — Screen Flow Diagram של כל המסכים

פירוט מסכי המערכת

מסך 1: עמוד פתיחה (index.html)

תפקיד: נקודת כניסה — מציג כותרת ומפנה אוטומטית ל-login.html.

צילום מסך: עמוד index.html Index page הצגת כותרת "Cloud Storage" וטקסט "Redirecting to login..." לפני הניתוב האוטומטי

מסך 2: התחברות (login.html)

תפקיד: איסוף Username + Password, ולידציה בצד לקוח, שליחה ל-POST /api/auth/login, שמירת ה-token והפניה ל-home. בנוסף — קישור לעמוד ההרשמה וכפתור החלפת ערכת נושא.

אלמנטים: שדה username, שדה password, כפתור "Log in" עם מצב loading, הודעות שגיאה inline פר-שדה, error region כללי, קישור "Sign up".

צילום מסך: עמוד login.html Login page טופס התחברות במצב כהה עם כפתור החלפת ערכת נושא בפינה

מסך 3: הרשמה (signup.html)

תפקיד: איסוף First/Last Name, Username, Password ו-Confirm Password, ולידציה (אורך, ייחודיות, מורכבות), שליחה ל-POST /api/auth/signup.

צילום מסך: עמוד signup.html Signup page טופס הרשמה עם 5 שדות וולידציה ב-blur לכל שדה

מסך 4: דף הבית – Tab "My Files" (home.html)

תפקיד: המרכז העיקרי של היישום. מציג טבלת קבצים ותיקיות של המשתמש, עם breadcrumbs לניווט בעץ, תיבת חיפוש, כפתורי Upload / Create Directory, מיון עמודות, checkboxes ל-Bulk Actions, וסרגל פעולות Bulk שמופיע מתחת לטבלה כשנבחרו פריטים.

פעולות: העלאה (Drag&Drop או Browse), יצירת תיקייה, כניסה לתיקייה (double-click), הורדה, מחיקה, שיתוף, סימון מועדף, חיפוש, מיון, Bulk Download / Move / Copy / Delete.

צילום מסך: home.html — לשונית My Files Home - My Files טבלת קבצים עם breadcrumbs, סרגל Tabs (My Files / Shared / Favorites / Recycle Bin), וסרגל Bulk Actions

מסך 5: דף הבית – Tab "Shared with me"

תפקיד: רשימת קבצים ותיקיות שהמשתמש קיבל שיתוף אליהם, עם הצגת בעלים (Owner) לכל פריט.

צילום מסך: home.html — לשונית Shared with me Home - Shared with me טבלה עם עמודת Owner (אווטאר + שם)

מסך 6: דף הבית – Tab "Favorites"

תפקיד: רשימת פריטים שהמשתמש סימן כמועדפים. תומך בחיפוש ומיון.

צילום מסך: home.html — לשונית Favorites Home - Favorites טבלת מועדפים עם עמודת Source (שלי / שותף אליי)

מסך 7: דף הבית – Tab "Recycle Bin"

תפקיד: רשימת פריטים שנמחקו, עם עמודת Days Remaining וכפתורי Restore / Empty Recycle Bin.

צילום מסך: home.html — לשונית Recycle Bin Home - Recycle Bin טבלת פריטים שנמחקו עם עמודת ימים נותרים

מסך 8: דיאלוג העלאת קובץ (Modal)

תפקיד: אזור Drag&Drop גדול, כפתור "Browse File", spinner העלאה, success icon, ושדה בחירת תיקיית יעד.

צילום מסך: Upload Modal Upload modal חלון מודאל לבחירת קובץ עם Drag&Drop וויזואלי

מסך 9: דיאלוג שיתוף (Share Modal)

תפקיד: חיפוש משתמשים בזמן אמת, רשימת משותפים נוכחית, יכולת לבטל שיתוף.

צילום מסך: Share Modal Share modal תיבת חיפוש משתמשים + רשימת משותפים עם כפתור Remove לכל אחד

מסך 10: דיאלוגי Move / Copy / Bulk Delete / Create Directory / Delete

תפקיד: Modals לאישור פעולות ובחירת יעדים.

צילום מסך: Move / Copy / Bulk Delete Modal Move modal חלון בחירת תיקיית יעד לפעולת העברה (Move) — מבנה דומה משמש גם ל-Copy ול-Bulk Delete

מסך 11: פרופיל אישי (profile.html)

תפקיד: עריכת firstName / lastName, העלאה והסרת תמונת פרופיל, שם משתמש לקריאה בלבד.

צילום מסך: profile.html Profile page טופס פרופיל עם תמונה ושדות עריכה

מסך 12: Admin Dashboard (admin.html)

תפקיד: זמין רק ל-admin. ארבעה כרטיסי סטטיסטיקה (Total Users, Total Files, Total Storage, Active Shares), טבלת משתמשים עם File Count + Storage Used + כפתור מחיקה, וטבלת All Files.

צילום מסך: Admin Dashboard Admin dashboard 4 stat cards למעלה, טבלת משתמשים מתחת

מבני נתונים ומסדי נתונים

בסיס הנתונים הוא Google Firestore – NoSQL מסוג Document Store. כל אוסף (collection) מכיל מסמכים (documents) בעלי מזהה (id) ייחודי שמופק אוטומטית על-ידי Firestore. ה-Schema אינו נאכף ברמת ה-DB, אלא נשמר באופן עקבי ברמת קוד היישום.

קולקציה: users

שדהטיפוסדוגמהחובה
usernamestring"galco"כן (מפתח עסקי + Unique)
passwordstring (hashed)"pbkdf2:sha256:600000$...$..."כן
firstNamestring"Gal"כן (בהרשמה)
lastNamestring"Cohen"כן (בהרשמה)
profilePicturestring | null"/api/user/profile/picture/profile_galco_20260101_..."לא
public_keystring (PEM)"-----BEGIN PUBLIC KEY-----..." (RSA-2048)כן (נוצר בהרשמה / backfill)
wrapped_private_keystringהמפתח הפרטי (PEM) עטוף תחת ה-KEK — נשמר מוצפןכן
token_revoked_afterstring (ISO8601) | —"2026-05-20T08:30:00" — נכתב ב-logout; JWT שהונפק לפניו נדחהלא

מפתח ראשי: Document ID (אוטומטי). מפתח עסקי: username.

הערת אבטחה: ה-public_key נשמר בטקסט גלוי; המפתח הפרטי לעולם אינו נשמר גלוי — הוא נעטף תחת מפתח המסטר (KEK) של האפליקציה. ראו פרק "סקירת חולשות ואיומים" — At-Rest Encryption.

קולקציה: files

שדהטיפוסדוגמהחובה
filenamestring"report.pdf"כן
stored_filenamestring"20260101_123045_report.pdf"כן
gcs_pathstring"users/galco/files/20260101_123045_report.pdf"כן
uploaded_bystring (username)"galco"כן
uploaded_atstring (ISO8601)"2026-05-20T08:30:00.000000"כן
sizenumber (bytes)1024567כן
content_typestring (MIME)"application/pdf"כן
directory_idstring | null"abc123..." או null עבור Rootלא
wrapped_dekstring | nullמפתח ההצפנה של הקובץ (DEK) עטוף תחת ה-KEKלא (חסר ב-blobs מלפני ההצפנה)

הערה: קולקציה זו מאוחזרת כ-db.collection('files') בכל מודול, ואינה מיוצאת מ-config.py. השדה wrapped_dek נוסף עם הצפנת המעטפה: בייטי הקובץ ב-GCS מוצפנים תחת ה-DEK, וה-DEK נשמר כאן עטוף. קובץ ללא השדה (העלאה ישנה) נקרא כבייטים גולמיים בנפילה-לאחור.

קולקציה: directories

שדהטיפוסדוגמהחובה
namestring"Documents"כן
created_bystring (username)"galco"כן
created_atstring (ISO8601)"2026-05-20T08:30:00.000000"כן
parent_directory_idstring | nullnull עבור root, אחרת id של תיקיית הורהלא

קולקציה: shares

שדהטיפוסדוגמה
item_idstring"file_doc_id_xyz"
item_typestring"file" | "directory"
item_namestring"report.pdf"
ownerstring"galco"
shared_withstring"daniela"
shared_atstring (ISO8601)"2026-05-20T08:30:00.000000"
wrapped_dekstring (base64) | —ה-DEK של הקובץ עטוף תחת המפתח הציבורי (RSA) של shared_with

הערה: wrapped_dek קיים רק בשיתופי קבצים (לא תיקיות) ומאפשר לנמען בלבד לפענח את הקובץ — נעטף בנפרד עבור כל נמען תחת המפתח הציבורי שלו.

קולקציה: favorites

שדהטיפוסדוגמה
item_idstring"file_doc_id_xyz"
item_typestring"file" | "directory"
item_namestring"report.pdf"
userstring"galco"
favorited_atstring (ISO8601)"2026-05-20T08:30:00.000000"

קולקציה: recycle_bin

שדהטיפוסדוגמה
item_idstringid של הפריט המקורי
item_typestring"file" | "directory"
item_namestring"report.pdf"
original_dataobjectתוכן המסמך המקורי כדי לאפשר שחזור
deleted_bystring (username)"galco"
deleted_atstring (ISO8601)"2026-05-20T08:30:00.000000"
expires_atstring (ISO8601)"2026-06-19T08:30:00.000000" (deleted_at + 30 ימים)
parent_directory_idstring | nullלמקרה של מחיקה רקורסיבית של תיקייה
pathstring"Documents/Sub" (נתיב מקורי)

אחסון בענן (GCS Bucket)

חומר מפתחות בצד השרת (גנוז מ-git)

קובץ / משתנהתוכןתפקיד
server/.secret_key (או env SECRET_KEY)מחרוזת אקראית 48-byteסוד החתימה של JWT — נוצר אוטומטית אם חסר
server/.file_master_key (או env FILE_MASTER_KEY)מפתח Fernetמפתח המסטר (KEK) — עוטף כל DEK וכל מפתח פרטי

שני הקבצים נוספו ל-.gitignore. מחיקת .file_master_key ללא עטיפה מחדש מתואמת הופכת את כל הקבצים והמפתחות הפרטיים לבלתי-ניתנים לשחזור — מחיקה רק כחלק ממיגרציה מתוכננת.

אחסון בצד לקוח (localStorage)

מפתחתוכןשימוש
authTokenJWT stringנשלח ככותרת Authorization בכל בקשה מוגנת
usernamestringתצוגת שם משתמש בכותרת home page
cloudStorageTheme"dark" | "light"שמירת בחירת ערכת הנושא בין רענונים

בנוסף: sessionStorage.cameFromHome כדגל זמני בעת חזרה ל-login אחרי 401.

סקירת חולשות ואיומים

הסעיף הזה מתאר באופן כן ומפורש את מצב האבטחה בקוד הנוכחי (סביבת פיתוח / למידה), ולכל נושא – מה היה ראוי לבצע בסביבת ייצור. אסטרטגיה זו נבחרה במכוון: דיווח אמיתי הוא בעל ערך לימודי גבוה הרבה יותר מהצגת מימוש מושלם.

עדכון (סבב הקשחה): לאחר הגרסה הראשונית בוצע סבב הקשחת אבטחה שסגר חלק מהפערים שתועדו כאן — בין היתר תיקון XSS, סוד JWT אקראי נטען-אוטומטית (ללא fallback קשיח) + שלילת אסימונים, צמצום CORS, הוספת security headers (כולל CSP), הפסקת דליפת פרטי שגיאה, הגבלות DoS על ZIP, הצפנת מעטפה במנוחה, ותמיכת HTTPS בפיתוח. הסעיפים שעודכנו מסומנים "תוקן".

שכבת האפליקציה

1. SQL Injection

מה הקוד עושה היום: אין שכבת SQL בכלל. ה-DB הוא Firestore (NoSQL). שאילתות מבוצעות באמצעות builder מתודות (.where('field', '==', value)) שאינן נוצרות מ-concatenation של מחרוזות. ההגנה היא מבנית.

מקבילה מסוכנת (NoSQL injection): ב-MongoDB עלולה להיות בעיה אם בונים שאילתה מ-dict שמגיע ישירות מהמשתמש. ב-Firestore Python SDK כל ערך עובר type-conversion ל-Firestore native types ולא נוצרת בעיה דומה.

בייצור: שמירה על המודל הקיים; הימנעות מבניית query string דינמיים.

2. XSS (Cross-Site Scripting) — תוקן

מה היה: רינדור אווטאר משתמש/שיתוף שילב טקסט שמקורו במשתמש (שם משתמש / ראשי תיבות) ישירות ל-innerHTML, מה שאיפשר XSS מאוחסן (stored XSS).

מה הקוד עושה היום: תוקן — הטקסט שמקורו במשתמש עובר escape לפני הצבה ב-DOM ברינדור האווטארים. בנוסף, השרת שולח כותרת Content-Security-Policy בכל תגובה (default-src 'self', frame-ancestors 'none', וכו') כשכבת הגנה שנייה.

בייצור: מעבר עקבי ל-textContent בכל שאר המקומות, צמצום ה-'unsafe-inline' שעדיין מותר ל-scripts (מעבר ל-CSP nonces), ושימוש ב-template engine.

3. תהליך Login — Brute-force ו-Lockout

מה הקוד עושה היום: auth.py:login() אינו מגביל קצב, לא סופר כישלונות ולא נועל חשבונות. תוקף יכול להריץ ניסיונות אינסופיים.

בייצור: הוספת Rate Limiting (לדוגמה Flask-Limiter), נעילת חשבון לאחר X כישלונות, CAPTCHA על ניסיון מספר Y, התראות לבעל החשבון.

4. אחסון סיסמאות

מה הקוד עושה היום: סיסמאות נשמרות בגיבוב pbkdf2:sha256 עם Salt אקראי מובנה דרך werkzeug.security.generate_password_hash. בכניסה משתמשים ב- check_password_hash שמשווה ב-constant-time. זוהי הגנה תקפה.

בייצור: שדרוג ל-Argon2 (Memory-hard), הוספת מדיניות סיסמה (אורך + מורכבות + רוטציה).

5. JWT — סוד וחתימה — תוקן

מה היה: בהיעדר SECRET_KEY ב-env היה fallback קשיח 'your-secret-key-change-in-production' בקוד — סוד ידוע שאיפשר זיוף JWT.

מה הקוד עושה היום: ה-fallback הקשיח הוסר. _load_or_create_secret_key() משתמש ב-env SECRET_KEY אם קיים, אחרת מייצר ושומר מפתח אקראי 48-byte ב-server/.secret_key (gitignored). נוסף גם מנגנון שלילת אסימונים פר-משתמש: logout כותב token_revoked_after על מסמך המשתמש, וה-decorator דוחה כל JWT שה-iat שלו קודם לחותמת.

בייצור: הגדרה מחייבת של SECRET_KEY דרך Secret Manager (Google / AWS), שימוש ב-Asymmetric (RS256), רוטציה תקופתית.

6. הצפנה (Encryption) ו-MITM — תוקן חלקית

מה היה: השרת רץ תמיד ב-debug=True על HTTP פשוט; ה-CORS היה פתוח לחלוטין (origins="*") יחד עם Authorization — צירוף מסוכן.

מה הקוד עושה היום: נתמך HTTPS בפיתוח — סקריפט generate_dev_cert.py מנפיק תעודה מקומית (mkcert, ובנפילה-לאחור self-signed), וכאשר קיימים server/certs/dev-{cert,key}.pem השרת מגיש TLS. ה-debug הפך ל-opt-in (FLASK_DEBUG) וברירת המחדל היא האזנה ל-127.0.0.1 בלבד. ה-CORS צומצם לרשימת היתר של localhost (ניתן לעקיפה דרך CORS_ORIGINS). כותרת Strict-Transport-Security נשלחת רק על תגובות TLS.

בייצור: אכיפת HTTPS דרך reverse proxy (nginx + Let's Encrypt) במקום תעודת dev, CORS מצומצם ל-domain ספציפי, והצפנה ברמת השדה לנתונים רגישים.

7. DoS / DDoS — תוקן חלקית

מה היה: פעולות ה-ZIP הרקורסיביות (הורדת תיקייה / Bulk Download) לא היו מוגבלות בכמות — תוקף יכול לבקש ZIP של עשרות-אלפי פריטים ולגרום ל-OOM.

מה הקוד עושה היום: נוספו תקרות MAX_ZIP_FILES=2000 ו-MAX_ZIP_BYTES=512MB על בניית ה-ZIP (ב-models/directory_item.py, routes/bulk.py ו-sharing.py), לצד MAX_CONTENT_LENGTH=16MB הקיים על קובץ בודד. עדיין אין rate-limiting כללי על מספר הבקשות.

בייצור: Flask-Limiter, WAF מול השרת (Cloudflare / GCP Armor), Per-Endpoint Quotas, Streaming ZIP אמיתי במקום ZIP-in-memory.

8. CSRF (Cross-Site Request Forgery)

מה הקוד עושה היום: אסימון JWT מאוחסן ב-localStorage (לא ב-cookie), ולכן אין Auto-Send של ה-token לבקשות cross-origin. הסיכון ל-CSRF מצומצם משמעותית. עם זאת, אין CSRF Token מפורש.

בייצור: אם עוברים ל-HttpOnly Cookie לאחסון JWT, חובה להוסיף CSRF Token. בתצורה הנוכחית הסיכון נמוך.

9. העלאת קבצים

מה הקוד עושה היום:

בייצור: שילוב ClamAV או Google Cloud Threat Detection לסריקת קבצים שעולים, allowlist של סוגי קבצים מותרים, חישוב SHA-256 לכל קובץ לזיהוי כפילויות וזיהוי גרסאות, deny-list של סיומות מסוכנות.

10. דליפת מידע בהודעות שגיאה — תוקן

מה היה: ה-global error handler החזיר את str(e) ללקוח כאשר debug=True — והשרת רץ עם debug תמיד, כך ש-stack traces נחשפו.

מה הקוד עושה היום: ה-handler מחזיר תמיד הודעה גנרית ("An internal error occurred") ורושם את החריגה ל-log בלבד. debug הפך ל-opt-in דרך FLASK_DEBUG (ברירת מחדל: כבוי).

בייצור: ניתוב הודעות שגיאה אמיתיות ל-log מרכזי, החזרת error-id ללקוח למעקב.

11. הצפנת נתונים במנוחה (At-Rest) — שודרג

מה היה: רק הצפנת ברירת-המחדל של הענן (GCS ו-Firestore ב-AES-256 בניהול Google). תוקף שגונב את הדלי/ה-DB יחד עם גישת ה-API היה רואה טקסט גלוי.

מה הקוד עושה היום: נוספה שכבת הצפנת מעטפה בבעלות האפליקציה מעל הצפנת הענן (מודול utils/crypto.py): כל קובץ מוצפן תחת DEK ייחודי (Fernet / AES-128-CBC + HMAC), ה-DEK עטוף תחת מפתח מסטר (KEK) שהאפליקציה מחזיקה. מפתחות פרטיים (RSA) של משתמשים נשמרים עטופים אף הם תחת ה-KEK. גבול ההגנה: גניבת הדלי או ה-DB בלבד חושפת צופן בלבד; פריצה למארח ה-API (שם יושב ה-KEK) עוקפת זאת.

בייצור: מעבר ל-Customer-Managed Encryption Keys (CMEK) דרך KMS / HSM לאחסון ה-KEK מחוץ למארח, ורוטציה מתוזמנת.

12. דליפת מפתח שירות (Service Account Key)

מה הקוד עושה היום: הקובץ cloudstorageproject-privatekey.json קיים פיזית ברפו של הפרויקט (משמש לפיתוח מקומי). מצב זה אינו מקובל בייצור.

בייצור: מחיקה מההיסטוריה של git, רוטציה של המפתח, אחסון ב-Secret Manager, גישה ל-GCS דרך Workload Identity (Cloud Run / GKE).

15. כותרות אבטחה ו-CSP (הגנה חדשה)

נוסף @app.after_request שמצרף לכל תגובה: Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Permissions-Policy, ו-HSTS על תגובות TLS בלבד. ה-CSP עדיין מתיר 'unsafe-inline' ל-scripts בשל stubs מוטמעים — מועמד לצמצום עתידי דרך nonces.

16. בקרת בעלות ו-IDOR (הוקשח)

כל endpoint שמחזיר/מערוך נכס בודק בעלות לפי ה-username מה-JWT. בסבב ההקשחה, קולקציית ה-items (legacy) קיבלה שדה owner וכל ה-CRUD שלה צומצם למשתמש הנוכחי (מניעת IDOR). בנוסף: שחזור מסל המיחזור כופה בעלות חזרה ל-deleted_by ומחזיר לשורש אם תיקיית ההורה כבר לא קיימת; הורדות משתמשות ב-send_file(download_name=...) כדי שכותרת Content-Disposition תקודד לפי RFC 5987 (מניעת header injection).

17. חיזוק ולידציית הרשמה (הוקשח)

נחסמו שמות-משתמש שמורים (admin/administrator/root וכו'), נדרשת סיסמה באורך 8+ עם אות וספרה, הודעת שגיאת ההרשמה הפכה גנרית (מניעת user enumeration), ותווים מסוכנים ל-HTML בשמות נדחים.

שכבת התעבורה (Transport Layer)

13. פרוטוקול TCP ולחיצת יד משולשת (Three-Way Handshake)

הלקוח (דפדפן) פותח חיבור TCP אל פורט 5000 בשרת. החיבור נפתח באמצעות לחיצת יד משולשת: SYN מהלקוח, SYN-ACK מהשרת, ACK מהלקוח. רק לאחר השלמת ה-handshake נשלחת בקשת ה-HTTP. השרת מאזין דרך socket TCP במצב LISTEN, שמסופק על-ידי Flask development server (Werkzeug). בייצור — שרת WSGI כמו gunicorn / uvicorn מאחורי reverse proxy.

איום נפוץ: SYN Flood — תוקף שולח SYN רבים ללא ACK ובכך ממלא את ה-backlog. הגנה: SYN cookies ברמת ה-OS, rate-limit ב-firewall.

14. הצפנה ב-Transit — תוקן בפיתוח

בסביבת הפיתוח ניתן כעת להפעיל HTTPS: python server/generate_dev_cert.py מנפיק תעודה (mkcert / self-signed), וכשהיא קיימת השרת מגיש TLS על פורט 5000. החיבור בין Flask ל-Firestore ול-GCS ממילא עובר HTTPS/TLS אוטומטית דרך firebase-admin ו-google-cloud-storage. ערוץ ה-push (socket TCP פורט 5001) רץ על localhost בלבד. בייצור: HTTPS חובה דרך reverse proxy עם תעודת CA אמיתית.

סיכום ההגנות הקיימות

מימוש הפרויקט

חלק א' — סקירת מודולים ומחלקות

מודולים מיובאים (Third-Party)

שם המודוללמה משמש
flask.Flask, Blueprint, request, jsonify, Response, send_file, send_from_directoryמסגרת השרת — Flask app, routing, request/response handling, שירות קבצים
flask_cors.CORSהגדרת CORS לאפשר Cross-Origin requests מהדפדפן
firebase_admin / firebase_admin.credentials / firebase_admin.firestoreחיבור ל-Firestore דרך Service Account
google.cloud.storageחיבור ל-GCS bucket לקריאה/כתיבה של blobs
werkzeug.security.generate_password_hash / check_password_hashגיבוב והשוואת סיסמאות בעזרת pbkdf2:sha256
werkzeug.utils.secure_filenameניקוי שמות קבצים מתווים מסוכנים לפני שמירה
jwt (PyJWT)חתימה ופענוח אסימוני JWT (HS256)
datetime.datetime / datetime.timedeltaחותמות זמן, חישוב תפוגה של JWT ו-Recycle Bin
functools.wrapsשמירה על שמות פונקציות בעת שימוש ב-decorators
zipfile.ZipFile, ZIP_DEFLATEDיצירת ארכיוני ZIP-in-memory להורדת תיקיות
io.BytesIObuffer זיכרון לקובץ ZIP
os / os.pathטיפול במשתני סביבה ובניית paths
cryptography.fernet.Fernetהצפנה סימטרית מאומתת (AES-128-CBC + HMAC) לקבצים ולעטיפת מפתחות
cryptography.hazmat ... rsa / padding / serializationזוגות מפתחות RSA-2048, עטיפת DEK ב-OAEP, סריאליזציית PEM
socket / threadingשרת ה-push הגולמי: listening socket, thread לכל חיבור, registry מוגן-Lock
customtkinter (לקוח שולחני)מסגרת ה-UI של אפליקציית ההתראות השולחנית

מודולים פותחו עצמאית — ארכיטקטורת ה-Blueprints

Module: config.py

תפקיד: אתחול singletons משותפים — Flask app, Firestore client, GCS bucket, וקולקציות.

תכונות (Singletons):

שםתפקיד
appFlask application instance
dbFirestore client
users_collectioncollection('users')
items_collectioncollection('items') – legacy
directories_collectioncollection('directories')
shares_collectioncollection('shares')
favorites_collectioncollection('favorites')
recycle_bin_collectioncollection('recycle_bin')
gcs_bucketstorage.Bucket object
GCS_BUCKET_NAME"clientstorage-6978a.firebasestorage.app"

Module: utils/auth.py

תפקיד: מעטפות אימות לכל route מוגן.

פעולה (Function)טענת כניסהטענת יציאה
token_required(f)פונקציית route ל-wrapפונקציה עטופה שמזריקה current_user; מחזירה 401 אם token חסר / לא תקף
admin_required(f)פונקציית routeפונקציה עטופה; 403 אם username != 'admin'

Module: routes/auth.py — Blueprint: auth_bp (/api/auth)

פעולהטענת כניסה (Parameters)טענת יציאה (Returns)
signup()JSON: firstName, lastName, username, password201 + {token, username} | 400 שגיאת ולידציה
login()JSON: username, password200 + {token, username} | 401
verify_token(current_user)JWT header200 + {valid:true, username}
logout(current_user)JWT header200 + {message}

Module: routes/user.py — Blueprints: user_bp (/api/user), users_bp (/api/users)

פעולהטענת כניסהטענת יציאה
get_profile(current_user)JWT{username, firstName, lastName, profilePicture}
update_profile(current_user)JSON: firstName?, lastName?, profilePicture?updated profile
upload_profile_picture(current_user)multipart file{profilePicture: URL}
get_profile_picture(current_user, filename)filenameimage bytes
get_user_profile_picture(current_user, username)usernameimage bytes (למשתמש אחר)

Module: routes/files.py — Blueprint: files_bp (/api)

פעולהטענת כניסהטענת יציאה
upload_file(current_user)multipart file + directory_id?{filename, stored_filename, directory_id}
get_files(current_user)query: directory_id?{files[], directories[]}
search_files(current_user)query: q{files[], directories[]} עם path
download_file(current_user, file_id)file_idfile bytes + Content-Disposition
delete_file(current_user, file_id)file_id{message: "moved to recycle bin"}

Module: routes/directories.py — Blueprint: directories_bp (/api/directories)

פעולהטענת כניסהטענת יציאה
get_all_directories(current_user)JWT{directories[]}
create_directory(current_user)JSON: name, parent_directory_id?{directory: {...}}
download_directory(current_user, directory_id)directory_idZIP bytes
delete_directory(current_user, directory_id)directory_idsoft-delete רקורסיבי עם רישום של כל הילדים
get_all_files_in_directory(internal)dir_id, base_path(files_list, dirs_list)

Module: routes/sharing.py — Blueprint: sharing_bp (/api)

פעולהטענת כניסהטענת יציאה
search_users(current_user)query: q (min 2 chars){users[]} (max 10)
share_item(current_user)item_id, item_type, share_with{share}
unshare_item(current_user, item_id)?username=...{message}
get_shared_users(current_user, item_id)item_id{users[]}
get_shared_with_me(current_user)JWT{files[], directories[]} עם owner
download_shared_file(current_user, file_id)file_idfile bytes
download_shared_directory(current_user, directory_id)directory_idZIP bytes

Module: routes/favorites.py — Blueprint: favorites_bp (/api/favorites)

פעולהטענת כניסהטענת יציאה
add_favorite(current_user)item_id, item_type{favorite}
remove_favorite(current_user, item_id)item_id{message}
get_favorites(current_user)JWT{files[], directories[]} (כולל owner למשותפים)

Module: routes/recycle_bin.py — Blueprint: recycle_bin_bp (/api/recycle-bin)

פעולהטענת כניסהטענת יציאה
cleanup_expired_items(username=None)username אופציונליint (כמות שנמחקה)
get_recycle_bin(current_user)JWT{files[], directories[]} + days_remaining
restore_item(current_user, bin_item_id)bin_item_id{message} — שחזור רקורסיבי
empty_recycle_bin(current_user)JWT{message: "X items deleted"}

Module: routes/bulk.py — Blueprint: bulk_bp (/api/bulk)

פעולהטענת כניסהטענת יציאה
bulk_delete(current_user)items: [{id, type}]{message, errors?}
bulk_move(current_user)items[], destination_id?{message, errors?} – עם בדיקת לולאה
bulk_copy(current_user)items[], destination_id?{message, errors?} – העתקה רקורסיבית ב-GCS
bulk_download(current_user)items[]ZIP bytes

Module: routes/admin.py — Blueprint: admin_bp (/api/admin)

פעולהטענת כניסהטענת יציאה
get_admin_stats(current_user)admin JWT{total_users, total_files, total_directories, total_storage, total_shares}
get_all_users(current_user)admin JWT{users[]} עם file_count + total_storage
get_all_files(current_user)admin JWT{files[]}
delete_user(current_user, user_id)user_id{message} — מחיקה מלאה

Module: routes/static_files.py — Blueprint: static_bp (/)

תפקיד: שירות עמודי HTML/CSS/JS של הלקוח עם כותרות no-cache, ומתן endpoint /api/health.

Module: routes/items.py — Blueprint: items_bp (/api/items)

תפקיד (legacy): CRUD בסיסי לקולקציה items — שריד מהדגמה הראשונית; אינו חלק מהזרם המרכזי של מוצר ה-Cloud Storage. ב-CRUD הוקשח scoping למשתמש הנוכחי דרך שדה owner (מניעת IDOR).

Module: utils/crypto.py

תפקיד: הצפנת מעטפה לבייטי קבצים ב-GCS וחומר המפתחות האסימטרי (RSA) שמאחורי שיתוף קריפטוגרפי.

פעולהטענת כניסהטענת יציאה
generate_dek()מפתח Fernet טרי (DEK) לקובץ
encrypt_with_dek / decrypt_with_dekbytes, dekצופן / טקסט גלוי
wrap_dek_with_master / unwrap_dek_with_masterdek / wrapped strעטיפת/חילוץ ה-DEK תחת ה-KEK
encrypt_bytes / decrypt_bytesbytesמסלול מפתח גלובלי (תמונות פרופיל + blobs ישנים)
decrypt_stored_fileciphertext, wrapped_dek?מפענח מאוחד עם נפילה-לאחור לבייטים גולמיים
generate_rsa_keypair()(private_pem, public_pem) — RSA-2048
wrap_private_key / unwrap_private_keyPEM / wrapped strעטיפת/חילוץ המפתח הפרטי תחת ה-KEK
rsa_wrap_dek / rsa_unwrap_dekdek + public_pem / wrapped + private_pemעטיפת DEK ל-RSA-OAEP של נמען / חילוצו

Module: socket_server.py — class NotificationHub

תפקיד: שרת socket TCP גולמי (פורט 5001) לדחיפת אירועים בזמן אמת. רץ באותו תהליך כמו Flask; ה-singleton notification_hub משותף בין ה-socket threads לבין ה-route handlers.

פעולהטענת כניסהטענת יציאה
start(host, port=5001)host/port—; bind+listen ו-accept loop על daemon thread
register / unregister(username, conn)username, socket—; עדכון registry מוגן-Lock
push_to_user(username, event)username, dict—; שולח JSON לכל socket חי של המשתמש (best-effort)
_handle_client(conn, addr)חיבור נכנס—; אימות-תחילה (JWT) ואז לולאת ping/pong

חבילת models/ — שכבת המודלים (OOP)

תפקיד: מחלקות שמכמסות את הלוגיקה והנתונים של הישויות, וממחישות את ארבעת עמודי ה-OOP. שכבה זו גם מרכזת את לוגיקת ההצפנה (encrypt-on-create / decrypt-on-read).

מחלקה / קובץתפקיד עיקרי
StorageItem (storage_item.py)מחלקת בסיס מופשטת — שדות ופעולות משותפים לקובץ/תיקייה (בעלות, מטא-דאטה, to_dict)
FileItem (file_item.py)קובץ: create() מצפין תחת DEK ועוטף תחת KEK; download() מפענח; move() ו-copy() (copy שומר על אותו wrapped_dek)
DirectoryItem (directory_item.py)תיקייה: מעבר רקורסיבי, בניית ZIP עם פענוח לכל קובץ ותקרות DoS
User (user.py)משתמש: יצירה כולל זוג מפתחות RSA (פרטי עטוף תחת KEK), ו-backfill למשתמשים ותיקים

מודולים בצד הלקוח השולחני (desktop/)

אפליקציית ההתראות השולחנית (Python + CustomTkinter)

לקוח שני, נפרד מהדפדפן, שמדגים תקשורת socket גולמית דו-כיוונית. ההורדות עדיין מתבצעות דרך ה-REST API.

קובץתפקיד
app.pyה-UI ונקודת הכניסה: מסך login, header עם status pill, feed התראות חי, רשימת "שותף איתי", toasts, החלפת theme
components.pyרכיבי UI לשימוש חוזר: Header, StatusPill, NotificationFeed/Card, SharedList, ToastManager, Tooltip — כל אחד עם apply_theme
theme.pyפלטות dark/light (מראה זהה ל-home.css) ובחירת פונט (Inter → Segoe UI)
preferences.pyקריאה/כתיבה של %APPDATA%/CloudStorage/preferences.json (בחירת theme)
socket_client.pyNotificationClient: פותח את ה-socket, מאמת, מקבל אירועים ב-thread רקע לתור thread-safe
api_client.pyApiClient: קריאות REST (login, shared-with-me, download) דרך urllib

מודולים בצד הלקוח

Module: client/theme.js (משותף לכל העמודים)

פעולהטענת כניסהטענת יציאה
initTheme()—; קורא את localStorage ומאתחל data-theme
setTheme(theme)'dark'/'light'—; שומר ב-localStorage + מעדכן UI
toggleTheme()—; מחליף בין dark ל-light
updateThemeToggle(theme)theme—; מחליף את אייקון השמש/ירח
window.getCurrentTheme()string (dark|light)

Module: client/login/login.js

פונקציות עיקריות: verifyToken(token), setupFormValidation(), validateField(input,err,msg), showFieldError, clearFieldError, clearAllErrors(), ומאזין submit לטופס.

Module: client/signup/signup.js

פונקציות: setupFormValidation(), validateName / validateUsername / validatePassword / validatePasswordMatch, מאזיני input/blur/submit, ושליחה ל-/api/auth/signup.

Module: client/home/home.js (מודול הליבה — ~1500 שורות)

פונקציות עיקריות לפי תחום:

Modules: client/profile/profile.js, client/admin/admin.js

profile.js: loadProfile, uploadProfilePicture, removeProfilePicture, saveProfile. admin.js: loadStats, loadUsers, loadAllFiles, deleteUser.

חלק ב' — קטעי קוד מרכזיים

קטע 1: Decorator token_required

זוהי אבן הפינה של כל מנגנון ההזדהות בשרת. כל endpoint מוגן עוטף את עצמו ב-decorator הזה, שמחלץ את ה-JWT מהכותרת Authorization: Bearer ..., מאמת חתימה ותפוגה, מאתר את המשתמש ב-Firestore, ומזריק current_user כפרמטר ראשון לפונקציה.

def token_required(f):
    """Decorator to protect routes that require authentication"""
    @wraps(f)
    def decorated(*args, **kwargs):
        token = None
        if 'Authorization' in request.headers:
            auth_header = request.headers['Authorization']
            try:
                token = auth_header.split(' ')[1]  # Bearer <token>
            except IndexError:
                return jsonify({"error": "Invalid token format"}), 401

        if not token:
            return jsonify({"error": "Token is missing"}), 401

        try:
            data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
            user_query = users_collection.where('username', '==', data['username']).limit(1).get()
            user_docs = list(user_query)
            if not user_docs:
                return jsonify({"error": "User not found"}), 401
            current_user = user_docs[0].to_dict()
            current_user['_id'] = user_docs[0].id
        except jwt.ExpiredSignatureError:
            return jsonify({"error": "Token has expired"}), 401
        except jwt.InvalidTokenError:
            return jsonify({"error": "Invalid token"}), 401

        return f(current_user, *args, **kwargs)
    return decorated

קטע 2: העלאה ל-GCS עם בדיקת שם כפול ו-Timestamp Prefix

הקטע מציג את לב פונקציית upload_file. שתי הגנות פעלו במקביל: (א) בדיקה לוגית בקולקציית files שלא קיים קובץ בשם זהה באותה תיקייה, ו-(ב) prefix של חותמת זמן ל-blob path כדי שגם אם המשתמש העלה שני קבצים זהים שמית, הם יישמרו כ-objects נפרדים ב-GCS.

# Check for duplicate filename in the same directory
files_collection = db.collection('files')
existing_files = files_collection.where('uploaded_by', '==', current_user['username']).get()
for doc in existing_files:
    existing_file = doc.to_dict()
    if existing_file.get('filename') == file.filename:
        existing_dir_id = existing_file.get('directory_id')
        if existing_dir_id == directory_id:
            return jsonify({"error": f"A file named '{file.filename}' already exists in this location"}), 400

filename = secure_filename(file.filename)
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
stored_filename = timestamp + filename

# GCS path: users/{username}/files/{timestamp_filename}
gcs_path = f"users/{current_user['username']}/files/{stored_filename}"

blob = gcs_bucket.blob(gcs_path)
file_content = file.read()
file_size = len(file_content)
content_type = file.content_type or 'application/octet-stream'
blob.upload_from_string(file_content, content_type=content_type)

# Store metadata in Firestore
file_data = {
    'filename': file.filename,
    'stored_filename': stored_filename,
    'gcs_path': gcs_path,
    'uploaded_by': current_user['username'],
    'uploaded_at': datetime.utcnow().isoformat(),
    'size': file_size,
    'content_type': content_type,
    'directory_id': directory_id
}
files_collection.add(file_data)

קטע 3: הורדה רקורסיבית של תיקייה כ-ZIP-in-memory

אלגוריתם רקורסיבי שאוסף את כל הקבצים תוך תתי-תיקיות, מוריד את הבייטים מ-GCS, וארוז ZIP יחיד בזיכרון.

def get_all_files_in_directory(dir_id, base_path=''):
    files_list, dirs_list = [], []
    files_query = files_collection.where('uploaded_by', '==', current_user['username']).get()
    for doc in files_query:
        file_data = doc.to_dict()
        if file_data.get('directory_id') == dir_id:
            relative_path = f"{base_path}{file_data['filename']}" if base_path else file_data['filename']
            files_list.append({'gcs_path': file_data['gcs_path'], 'relative_path': relative_path})
    dirs_query = directories_collection.where('created_by', '==', current_user['username']).get()
    for doc in dirs_query:
        subdir = doc.to_dict()
        if subdir.get('parent_directory_id') == dir_id:
            sub_base = f"{base_path}{subdir['name']}/" if base_path else f"{subdir['name']}/"
            sub_files, _ = get_all_files_in_directory(doc.id, sub_base)
            files_list.extend(sub_files)
    return files_list, dirs_list

all_files, _ = get_all_files_in_directory(directory_id, '')

zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
    for file_info in all_files:
        blob = gcs_bucket.blob(file_info['gcs_path'])
        if blob.exists():
            zip_file.writestr(file_info['relative_path'], blob.download_as_bytes())

zip_buffer.seek(0)
return send_file(zip_buffer, mimetype='application/zip', as_attachment=True,
                 download_name=f"{dir_name}.zip")

קטע 4: ניקוי אוטומטי של פריטים שפגו מסל המיחזור

def cleanup_expired_items(username=None):
    """Clean up expired items from recycle bin (older than 1 month)"""
    now = datetime.utcnow()
    query = recycle_bin_collection.where('expires_at', '<', now.isoformat())
    if username:
        query = query.where('deleted_by', '==', username)

    expired_items = query.get()
    deleted_count = 0
    for doc in expired_items:
        bin_data = doc.to_dict()
        original_data = bin_data.get('original_data', {})
        item_type = bin_data.get('item_type')

        if item_type == 'file':
            gcs_path = original_data.get('gcs_path')
            if gcs_path:
                blob = gcs_bucket.blob(gcs_path)
                if blob.exists():
                    blob.delete()  # מחיקה פיזית מ-GCS לאחר 30 יום

        doc.reference.delete()
        deleted_count += 1
    return deleted_count

קטע 5: שיתוף פריט עם בדיקות עומק

@sharing_bp.route('/share', methods=['POST'])
@token_required
def share_item(current_user):
    data = request.get_json()
    item_id, item_type, share_with = data.get('item_id'), data.get('item_type'), data.get('share_with')

    if not item_id or not item_type or not share_with:
        return jsonify({"error": "item_id, item_type, and share_with are required"}), 400

    # 1) Verify ownership of the item
    if item_type == 'file':
        item_doc = db.collection('files').document(item_id).get()
        if not item_doc.exists or item_doc.to_dict().get('uploaded_by') != current_user['username']:
            return jsonify({"error": "You can only share your own files"}), 403
    else:
        dir_doc = directories_collection.document(item_id).get()
        if not dir_doc.exists or dir_doc.to_dict().get('created_by') != current_user['username']:
            return jsonify({"error": "You can only share your own directories"}), 403

    # 2) Verify target user exists; 3) Cannot share with self; 4) No duplicates
    if share_with == current_user['username']:
        return jsonify({"error": "Cannot share with yourself"}), 400
    target = users_collection.where('username', '==', share_with).limit(1).get()
    if not list(target):
        return jsonify({"error": "User not found"}), 404
    existing = shares_collection.where('item_id','==',item_id).where('shared_with','==',share_with).limit(1).get()
    if list(existing):
        return jsonify({"error": "Item already shared with this user"}), 400

    # 5) Create share record
    shares_collection.add({
        'item_id': item_id, 'item_type': item_type,
        'owner': current_user['username'], 'shared_with': share_with,
        'shared_at': datetime.utcnow().isoformat()
    })
    return jsonify({"message": f"Successfully shared with {share_with}"}), 201

קטע 6: מניעת לולאה ב-Bulk Move

def is_subdirectory(parent_id, check_id):
    """Returns True if check_id is a (transitive) child of parent_id."""
    if not check_id:
        return False
    check_doc = directories_collection.document(check_id).get()
    if not check_doc.exists:
        return False
    check_data = check_doc.to_dict()
    if check_data.get('parent_directory_id') == parent_id:
        return True
    return is_subdirectory(parent_id, check_data.get('parent_directory_id'))

if destination_id and is_subdirectory(item_id, destination_id):
    errors.append("Cannot move directory into its own subdirectory")
    continue

קטע 7: לוגיקת Login בצד לקוח עם redirect מאובטח

loginForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    const username = usernameInput.value.trim();
    const password = passwordInput.value;
    clearAllErrors();
    if (!username || !password) { /* show inline errors */ return; }
    loginBtn.disabled = true; loginBtn.textContent = 'Logging in...';
    try {
        const response = await fetch(`${API_BASE_URL}/auth/login`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({ username, password })
        });
        const data = await response.json();
        if (response.ok) {
            localStorage.setItem('authToken', data.token);
            localStorage.setItem('username', data.username);
            window.location.replace('/home.html');
        } else {
            errorMessage.textContent = data.error || 'Login failed';
            errorMessage.classList.add('show');
        }
    } catch (error) {
        errorMessage.textContent = 'Network error. Please check if the server is running';
    } finally {
        loginBtn.disabled = false;
    }
});

קטע 8: העלאה מוצפנת — DEK לכל קובץ עטוף תחת ה-KEK

לב FileItem.create(): לכל קובץ נוצר DEK טרי, הבייטים מוצפנים תחתיו, וה-DEK נעטף תחת מפתח המסטר ונשמר במטא-דאטה. ל-GCS נכתב רק הצופן (עם content-type גנרי כדי לא לחשוף את צורת הקובץ).

# Envelope encryption: per-file DEK, bytes under DEK, DEK wrapped under master KEK.
dek = generate_dek()
ciphertext = encrypt_with_dek(file_content, dek)
wrapped_dek = wrap_dek_with_master(dek)
gcs_bucket.blob(gcs_path).upload_from_string(
    ciphertext, content_type='application/octet-stream'
)

item = cls(
    doc_id=None, owner=username,
    filename=file_storage.filename, stored_filename=stored_filename,
    gcs_path=gcs_path, size=file_size, content_type=content_type,
    directory_id=directory_id, uploaded_at=datetime.utcnow().isoformat(),
    wrapped_dek=wrapped_dek,
)
ref = files_collection.add(item.to_dict())

קטע 9: דחיפת אירוע על Socket גולמי + אימות-תחילה

ה-NotificationHub שומר registry של username → sockets. push_to_user כותב את האירוע ב-JSON תחום-שורה לכל socket חי (best-effort); הטיפול בלקוח דורש הודעת auth ראשונה לפני כל דבר אחר.

def push_to_user(self, username, event):
    line = (json.dumps(event) + "\n").encode("utf-8")
    with self._lock:
        conns = list(self._connections.get(username, ()))
    for conn in conns:
        try:
            conn.sendall(line)
        except OSError:
            self.unregister(username, conn)   # socket מת — מנותק

def _handle_client(self, conn, addr):
    first = _LineReader(conn).readline()
    msg = _parse(first)
    token = msg.get("token") if msg and msg.get("type") == "auth" else None
    user = resolve_user_from_token(token) if token else None
    if user is None:
        _send(conn, {"type": "auth_error", "error": "Invalid token"})
        return
    self.register(user["username"], conn)
    _send(conn, {"type": "auth_ok", "username": user["username"]})

חלק ג' — מסמך בדיקות מלא

הבדיקות נערכו ידנית בדפדפן Chrome ובכלי Postman עבור endpoints ספציפיים. הטבלאות הבאות מציגות 28 בדיקות מרכזיות שבוצעו במהלך הפיתוח.

בדיקות שתכננתי בשלב האפיון

#מטרת הבדיקהמה בוצע בפועלתוצאהבעיות שהתגלו וכיצד נפתרו
1הרשמה תקינההרשמה עם firstName="Test", lastName="User", username="testuser1", password="123456"201 Created + JWT הוחזר + הפניה ל-home.html
2הרשמה כפולההרשמה חוזרת עם username="testuser1"400 Bad Request + הודעה "Username already exists"
3סיסמה קצרה מ-6 תוויםהרשמה עם password="123"נחסם בצד לקוח (validation) + 400 בצד שרת כ-defense in depth
4התחברות תקינהusername + password נכונים200 OK + JWT + redirect
5התחברות שגויהסיסמה לא נכונה401 Unauthorized + הודעה "Invalid username or password"
6אסימון JWT שפגשינוי ידני של exp לעבר ושליחה ל-/api/files401 + הודעה "Token has expired"
7גישה לעמוד ללא tokenגישה ידנית ל-/home.html אחרי localStorage.clear()redirect אוטומטי ל-/login.html?from=homeבעיית redirect-loop אינסופי שהתגלתה בתחילה — נפתרה ב-commit 7c6e442 על ידי הוספת בדיקת currentPath בכל DOMContentLoaded
8העלאת קובץ תקיןהעלאת PDF בגודל 2MB ל-root200 OK, הקובץ הופיע בטבלה
9העלאת קובץ בשם זהההעלאה שנייה של אותו שם קובץ באותה תיקייה400 + הודעה "A file named '...' already exists"בתחילה ההודעה הוצגה כ-raw response — נפתר ב-commit b6e4cda עם error banner מעוצב במודאל
10קובץ גדול מ-16MBניסיון העלאת קובץ של 20MBFlask חוסם אוטומטית עם 413 Request Entity Too Large
11יצירת תיקייהPOST /api/directories עם name="Documents"201 Created, התיקייה הופיעה בטבלה
12תיקייה כפולה באותה רמהPOST שני עם אותו name + parent400 + הודעה "Directory with this name already exists"
13ניווט להיררכיהיצירת תיקייה בתוך תיקייה (3 רמות עומק), מעבר עם breadcrumbsתקין; breadcrumbs מציג Root > Documents > Sub > Sub2
14הורדת קובץ בודדלחיצה על Downloadהדפדפן מוריד את הקובץ עם שם הקובץ המקורי, Content-Disposition: attachment
15הורדת תיקייה כ-ZIPלחיצה על Download Directory על תיקייה עם 5 קבצים ו-2 תתי-תיקיותZIP הורד עם מבנה התיקיות שמור
16מחיקת קובץ → סל מיחזורDELETE /api/files/{id}הקובץ נעלם מהטבלה, הופיע ב-tab "Recycle Bin" עם 30 ימים נותרים
17שחזור מסל מיחזורלחיצה Restoreהקובץ חזר ל-My Files עם אותם מטא-דאטהבעיה מקורית: שחזור תיקייה לא החזיר את הילדים — נפתר עם פונקציה רקורסיבית restore_children
18ריקון סל מיחזורPOST /api/recycle-bin/emptyכל הפריטים נמחקו ופיזית גם ה-blobs ב-GCS נמחקו
19שיתוף קובץשיתוף עם משתמש "user2" קיים201 + הקובץ הופיע ב-tab "Shared with me" של user2
20שיתוף עם משתמש לא קייםPOST /api/share עם share_with="nonexistent"404 + הודעה "User not found"
21הסרת שיתוףDELETE /api/share/{id}?username=...200 + הקובץ נעלם מ-tab של user2
22חיפוש רקורסיביחיפוש "report" כאשר קיימים קבצים בתתי-תיקיות שונותכל המופעים הוחזרו עם path מלא
23מועדפיםסימון כוכב על קובץ ועל תיקייה; פתיחת tab Favoritesשני הפריטים הופיעו עם עמודת Source נכונה
24Bulk Downloadבחירת 3 קבצים ו-2 תיקיות, לחיצה Download ZIPZIP אחד עם כל המבנה הוחזר
25Bulk Move עם לולאהניסיון להעביר תיקיית הורה לתוך הבת שלהשגיאה "Cannot move directory into its own subdirectory"
26העלאת תמונת פרופילהעלאת PNG דרך עמוד פרופילהתמונה הופיעה, התמונה הקודמת נמחקה אוטומטית מ-GCS
27גישה ל-Admin ללא הרשאהפתיחה ידנית של /admin.html עם משתמש רגיל403 + redirect ל-home + הסתרת admin button בכותרת home
28מחיקת משתמש דרך פאנל Adminכניסה עם admin, לחיצה Delete על משתמש "testuser1"המשתמש נמחק, כל הקבצים ב-GCS נמחקו, השיתופים והמועדפים שלו נמחקו, הוא לא הופיע יותר ברשימה

בדיקות נוספות שבוצעו במהלך הפיתוח

#מטרהבוצעתוצאהבעיות וכיצד נפתרו
A1תאימות דפדפניםבדיקה ב-Chrome, Edge, Firefoxתקין בכולם
A2החלפת ערכת נושאלחיצה על כפתור Theme Toggle בכל עמודהחלפה מיידית; שמירה ב-localStorage; שיחזור ברענון
A3מירכוז מודלים בכל הגדלי מסךשינוי גודל חלון, פתיחת מודלים שוניםנמצאו בעיות עם הרקע + הצמדה — נפתר ב-commit 2be6774 על ידי הוצאת המודלים מה-container הראשי + flexbox centeringתיקון חיוני לחווית משתמש
A4גישה ישירה ל-URL פנימיפתיחה של /home.html כשמשתמש לא מחוברהופיע redirect-loop בתחילה — נפתר ב-commit 7c6e442 ע"י הוספת בדיקת currentPath ושימוש ב-window.location.replaceתיקון קריטי
A5קובץ ב-Firestore שלא קיים ב-GCSמחיקה ידנית של blob מ-GCS console, רענון מהקליינטהקובץ נעלם אוטומטית גם מ-Firestoreתקלה גלויה — האפליקציה מתחזקת את עצמה
A6מיון עמודותלחיצה על כותרת עמודה במצבי asc/descסדר הטבלה משתנה, חצים מציגים כיוון
A7חיפוש עם שדה ריקניקוי תיבת החיפושבמצב מקורי — לא הוצגו קבצים. נפתר ב-commit 9a4407b: כעת מוצגים כל הקבצים בעת שדה ריקתיקון UX
A8קצב בקשותשליחה של 100 בקשות חיפוש מ-scriptהשרת מטפל בכולן ללא קריסה אך מאוד איטי — כיוון לעיתיד: rate limiting
A9סטטיסטיקות מנהלפתיחת admin dashboard עם 5 משתמשים ו-30 קבציםהסטטיסטיקות תאמו את התוכן בפועל
A10טבלת מועדפים — סנכרוןסימון/ביטול מועדף ב-tab אחר וחזרה ל-Favoritesמסונכרן אחרי refresh

מדריך למשתמש

עץ קבצי המערכת

CloudStorage/
├── client/                         # קוד צד-לקוח (Vanilla HTML/CSS/JS)
│   ├── theme.js                    # מודול ערכת נושא משותף (Dark/Light)
│   ├── index/                      # עמוד פתיחה
│   │   ├── index.html
│   │   ├── index.css
│   │   └── index.js
│   ├── login/                      # עמוד התחברות
│   │   ├── login.html
│   │   ├── login.css
│   │   └── login.js
│   ├── signup/                     # עמוד הרשמה
│   │   ├── signup.html
│   │   ├── signup.css
│   │   └── signup.js
│   ├── home/                       # דף הבית (Tabs, Modals, ניהול קבצים)
│   │   ├── home.html
│   │   ├── home.css
│   │   └── home.js
│   ├── profile/                    # עמוד פרופיל אישי
│   │   ├── profile.html
│   │   ├── profile.css
│   │   └── profile.js
│   ├── admin/                      # פאנל מנהל
│   │   ├── admin.html
│   │   ├── admin.css
│   │   └── admin.js
│   ├── index.html                  # stub redirect → /index/index.html
│   ├── login.html                  # stub redirect
│   ├── signup.html                 # stub redirect
│   ├── home.html                   # stub redirect
│   └── profile.html                # stub redirect
│
├── server/                         # קוד צד-שרת (Flask + Python)
│   ├── app.py                      # נקודת כניסה — blueprints + הפעלת socket server
│   ├── config.py                   # Singletons (Flask app, Firestore, GCS, SECRET_KEY)
│   ├── socket_server.py            # NotificationHub — socket TCP גולמי לדחיפת push (:5001)
│   ├── generate_dev_cert.py        # הנפקת תעודת HTTPS מקומית (mkcert / self-signed)
│   ├── seed_db.py                  # סקריפט אתחול חד-פעמי של משתמש admin
│   ├── cloudstorageproject-privatekey.json   # Service Account Key (gitignored)
│   ├── .secret_key                 # סוד JWT אקראי (gitignored, נוצר אוטומטית)
│   ├── .file_master_key            # מפתח המסטר KEK (gitignored, נוצר אוטומטית)
│   ├── certs/                      # dev-cert.pem / dev-key.pem ל-HTTPS (אם הונפקו)
│   ├── utils/
│   │   ├── __init__.py
│   │   ├── auth.py                 # @token_required / @admin_required / שלילת אסימונים
│   │   └── crypto.py               # הצפנת מעטפה (DEK/KEK) + RSA לשיתוף
│   ├── models/                     # שכבת מודלים (OOP)
│   │   ├── __init__.py
│   │   ├── storage_item.py         # מחלקת בסיס מופשטת
│   │   ├── file_item.py            # FileItem — create/download/move/copy + הצפנה
│   │   ├── directory_item.py       # DirectoryItem — מעבר רקורסיבי + ZIP
│   │   └── user.py                 # User — כולל זוג מפתחות RSA
│   └── routes/                     # 11 Blueprints
│       ├── __init__.py             # all_blueprints list
│       ├── auth.py                 # /api/auth/*
│       ├── user.py                 # /api/user/*, /api/users/*
│       ├── files.py                # /api/upload, /api/files, /api/files/search, ...
│       ├── directories.py          # /api/directories/*
│       ├── sharing.py              # /api/share, /api/shared-with-me, ...
│       ├── favorites.py            # /api/favorites/*
│       ├── recycle_bin.py          # /api/recycle-bin/*
│       ├── bulk.py                 # /api/bulk/*
│       ├── admin.py                # /api/admin/* (admin-only)
│       ├── static_files.py         # שירות HTML/CSS/JS
│       └── items.py                # /api/items (legacy)
│
├── desktop/                        # לקוח שולחני (CustomTkinter) — התראות push
│   ├── app.py                      # UI ונקודת כניסה
│   ├── components.py               # רכיבי UI לשימוש חוזר
│   ├── theme.py                    # פלטות dark/light
│   ├── preferences.py              # שמירת העדפות (theme) ב-APPDATA
│   ├── socket_client.py            # NotificationClient — חיבור ה-socket
│   ├── api_client.py               # ApiClient — קריאות REST
│   └── README.md
│
├── requirements.txt                # Python dependencies (כולל cryptography, customtkinter)
├── start_all.bat                   # הפעלת השרת + פתיחת דפדפן
├── start_server.bat                # רק הפעלת שרת
├── start_server.ps1                # גרסת PowerShell
├── start_server_debug.bat          # הפעלה עם FLASK_DEBUG=1
├── start_desktop.bat               # הפעלת הלקוח השולחני
├── check_setup.bat                 # אבחון התקנה
├── README.md                       # תיעוד בסיסי (מיושן — ראו CLAUDE.md)
└── PROJECT_BOOK.html               # תיק הפרויקט הזה

התקנת המערכת

סביבה נדרשת

כלים נדרשים

כליגרסה מינימליתתפקיד
Python3.7הרצת השרת
pip20+התקנת תלויות
Git2.30+שכפול הרפו (אופציונלי)
דפדפןChrome 100+ / Edge 100+ / Firefox 95+הרצת הלקוח

מיקומי קבצים חשובים

שלבי התקנה

  1. שיכפול הרפו: git clone <repo-url> CloudStorage
  2. כניסה לתיקיית הפרויקט: cd CloudStorage
  3. התקנת תלויות: pip install -r requirements.txt
  4. וידוא מיקום Service Account Key (ראו לעיל).
  5. אתחול בסיס נתונים — יצירת משתמש admin: python server/seed_db.py
  6. הפעלת השרת: .\start_all.bat (פותח אוטומטית דפדפן) או python server/app.py (רק שרת). השרת מאזין על פורט 5000 (REST) ו-5001 (socket התראות).
  7. פתיחת דפדפן בכתובת http://localhost:5000/
  8. (אופציונלי) HTTPS בפיתוח: python server/generate_dev_cert.py מנפיק תעודה מקומית (mkcert, ובנפילה-לאחור self-signed) ל-server/certs/; בהרצה הבאה השרת יגיש https://localhost:5000/.
  9. (אופציונלי) הלקוח השולחני: python desktop/app.py או .\start_desktop.bat, ולאחר התחברות מתקבלות התראות שיתוף בזמן אמת.

משתני סביבה (אופציונליים)

משתנהברירת מחדלתפקיד
FLASK_DEBUGכבויהפעלת debug mode (opt-in)
FLASK_HOST127.0.0.1כתובת האזנה של Flask
CORS_ORIGINSlocalhost:5000/8000רשימת מקורות CORS מותרים (מופרדים בפסיק)
SECRET_KEYנוצר ב-.secret_keyסוד חתימת JWT
FILE_MASTER_KEYנוצר ב-.file_master_keyמפתח המסטר (KEK) להצפנה

פרטי כניסה התחלתיים

משתמש מנהל (Admin) שנוצר על-ידי seed_db.py:
Username: admin
Password: admin

משתמשים נוספים נוצרים דרך עמוד ההרשמה ב-/signup.html.

דרישות רשת

ארכיטקטורה מינימלית נדרשת

משאבמינימוםמומלץ
RAM2GB4GB+
דיסק500MB פנוי (לתלויות Python)1GB+
CPUDual Core 1.5GHzQuad Core 2.5GHz+
חיבור1Mbps5Mbps+

משתמשי המערכת והפעלתה

משתמש קצה חדש

  1. פתיחת דפדפן ב-http://localhost:5000/.
  2. לחיצה על הקישור "Sign up" בתחתית טופס ה-Login.
  3. מילוי הטופס: First Name, Last Name, Username, Password, Confirm Password.
  4. לחיצה על "Sign Up". המערכת מבצעת ולידציה ושולחת אותך ל-home.html.
  5. בחלון הבית — לחיצה על "Upload File" (drag&drop / browse) להעלאת קובץ ראשון.
  6. שימוש בלשוניות (My Files / Shared with me / Favorites / Recycle Bin) לניווט.
צילום מסך: זרימת משתמש חדש — login → signup → home Login Signup Home סדרת שלושת המסכים שמשתמש חדש עובר: התחברות → הרשמה → דף הבית

משתמש קצה קיים

  1. פתיחת http://localhost:5000/.
  2. הזנת Username + Password בטופס ה-Login → "Log in".
  3. בעמוד הבית: ניהול קבצים ותיקיות, שיתוף, חיפוש, מועדפים, Bulk Operations.
  4. לחיצה על "Profile" בכותרת לעריכת פרטים אישיים ותמונה.
  5. לחיצה על "Logout" בכותרת לסיום סשן.

משתמש Admin

  1. התחברות עם Username = admin ו-Password = admin.
  2. בעמוד הבית מופיע כפתור "Admin" נוסף בכותרת.
  3. לחיצה עליו פותחת את ה-Admin Dashboard.
  4. צפייה בסטטיסטיקות (Total Users / Files / Storage / Shares), טבלת משתמשים, וטבלת All Files.
  5. אפשר למחוק משתמש (חוץ מ-admin) — מחיקה כוללת את כל הקבצים, התיקיות, השיתופים והמועדפים שלו.
צילום מסך: Admin Dashboard Admin dashboard 4 stat cards + טבלת משתמשים + טבלת קבצים

משתמש לקוח שולחני (Desktop Notifier)

  1. הפעלת השרת (מאזין גם על פורט 5001): python server/app.py.
  2. הפעלת האפליקציה: python desktop/app.py (או .\start_desktop.bat).
  3. התחברות עם אותם פרטי משתמש של הדפדפן. ה-status pill הופך לירוק: Connected as <user>.
  4. כשמשתמש אחר משתף איתך קובץ — מופיעה התראת toast מיידית בפינה, וכרטיס קבוע ב-feed; הקובץ נכנס לרשימת "שותף איתי".
  5. בחירת פריט מהרשימה ולחיצה על Download מורידה אותו דרך ה-REST (קבצים בודדים ישירות; תיקיות כ-ZIP מהדפדפן).
  6. כפתור בפינה העליונה מחליף ערכת נושא (dark/light) ושומר את הבחירה.

סיכום אישי / רפלקציה

פרויקט ה-Cloud Storage היה אחד מהאתגרים המקצועיים המשמעותיים ביותר שלי בלימודי החלופה. הוא ארך כשנה מלאה — מהתחייבות בסיסית עם דף Login שכותב טוקן ל-localStorage (commit 1c564b9), ועד מערכת אחסון בענן מלאה עם 11 Blueprints, 7 קולקציות ב-Firestore, 6 עמודי לקוח, וכ-40 endpoints. במהלך הדרך פגשתי החלטות ארכיטקטוניות, באגים מאתגרים, ולמדתי טכנולוגיות שלא הכרתי בתחילת הדרך.

תהליך העבודה, הצלחות ואתגרים

החלטה מרכזית — מעבר מ-MongoDB ל-Firestore: בתחילה תכננתי להשתמש ב-MongoDB מקומי, וכך גם תועד ב-README.md. אך לאחר כמה שבועות הבנתי שאחסון רק במסד מקומי לא מתאים לפרויקט "ענן" אמיתי. ביצעתי migration מלא ל-Firestore + GCS (commits c3e1d60, 1284afb, fd268c8). שינוי זה דרש שכתוב של מודל ההזדהות עם הענן (Service Account), שינוי של כל השאילתות (model מסמכים במקום collections+documents-as-rows), ולמידה של מודל ה-blobs ב-GCS. זה היה אתגר אך גם הזדמנות לימוד יקרת ערך — Firestore היא טכנולוגיה שאני בוודאי איפגש איתה בקריירה.

Refactor של app.py: בנקודת זמן מסוימת קובץ server/app.py היחיד שלי הגיע לכ-2900 שורות וכלל את כל ה-endpoints, את הגדרת Firebase, ואת כל הלוגיקה. הוא היה בלתי נוח לתחזוקה, וכל שינוי קטן הביא לסיכון של רגרסיה במקום אחר. ב-commit 8298772 ביצעתי refactor משמעותי שפיצל את הקובץ ל-11 Blueprints נפרדים תחת server/routes/, ולמודול מרכזי אחד של singletons ב-config.py. אחרי השינוי כל קובץ עומד ב-50–500 שורות בלבד, וכל פיצ'ר חדש נוסף ל-Blueprint המתאים בלי לגעת בקבצים אחרים. למדתי מהר מאוד שהבחירה הנכונה לארגון קוד היא לפי תחום עניין, לא לפי טכנולוגיה.

הבאג שלא נשכח — Redirect Loop: בשלב כלשהו של הפיתוח גיליתי שאחרי לחיצה על "Logout" המשתמש מקבל לולאת redirect אינסופית בין login.html ל-home.html. הסיבה הייתה שב-DOMContentLoaded של home.html, בודקים אם יש token, ואם אין — מפנים ל-login. אבל ב-login.html יש קוד שמנסה לוודא את ה-token שעדיין נשאר במצב ביניים, ומפנה בחזרה ל-home. הפתרון (commits 77674c5, 7c6e442) הצריך ארבעה תיקונים מצטברים: (א) בדיקה ב-DOMContentLoaded שאני אכן בעמוד הנכון (לא להפעיל את לוגיקת ה-Login בעמודי home), (ב) שימוש ב-URL parameter ?from=home כסימן ש-Logout התרחש, (ג) שימוש ב-sessionStorage כ-backup, ו-(ד) ניקוי שיטתי של ה-token לפני הפנייה. הבאג לימד אותי הרבה על איך browser handles ניווט asynchronous, ועל המורכבות של state שמתמשך בין דפים.

אתגרי UX קטנים אך משמעותיים: מספר תיקונים שנראים שוליים — אבל הצטברו לחווית משתמש טובה משמעותית: (א) commit 2be6774 — מירכוז של modals במסכים בכל גודל (קודם הם נצמדו לפינה ימנית עליונה בחלונות גדולים); (ב) commit b6e4cda — הודעת שגיאה ידידותית לכישלון העלאת קובץ כפול (קודם הוצג JSON גולמי ל-Console); (ג) commit 9a4407b — הצגת כל הקבצים כשתיבת החיפוש ריקה (קודם הוסתרו); (ד) הוספת dark/light mode (commit c1c39ca) שהפכה את האפליקציה לנעימה משמעותית לעיניים.

תהליך הלמידה

במסגרת הפרויקט למדתי באופן עצמאי טכנולוגיות שלא הכרתי לפני כן: Firebase Admin SDK לפייתון ומודל ה-Service Account שלו; Google Cloud Storage, מודל ה-blobs והעבודה עם פרפיקסים; JWT והאלגוריתם HS256, האופן בו Header.Payload.Signature מורכב ומאומת; Flask Blueprints ככלי לארגון מודולרי של שרת Web; זרימת אסינכרונית עם async/await ב-JavaScript והשימוש ב-fetch לבקשות AJAX; ZIP-in-memory כטכניקה להחזרת קבצי ארכיון ללא כתיבה לדיסק; ו-ARIA / Accessibility דרך הוספת תכונות aria-label, aria-live, ו-role לכל אלמנט אינטראקטיבי.

כלים שאקח להמשך

תובנות מהתהליך

התובנה החשובה ביותר היא שקוד טוב הוא קוד שאפשר לחזור אליו בקלות. בתחילת הפרויקט כתבתי "מהיר" – פונקציות ארוכות, יציאות מוקדמות, חוסר עקביות בשמות. אחרי 8 חודשים, כשניסיתי להוסיף את פיצ'ר ה-Bulk Operations, לא הצלחתי להבין את הקוד שלי עצמי. ה-refactor שביצעתי לא היה רק שיפור איכות — הוא היה תנאי הכרחי להמשך הפיתוח. גם הוספת תיעוד פנימי (docstrings על כל route, הסברים ב-CLAUDE.md) חסכה לי שעות של חיפוש.

תובנה שנייה: שיתוף ולמידת עמיתים חיוניים. שאלות שהפניתי לעמיתים ולפורומים ברשת (Stack Overflow, Firebase community) קיצרו תהליכי debugging משעות ל-דקות. בנושאים מסוימים (למשל הזרימה בין Service Account ל-Firestore client) הסתמכתי על תיעוד רשמי של Google ועל מדריכים של Flask.

בראייה לאחור — מה הייתי משנה

משאבים נוספים — מה הייתי עושה

אם היו בידי משאבים נוספים, הייתי מוסיפה:

שאלות חקר עצמי

תודות

תודה מיוחדת ל-[שם המנחה], על ההכוונה הסבלנית לאורך הדרך, על השאלות שהפנה אליי בנקודות הקריטיות שגרמו לי לחשוב מחדש על החלטות מהותיות, ועל הזמן שהקדיש לסקירת קוד והערות בנייה. תודה גם לחברי לכיתה שעזרו ב-bouncing of ideas, ובמיוחד למי שעזר לי לאתר את הבאג של ה-redirect loop שהגיע בזמן שלא ידעתי לאן לפנות. ולסיום — תודה לקהילת המפתחים ברשת (Stack Overflow, Firebase Community, MDN) על התיעוד המעולה והתשובות המסייעות.

ביבליוגרפיה

הרשימה ערוכה לפי APA Style — 7th Edition.

  1. Pallets Projects. (2024). Flask Documentation (3.0.x). Retrieved from https://flask.palletsprojects.com/en/3.0.x/
  2. Pallets Projects. (2024). Werkzeug Documentation — Security Helpers. Retrieved from https://werkzeug.palletsprojects.com/en/3.0.x/utils/#module-werkzeug.security
  3. Google. (2024). Firebase Admin Python SDK Reference. Firebase. Retrieved from https://firebase.google.com/docs/reference/admin/python
  4. Google. (2024). Cloud Firestore Documentation. Google Cloud. Retrieved from https://cloud.google.com/firestore/docs
  5. Google. (2024). Cloud Storage Client Libraries — Python. Google Cloud. Retrieved from https://cloud.google.com/storage/docs/reference/libraries
  6. Jones, M., Bradley, J., & Sakimura, N. (2015). JSON Web Token (JWT) — RFC 7519. Internet Engineering Task Force. Retrieved from https://datatracker.ietf.org/doc/html/rfc7519
  7. PyJWT contributors. (2024). PyJWT 2.8.0 Documentation. Retrieved from https://pyjwt.readthedocs.io/en/stable/
  8. OWASP Foundation. (2021). OWASP Top 10 — 2021. Retrieved from https://owasp.org/Top10/
  9. OWASP Foundation. (2024). Password Storage Cheat Sheet. Retrieved from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
  10. Mozilla. (2024). Fetch API — MDN Web Docs. Mozilla Developer Network. Retrieved from https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
  11. Mozilla. (2024). Window.localStorage — MDN Web Docs. Retrieved from https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
  12. Mozilla. (2024). ARIA Authoring Practices Guide. Retrieved from https://www.w3.org/WAI/ARIA/apg/
  13. Python Software Foundation. (2024). zipfile — Work with ZIP archives. Python 3.11 Documentation. Retrieved from https://docs.python.org/3/library/zipfile.html
  14. Postel, J. (1981). Transmission Control Protocol — RFC 793. Internet Engineering Task Force. Retrieved from https://datatracker.ietf.org/doc/html/rfc793
  15. Fielding, R. T., & Reschke, J. (2014). Hypertext Transfer Protocol (HTTP/1.1) — RFC 7230. IETF. Retrieved from https://datatracker.ietf.org/doc/html/rfc7230
  16. JGraph. (2024). draw.io — Online Diagram Software. Retrieved from https://www.drawio.com/
  17. NIST. (2010). SP 800-132 — Recommendation for Password-Based Key Derivation: Part 1: Storage Applications. National Institute of Standards and Technology. Retrieved from https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf

נספחים

נספח א' — הסבר טכנולוגיות שנעשה בהן שימוש

REST (REpresentational State Transfer)

REST הוא סגנון ארכיטקטוני לבניית שירותי רשת. הוא משתמש ב-HTTP methods (GET, POST, PUT, DELETE) כדי לבצע פעולות (קריאה, יצירה, עדכון, מחיקה) על משאבים (Resources) המזוהים ב-URI. כל בקשה היא Stateless — השרת לא שומר זיכרון בין בקשות, מה שמקל על Scale Out. הפרויקט מממש את העיקרון של "המשאבים מזוהים על-ידי URI" — לדוגמה /api/files/{id} מייצג קובץ ספציפי, ופעולות עליו (GET = הורדה, DELETE = מחיקה) מבוצעות באותו URI.

JWT (JSON Web Token)

JWT הוא תקן (RFC 7519) לאסימוני אימות חתומים בפורמט JSON. כל אסימון מורכב משלושה חלקים מופרדים בנקודות: Header (מתאר את אלגוריתם החתימה), Payload (התוכן — claims, למשל username ו-exp), ו-Signature (חתימה קריפטוגרפית). בפרויקט אנו משתמשים באלגוריתם HMAC-SHA256 (HS256), כאשר השרת חותם את ה-payload עם SECRET_KEY, והלקוח שולח את ה-Token בכותרת Authorization: Bearer .... השרת מאמת את החתימה ופותח את ה-payload בכל בקשה מוגנת.

NoSQL Document Database (Firestore)

שלא כמו בסיסי נתונים רלציוניים (MySQL, PostgreSQL) שמאחסנים נתונים בטבלאות עם schema קבוע, Firestore הוא Document Store: מבנה הנתונים הוא collections (אוספים) שמכילים documents (מסמכים). כל מסמך הוא בעצם JSON object גמיש. ייתרון: גמישות בהתפתחות הסכימה. חיסרון: חוסר ב-JOIN, צורך לבנות שאילתות באופן מתחשב במבנה הנתונים. Firestore גם מבטיח עדכוני real-time ו-strong consistency באזור אחד.

Object Storage (Google Cloud Storage)

ב-Object Storage כל קובץ הוא object בעל שם ייחודי (key) בתוך bucket. אין מבנה תיקיות פיזי — תיקיות הן רק חלק משם ה-key (לדוגמה users/galco/files/file.pdf). התכונות העיקריות: redundancy גבוה (אחסון משוכפל גלובלית), מחיר נמוך לבית, hardware abstraction מלא, תמיכה בקבצים גדולים מאוד, וביצועים מצטיינים בקריאה/כתיבה. גישה דרך REST API או client libraries.

CORS (Cross-Origin Resource Sharing)

מנגנון של דפדפנים מודרניים שמונע מ-JavaScript בעמוד מ-origin אחד (לדוגמה localhost:8000) לבצע fetch ל-origin אחר (לדוגמה localhost:5000), אלא אם השרת המקבל מצהיר במפורש בכותרות HTTP על אישור. בפרויקט הוגדר CORS(app, origins="*") שמאשר את כל ה-origins — מספיק לפיתוח, אך בייצור יש לצמצם לכתובות ספציפיות.

Three-Way Handshake (TCP)

פרוטוקול TCP פותח כל חיבור באמצעות לחיצת יד משולשת: SYN (לקוח → שרת, "אני רוצה להתחבר", seq=X), SYN-ACK (שרת → לקוח, "מקובל, גם אני רוצה", seq=Y, ack=X+1), ACK (לקוח → שרת, ack=Y+1). רק לאחר השלמת ה-handshake החיבור מאושר ואפשר לשלוח נתונים. ה-handshake מבטיח ששני הצדדים מודעים אחד לשני וערוכים לשידור, ושיש להם שמות seq משותפים לזיהוי חבילות אבודות.

נספח ב' — צירוף קוד הפרויקט המלא

להלן הקוד המלא של כל קובצי המקור שהוכנו עבור הפרויקט. הקבצים מוצגים מסודרים לפי שכבה (שרת → כלים → מודולי ניתוב → לקוח), עם כותרת לכל קובץ. שני קבצים אינם מצורפים מסיבות אבטחה / רלוונטיות: cloudstorageproject-privatekey.json (Service Account — לא לפרסום), ו- server/app_backup.py (קוד מת — הוחלף ב-Blueprints).

קובץ: server/app.py

"""
Cloud Storage Server - Main Entry Point
This is the slimmed-down entry point that imports and registers all route blueprints.
"""
import os
from flask import jsonify, request
from werkzeug.exceptions import HTTPException

from config import app
from routes import all_blueprints


@app.errorhandler(Exception)
def handle_exception(e):
    """Handle all uncaught exceptions without leaking internals."""
    if isinstance(e, HTTPException):
        return e
    app.logger.exception("Unhandled exception")
    return jsonify({"error": "An internal error occurred"}), 500


@app.after_request
def add_security_headers(response):
    """Apply defensive HTTP headers to every response."""
    response.headers.setdefault('X-Content-Type-Options', 'nosniff')
    response.headers.setdefault('X-Frame-Options', 'DENY')
    response.headers.setdefault('Referrer-Policy', 'no-referrer')
    response.headers.setdefault('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
    response.headers.setdefault(
        'Content-Security-Policy',
        "default-src 'self'; "
        "script-src 'self' 'unsafe-inline'; "
        "style-src 'self' 'unsafe-inline'; "
        "img-src 'self' data:; "
        "connect-src 'self'; "
        "frame-ancestors 'none'; "
        "base-uri 'self'; "
        "form-action 'self'"
    )
    # HSTS only on TLS responses.
    if request.is_secure:
        response.headers.setdefault(
            'Strict-Transport-Security', 'max-age=31536000; includeSubDomains'
        )
    return response


# Register all blueprints
for blueprint in all_blueprints:
    app.register_blueprint(blueprint)


if __name__ == '__main__':
    # Debug mode is now opt-in via FLASK_DEBUG=1; bind to localhost by default.
    debug_mode = os.environ.get('FLASK_DEBUG', '').lower() in ('1', 'true', 'yes')
    host = os.environ.get('FLASK_HOST', '127.0.0.1')

    # TLS is opt-in: drop dev-cert.pem / dev-key.pem in server/certs and the
    # server serves HTTPS; with no certs present it runs plain HTTP.
    cert_path = os.path.join(os.path.dirname(__file__), 'certs', 'dev-cert.pem')
    key_path = os.path.join(os.path.dirname(__file__), 'certs', 'dev-key.pem')
    ssl_context = None
    scheme = 'http'
    if os.path.exists(cert_path) and os.path.exists(key_path):
        ssl_context = (cert_path, key_path)
        scheme = 'https'

    # Start the raw-TCP notification socket server alongside Flask (port 5001).
    # Gate on WERKZEUG_RUN_MAIN so the debug reloader doesn't bind twice.
    if not debug_mode or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
        from socket_server import notification_hub
        socket_host = os.environ.get('SOCKET_HOST', host)
        notification_hub.start(host=socket_host, port=5001)
        print(f"Notification socket server listening on {socket_host}:5001")

    print("Starting Flask server...")
    print(f"Server running at {scheme}://{host}:5000 (debug={debug_mode})")
    app.run(debug=debug_mode, host=host, port=5000, ssl_context=ssl_context)

קובץ: server/config.py

"""
Flask application configuration and initialization.
Contains Flask app, Firebase Admin SDK, and Google Cloud Storage setup.
"""
from flask import Flask
from flask_cors import CORS
import firebase_admin
from firebase_admin import credentials, firestore
from google.cloud import storage
import os
import secrets


def _load_or_create_secret_key():
    """Use SECRET_KEY env var if set; otherwise persist a random 32-byte key
    to server/.secret_key (which is .gitignored)."""
    env_value = os.environ.get('SECRET_KEY')
    if env_value:
        return env_value
    key_file = os.path.join(os.path.dirname(__file__), '.secret_key')
    if os.path.exists(key_file):
        with open(key_file, 'r', encoding='utf-8') as fh:
            value = fh.read().strip()
            if value:
                return value
    value = secrets.token_urlsafe(48)
    with open(key_file, 'w', encoding='utf-8') as fh:
        fh.write(value)
    return value


# Create Flask app
app = Flask(__name__, static_folder='../client', static_url_path='')
app.config['SECRET_KEY'] = _load_or_create_secret_key()
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), 'uploads')
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024  # 16MB max file size

# Create uploads directory if it doesn't exist
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

# CORS: dev defaults to localhost only. Override with CORS_ORIGINS env var
# (comma-separated). Wildcard `*` is no longer the default.
_cors_origins_env = os.environ.get('CORS_ORIGINS', '').strip()
if _cors_origins_env:
    _cors_origins = [o.strip() for o in _cors_origins_env.split(',') if o.strip()]
else:
    _cors_origins = [
        'http://localhost:5000',
        'http://localhost:8000',
        'http://127.0.0.1:5000',
        'http://127.0.0.1:8000',
    ]

CORS(app,
     origins=_cors_origins,
     methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
     allow_headers=["Content-Type", "Authorization"],
     supports_credentials=True)

# Initialize Firebase Admin SDK
cred_path = os.path.join(os.path.dirname(__file__), 'cloudstorageproject-privatekey.json')
cred = credentials.Certificate(cred_path)
firebase_admin.initialize_app(cred)

# Get Firestore database
db = firestore.client()
users_collection = db.collection('users')
items_collection = db.collection('items')
directories_collection = db.collection('directories')
shares_collection = db.collection('shares')
favorites_collection = db.collection('favorites')
recycle_bin_collection = db.collection('recycle_bin')

# Initialize Google Cloud Storage
GCS_BUCKET_NAME = 'clientstorage-6978a.firebasestorage.app'
storage_client = storage.Client.from_service_account_json(cred_path)
gcs_bucket = storage_client.bucket(GCS_BUCKET_NAME)

קובץ: server/seed_db.py

"""
Script to seed Firestore with initial admin user
Run this once to create the admin user: admin/admin
"""
import firebase_admin
from firebase_admin import credentials, firestore
from werkzeug.security import generate_password_hash
import os

# Initialize Firebase Admin SDK
cred_path = os.path.join(os.path.dirname(__file__), 'cloudstorageproject-privatekey.json')
cred = credentials.Certificate(cred_path)
firebase_admin.initialize_app(cred)

# Get Firestore database
db = firestore.client()
users_collection = db.collection('users')

# Check if admin user already exists
admin_query = users_collection.where('username', '==', 'admin').limit(1).get()
existing_admin = list(admin_query)

if existing_admin:
    print("Admin user already exists. Skipping seed.")
else:
    # Create admin user
    admin_user = {
        "username": "admin",
        "password": generate_password_hash("admin")
    }
    users_collection.add(admin_user)
    print("Admin user created successfully!")
    print("  Username: admin")
    print("  Password: admin")

קובץ: server/utils/__init__.py

# Utils package

קובץ: server/utils/auth.py

"""
Authentication decorators for route protection.
"""
from functools import wraps
from flask import request, jsonify
import jwt
from config import app, users_collection


def token_required(f):
    """Decorator to protect routes that require authentication"""
    @wraps(f)
    def decorated(*args, **kwargs):
        token = None
        if 'Authorization' in request.headers:
            auth_header = request.headers['Authorization']
            try:
                token = auth_header.split(' ')[1]  # Bearer <token>
            except IndexError:
                return jsonify({"error": "Invalid token format"}), 401

        if not token:
            return jsonify({"error": "Token is missing"}), 401

        try:
            data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
            user_query = users_collection.where('username', '==', data['username']).limit(1).get()
            user_docs = list(user_query)
            if not user_docs:
                return jsonify({"error": "User not found"}), 401
            current_user = user_docs[0].to_dict()
            current_user['_id'] = user_docs[0].id
        except jwt.ExpiredSignatureError:
            return jsonify({"error": "Token has expired"}), 401
        except jwt.InvalidTokenError:
            return jsonify({"error": "Invalid token"}), 401

        return f(current_user, *args, **kwargs)
    return decorated


def admin_required(f):
    """Decorator for admin-only routes"""
    @wraps(f)
    def decorated_function(*args, **kwargs):
        token = None
        if 'Authorization' in request.headers:
            auth_header = request.headers['Authorization']
            if auth_header.startswith('Bearer '):
                token = auth_header.split(' ')[1]

        if not token:
            return jsonify({"error": "Token is missing"}), 401

        try:
            data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
            if data['username'] != 'admin':
                return jsonify({"error": "Admin access required"}), 403
            user_query = users_collection.where('username', '==', 'admin').limit(1).get()
            user_docs = list(user_query)
            if not user_docs:
                return jsonify({"error": "Admin user not found"}), 401
            current_user = user_docs[0].to_dict()
        except jwt.ExpiredSignatureError:
            return jsonify({"error": "Token has expired"}), 401
        except jwt.InvalidTokenError:
            return jsonify({"error": "Invalid token"}), 401

        return f(current_user, *args, **kwargs)
    return decorated_function

קובץ: server/routes/__init__.py

# Routes package - exports all blueprints
from routes.auth import auth_bp
from routes.user import user_bp, users_bp
from routes.files import files_bp
from routes.directories import directories_bp
from routes.sharing import sharing_bp
from routes.favorites import favorites_bp
from routes.recycle_bin import recycle_bin_bp
from routes.bulk import bulk_bp
from routes.admin import admin_bp
from routes.static_files import static_bp
from routes.items import items_bp

all_blueprints = [
    auth_bp,
    user_bp,
    users_bp,
    files_bp,
    directories_bp,
    sharing_bp,
    favorites_bp,
    recycle_bin_bp,
    bulk_bp,
    admin_bp,
    static_bp,
    items_bp,
]

קובץ: server/routes/auth.py

"""
Authentication routes: signup, login, logout, verify token.
"""
from flask import Blueprint, request, jsonify
from werkzeug.security import check_password_hash, generate_password_hash
from datetime import datetime, timedelta
import jwt

from config import app, users_collection
from utils.auth import token_required

auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')


@auth_bp.route('/signup', methods=['POST'])
def signup():
    """Signup endpoint"""
    data = request.get_json()
    firstName = data.get('firstName', '').strip()
    lastName = data.get('lastName', '').strip()
    username = data.get('username', '').strip()
    password = data.get('password')

    if not firstName or not lastName or not username or not password:
        return jsonify({"error": "First name, last name, username, and password are required"}), 400

    # Validate first name
    if len(firstName) < 2:
        return jsonify({"error": "First name must be at least 2 characters"}), 400
    if len(firstName) > 50:
        return jsonify({"error": "First name must be less than 50 characters"}), 400

    # Validate last name
    if len(lastName) < 2:
        return jsonify({"error": "Last name must be at least 2 characters"}), 400
    if len(lastName) > 50:
        return jsonify({"error": "Last name must be less than 50 characters"}), 400

    # Validate username
    if len(username) < 3:
        return jsonify({"error": "Username must be at least 3 characters"}), 400
    if len(username) > 20:
        return jsonify({"error": "Username must be less than 20 characters"}), 400
    if not username.replace('_', '').isalnum():
        return jsonify({"error": "Username can only contain letters, numbers, and underscores"}), 400

    # Validate password
    if len(password) < 6:
        return jsonify({"error": "Password must be at least 6 characters"}), 400

    # Check if username already exists
    user_query = users_collection.where('username', '==', username).limit(1).get()
    if list(user_query):
        return jsonify({"error": "Username already exists"}), 400

    # Hash password and create user
    hashed_password = generate_password_hash(password)
    new_user = {
        'username': username,
        'password': hashed_password,
        'firstName': firstName,
        'lastName': lastName,
        'profilePicture': None
    }
    users_collection.add(new_user)

    # Generate JWT token
    token = jwt.encode({
        'username': username,
        'exp': datetime.utcnow() + timedelta(hours=24)
    }, app.config['SECRET_KEY'], algorithm='HS256')

    return jsonify({"message": "Account created successfully", "token": token, "username": username}), 201


@auth_bp.route('/login', methods=['POST'])
def login():
    """Login endpoint"""
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')

    if not username or not password:
        return jsonify({"error": "Username and password are required"}), 400

    user_query = users_collection.where('username', '==', username).limit(1).get()
    user_docs = list(user_query)
    if not user_docs:
        return jsonify({"error": "Invalid username or password"}), 401

    user = user_docs[0].to_dict()
    if not check_password_hash(user['password'], password):
        return jsonify({"error": "Invalid username or password"}), 401

    token = jwt.encode({
        'username': user['username'],
        'exp': datetime.utcnow() + timedelta(hours=24)
    }, app.config['SECRET_KEY'], algorithm='HS256')

    return jsonify({"message": "Login successful", "token": token, "username": user['username']}), 200


@auth_bp.route('/verify', methods=['GET'])
@token_required
def verify_token(current_user):
    """Verify if token is valid"""
    return jsonify({"valid": True, "username": current_user['username']}), 200


@auth_bp.route('/logout', methods=['POST'])
@token_required
def logout(current_user):
    """Logout endpoint (client should remove token)"""
    return jsonify({"message": "Logged out successfully"}), 200

קובץ: server/routes/static_files.py

"""
Static file serving routes: HTML pages, CSS, JS files.
"""
from flask import Blueprint, send_from_directory, jsonify
import os

from config import app

static_bp = Blueprint('static_files', __name__)


@static_bp.route('/')
def index():
    return send_from_directory(os.path.join(app.static_folder, 'index'), 'index.html')


@static_bp.route('/home.html')
def home_redirect():
    return send_from_directory(os.path.join(app.static_folder, 'home'), 'home.html')


@static_bp.route('/login.html')
def login_redirect():
    return send_from_directory(os.path.join(app.static_folder, 'login'), 'login.html')


@static_bp.route('/signup.html')
def signup_redirect():
    return send_from_directory(os.path.join(app.static_folder, 'signup'), 'signup.html')


@static_bp.route('/profile.html')
def profile_redirect():
    return send_from_directory(os.path.join(app.static_folder, 'profile'), 'profile.html')


# Serve static files from subdirectories
@static_bp.route('/<path:folder>/<path:filename>')
def serve_static(folder, filename):
    if folder in ['home', 'login', 'index', 'signup', 'profile']:
        response = send_from_directory(os.path.join(app.static_folder, folder), filename)
        if filename.endswith('.js'):
            response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
            response.headers['Pragma'] = 'no-cache'
            response.headers['Expires'] = '0'
        return response
    return "Not found", 404


@static_bp.route('/api/health', methods=['GET'])
def health_check():
    return jsonify({"status": "healthy", "message": "Server is running"}), 200

קובץ: server/routes/user.py

"""
User profile routes: get/update profile, profile picture management.
"""
from flask import Blueprint, request, jsonify, Response
from werkzeug.utils import secure_filename
from datetime import datetime

from config import app, db, users_collection, gcs_bucket
from utils.auth import token_required

user_bp = Blueprint('user', __name__, url_prefix='/api/user')


@user_bp.route('/profile', methods=['GET'])
@token_required
def get_profile(current_user):
    return jsonify({
        "username": current_user['username'],
        "id": current_user.get('_id', ''),
        "firstName": current_user.get('firstName', ''),
        "lastName": current_user.get('lastName', ''),
        "profilePicture": current_user.get('profilePicture', None)
    }), 200


@user_bp.route('/profile', methods=['PUT'])
@token_required
def update_profile(current_user):
    data = request.get_json()
    if not data:
        return jsonify({"error": "No data provided"}), 400

    user_query = users_collection.where('username', '==', current_user['username']).limit(1).get()
    user_docs = list(user_query)
    if not user_docs:
        return jsonify({"error": "User not found"}), 404

    user_doc = user_docs[0]
    update_data = {}

    if 'firstName' in data:
        firstName = str(data.get('firstName') or '').strip()
        if not firstName or len(firstName) < 2 or len(firstName) > 50:
            return jsonify({"error": "First name validation failed"}), 400
        update_data['firstName'] = firstName

    if 'lastName' in data:
        lastName = str(data.get('lastName') or '').strip()
        if not lastName or len(lastName) < 2 or len(lastName) > 50:
            return jsonify({"error": "Last name validation failed"}), 400
        update_data['lastName'] = lastName

    if 'profilePicture' in data:
        update_data['profilePicture'] = data.get('profilePicture')

    if not update_data:
        return jsonify({"error": "No fields to update"}), 400

    user_doc.reference.update(update_data)
    updated_user = user_doc.reference.get().to_dict()
    return jsonify({
        "message": "Profile updated successfully",
        "username": updated_user['username'],
        "firstName": updated_user.get('firstName', ''),
        "lastName": updated_user.get('lastName', ''),
        "profilePicture": updated_user.get('profilePicture', None)
    }), 200


@user_bp.route('/profile/picture', methods=['POST'])
@token_required
def upload_profile_picture(current_user):
    if 'file' not in request.files:
        return jsonify({"error": "No file provided"}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({"error": "No file selected"}), 400

    allowed_extensions = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
    filename = secure_filename(file.filename)
    if '.' not in filename or filename.rsplit('.', 1)[1].lower() not in allowed_extensions:
        return jsonify({"error": "Invalid file type. Only images are allowed"}), 400

    user_query = users_collection.where('username', '==', current_user['username']).limit(1).get()
    user_docs = list(user_query)
    if not user_docs:
        return jsonify({"error": "User not found"}), 404
    user_doc = user_docs[0]

    timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
    profile_filename = f"profile_{current_user['username']}_{timestamp}{filename}"
    gcs_path = f"users/{current_user['username']}/profile/{profile_filename}"

    blob = gcs_bucket.blob(gcs_path)
    file_content = file.read()
    content_type = file.content_type or 'image/jpeg'
    blob.upload_from_string(file_content, content_type=content_type)

    # Delete old profile picture
    old_profile_picture = current_user.get('profilePicture')
    if old_profile_picture and old_profile_picture.startswith('/api/user/profile/picture/'):
        old_filename = old_profile_picture.replace('/api/user/profile/picture/', '')
        old_gcs_path = f"users/{current_user['username']}/profile/{old_filename}"
        try:
            old_blob = gcs_bucket.blob(old_gcs_path)
            if old_blob.exists():
                old_blob.delete()
        except Exception as e:
            print(f"Error deleting old profile picture: {str(e)}")

    profile_picture_url = f"/api/user/profile/picture/{profile_filename}"
    user_doc.reference.update({'profilePicture': profile_picture_url})
    return jsonify({"message": "Profile picture uploaded successfully", "profilePicture": profile_picture_url}), 200


@user_bp.route('/profile/picture/<filename>', methods=['GET'])
@token_required
def get_profile_picture(current_user, filename):
    if not filename.startswith(f"profile_{current_user['username']}_"):
        return jsonify({"error": "Unauthorized access"}), 403
    gcs_path = f"users/{current_user['username']}/profile/{filename}"
    blob = gcs_bucket.blob(gcs_path)
    if not blob.exists():
        return jsonify({"error": "Profile picture not found"}), 404
    content_type = 'image/jpeg'
    if filename.lower().endswith('.png'): content_type = 'image/png'
    elif filename.lower().endswith('.gif'): content_type = 'image/gif'
    elif filename.lower().endswith('.webp'): content_type = 'image/webp'
    return Response(blob.download_as_bytes(), mimetype=content_type)


# Additional users blueprint
users_bp = Blueprint('users', __name__, url_prefix='/api/users')


@users_bp.route('/<username>/profile-picture', methods=['GET'])
@token_required
def get_user_profile_picture(current_user, username):
    user_query = users_collection.where('username', '==', username).limit(1).get()
    user_docs = list(user_query)
    if not user_docs:
        return jsonify({"error": "User not found"}), 404
    user_data = user_docs[0].to_dict()
    profile_picture = user_data.get('profilePicture')
    if not profile_picture or not profile_picture.startswith('/api/user/profile/picture/'):
        return jsonify({"error": "No profile picture"}), 404
    filename = profile_picture.replace('/api/user/profile/picture/', '')
    gcs_path = f"users/{username}/profile/{filename}"
    blob = gcs_bucket.blob(gcs_path)
    if not blob.exists():
        return jsonify({"error": "Profile picture not found"}), 404
    content_type = 'image/jpeg'
    if filename.lower().endswith('.png'): content_type = 'image/png'
    elif filename.lower().endswith('.gif'): content_type = 'image/gif'
    elif filename.lower().endswith('.webp'): content_type = 'image/webp'
    return Response(blob.download_as_bytes(), mimetype=content_type)

קובץ: server/routes/files.py

"""
File operations routes: upload, get, download, delete, search.
"""
from flask import Blueprint, request, jsonify, Response
from werkzeug.utils import secure_filename
from datetime import datetime, timedelta

from config import (db, users_collection, directories_collection,
    shares_collection, favorites_collection, recycle_bin_collection, gcs_bucket)
from utils.auth import token_required

files_bp = Blueprint('files', __name__, url_prefix='/api')


@files_bp.route('/upload', methods=['POST'])
@token_required
def upload_file(current_user):
    if 'file' not in request.files:
        return jsonify({"error": "No file provided"}), 400
    file = request.files['file']
    if file.filename == '':
        return jsonify({"error": "No file selected"}), 400

    directory_id = request.form.get('directory_id', None)

    if directory_id:
        dir_doc = directories_collection.document(directory_id).get()
        if not dir_doc.exists:
            return jsonify({"error": "Directory not found"}), 404
        dir_data = dir_doc.to_dict()
        if dir_data.get('created_by') != current_user['username']:
            return jsonify({"error": "Unauthorized access to directory"}), 403

    # Check for duplicate filename in the same directory
    files_collection = db.collection('files')
    existing_files = files_collection.where('uploaded_by', '==', current_user['username']).get()
    for doc in existing_files:
        existing_file = doc.to_dict()
        if existing_file.get('filename') == file.filename:
            if existing_file.get('directory_id') == directory_id:
                return jsonify({"error": f"A file named '{file.filename}' already exists in this location"}), 400

    filename = secure_filename(file.filename)
    timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
    stored_filename = timestamp + filename
    gcs_path = f"users/{current_user['username']}/files/{stored_filename}"

    blob = gcs_bucket.blob(gcs_path)
    file_content = file.read()
    file_size = len(file_content)
    content_type = file.content_type or 'application/octet-stream'
    blob.upload_from_string(file_content, content_type=content_type)

    file_data = {
        'filename': file.filename,
        'stored_filename': stored_filename,
        'gcs_path': gcs_path,
        'uploaded_by': current_user['username'],
        'uploaded_at': datetime.utcnow().isoformat(),
        'size': file_size,
        'content_type': content_type,
        'directory_id': directory_id
    }
    files_collection.add(file_data)

    return jsonify({"message": "File uploaded successfully", "filename": file.filename,
                    "stored_filename": stored_filename, "directory_id": directory_id}), 200


@files_bp.route('/files', methods=['GET'])
@token_required
def get_files(current_user):
    directory_id = request.args.get('directory_id', None)
    files_collection = db.collection('files')
    files_query = files_collection.where('uploaded_by', '==', current_user['username']).get()

    # Build shares map
    shares_query = shares_collection.where('owner', '==', current_user['username']).get()
    shares_map = {}
    for share_doc in shares_query:
        share_data = share_doc.to_dict()
        item_id = share_data.get('item_id')
        shared_with = share_data.get('shared_with')
        if item_id not in shares_map: shares_map[item_id] = []
        user_query = users_collection.where('username', '==', shared_with).limit(1).get()
        user_docs = list(user_query)
        if user_docs:
            user_data = user_docs[0].to_dict()
            shares_map[item_id].append({'username': shared_with, 'firstName': user_data.get('firstName',''),
                'lastName': user_data.get('lastName',''), 'profilePicture': user_data.get('profilePicture')})

    # Build favorites set
    favorites_query = favorites_collection.where('user', '==', current_user['username']).get()
    favorites_set = {fav_doc.to_dict().get('item_id') for fav_doc in favorites_query}

    files, files_to_delete = [], []
    for doc in files_query:
        file_data = doc.to_dict()
        file_directory_id = file_data.get('directory_id')
        if directory_id is not None:
            if file_directory_id != directory_id: continue
        elif file_directory_id is not None:
            continue
        gcs_path = file_data.get('gcs_path')
        if gcs_path:
            blob = gcs_bucket.blob(gcs_path)
            if blob.exists():
                files.append({'id': doc.id, 'filename': file_data.get('filename',''),
                    'stored_filename': file_data.get('stored_filename',''),
                    'uploaded_at': file_data.get('uploaded_at',''), 'size': file_data.get('size',0),
                    'directory_id': file_directory_id, 'type': 'file',
                    'shared_with': shares_map.get(doc.id, []), 'is_favorited': doc.id in favorites_set})
            else:
                files_to_delete.append(doc.id)
        else:
            files_to_delete.append(doc.id)

    for file_id in files_to_delete:
        try: files_collection.document(file_id).delete()
        except Exception as e: print(f"Error deleting file {file_id}: {e}")

    # Directories
    dirs_query = directories_collection.where('created_by', '==', current_user['username']).get()
    directories = []
    for doc in dirs_query:
        dir_data = doc.to_dict()
        dir_parent_id = dir_data.get('parent_directory_id')
        if directory_id is not None:
            if dir_parent_id != directory_id: continue
        elif dir_parent_id is not None:
            continue
        directories.append({'id': doc.id, 'name': dir_data.get('name',''),
            'created_at': dir_data.get('created_at',''), 'parent_directory_id': dir_parent_id,
            'type': 'directory', 'shared_with': shares_map.get(doc.id, []),
            'is_favorited': doc.id in favorites_set})

    files.sort(key=lambda x: x.get('uploaded_at',''), reverse=True)
    files.sort(key=lambda x: not x.get('is_favorited', False))
    directories.sort(key=lambda x: x.get('created_at',''), reverse=True)
    directories.sort(key=lambda x: not x.get('is_favorited', False))

    return jsonify({"files": files, "directories": directories}), 200


@files_bp.route('/files/search', methods=['GET'])
@token_required
def search_files(current_user):
    query = request.args.get('q', '').strip().lower()
    if not query: return jsonify({"files": [], "directories": []}), 200

    files_collection = db.collection('files')
    files_query = files_collection.where('uploaded_by', '==', current_user['username']).get()
    dirs_query = directories_collection.where('created_by', '==', current_user['username']).get()

    dir_map = {doc.id: {'id': doc.id, 'name': doc.to_dict().get('name',''),
        'parent_directory_id': doc.to_dict().get('parent_directory_id')} for doc in dirs_query}

    def get_directory_path(dir_id):
        if not dir_id or dir_id not in dir_map: return []
        dir_info = dir_map[dir_id]
        return get_directory_path(dir_info.get('parent_directory_id')) + [dir_info]

    matching_files, matching_directories = [], []
    for doc in files_query:
        file_data = doc.to_dict()
        if query in file_data.get('filename','').lower():
            gcs_path = file_data.get('gcs_path')
            if gcs_path and gcs_bucket.blob(gcs_path).exists():
                path_parts = get_directory_path(file_data.get('directory_id'))
                matching_files.append({'id': doc.id, 'filename': file_data.get('filename',''),
                    'uploaded_at': file_data.get('uploaded_at',''), 'size': file_data.get('size',0),
                    'directory_id': file_data.get('directory_id'),
                    'path': '/'.join([d['name'] for d in path_parts]), 'type': 'file'})

    for doc in dirs_query:
        dir_data = doc.to_dict()
        if query in dir_data.get('name','').lower():
            path_parts = get_directory_path(dir_data.get('parent_directory_id'))
            matching_directories.append({'id': doc.id, 'name': dir_data.get('name',''),
                'created_at': dir_data.get('created_at',''),
                'parent_directory_id': dir_data.get('parent_directory_id'),
                'path': '/'.join([d['name'] for d in path_parts]), 'type': 'directory'})

    return jsonify({"files": matching_files, "directories": matching_directories}), 200


@files_bp.route('/files/<file_id>/download', methods=['GET'])
@token_required
def download_file(current_user, file_id):
    files_collection = db.collection('files')
    file_doc = files_collection.document(file_id).get()
    if not file_doc.exists:
        return jsonify({"error": "File not found"}), 404
    file_data = file_doc.to_dict()
    if file_data.get('uploaded_by') != current_user['username']:
        return jsonify({"error": "Unauthorized access"}), 403

    gcs_path = file_data.get('gcs_path')
    blob = gcs_bucket.blob(gcs_path)
    if not blob.exists():
        try: files_collection.document(file_id).delete()
        except: pass
        return jsonify({"error": "File not found in storage"}), 404

    original_filename = file_data.get('filename', 'download')
    content_type = file_data.get('content_type', 'application/octet-stream')
    file_content = blob.download_as_bytes()
    return Response(file_content, mimetype=content_type,
        headers={'Content-Disposition': f'attachment; filename="{original_filename}"',
                 'Content-Length': len(file_content)})


@files_bp.route('/files/<file_id>', methods=['DELETE'])
@token_required
def delete_file(current_user, file_id):
    files_collection = db.collection('files')
    file_doc = files_collection.document(file_id).get()
    if not file_doc.exists:
        return jsonify({"error": "File not found"}), 404
    file_data = file_doc.to_dict()
    if file_data.get('uploaded_by') != current_user['username']:
        return jsonify({"error": "Unauthorized access"}), 403

    expiration_date = datetime.utcnow() + timedelta(days=30)
    recycle_bin_collection.add({
        'item_id': file_id, 'item_type': 'file',
        'item_name': file_data.get('filename', ''),
        'original_data': file_data,
        'deleted_by': current_user['username'],
        'deleted_at': datetime.utcnow().isoformat(),
        'expires_at': expiration_date.isoformat()
    })
    file_doc.reference.delete()
    return jsonify({"message": "File moved to recycle bin"}), 200

קובץ: server/routes/directories.py

"""
Directory operations routes: create, list, download, delete directories.
"""
from flask import Blueprint, request, jsonify, send_file
from datetime import datetime, timedelta
import zipfile, io

from config import db, directories_collection, recycle_bin_collection, gcs_bucket
from utils.auth import token_required

directories_bp = Blueprint('directories', __name__, url_prefix='/api/directories')


@directories_bp.route('', methods=['GET'])
@token_required
def get_all_directories(current_user):
    dirs_query = directories_collection.where('created_by', '==', current_user['username']).get()
    directories = [{'id': doc.id, 'name': doc.to_dict().get('name',''),
        'created_at': doc.to_dict().get('created_at',''),
        'parent_directory_id': doc.to_dict().get('parent_directory_id')} for doc in dirs_query]
    return jsonify({"directories": directories}), 200


@directories_bp.route('', methods=['POST'])
@token_required
def create_directory(current_user):
    data = request.get_json()
    name = data.get('name', '').strip()
    parent_directory_id = data.get('parent_directory_id', None)

    if not name:
        return jsonify({"error": "Directory name is required"}), 400

    if parent_directory_id:
        parent_doc = directories_collection.document(parent_directory_id).get()
        if not parent_doc.exists:
            return jsonify({"error": "Parent directory not found"}), 404
        if parent_doc.to_dict().get('created_by') != current_user['username']:
            return jsonify({"error": "Unauthorized access to parent directory"}), 403

    existing_dirs = directories_collection.where('created_by', '==', current_user['username']).get()
    for dir_doc in existing_dirs:
        dir_data = dir_doc.to_dict()
        if dir_data.get('name') == name and dir_data.get('parent_directory_id') == parent_directory_id:
            return jsonify({"error": "Directory with this name already exists"}), 400

    directory_data = {
        'name': name, 'created_by': current_user['username'],
        'created_at': datetime.utcnow().isoformat(),
        'parent_directory_id': parent_directory_id
    }
    dir_ref = directories_collection.add(directory_data)
    return jsonify({"message": "Directory created successfully",
        "directory": {"id": dir_ref[1].id, "name": name,
                      "parent_directory_id": parent_directory_id,
                      "created_at": directory_data['created_at']}}), 201


@directories_bp.route('/<directory_id>/download', methods=['GET'])
@token_required
def download_directory(current_user, directory_id):
    dir_doc = directories_collection.document(directory_id).get()
    if not dir_doc.exists:
        return jsonify({"error": "Directory not found"}), 404
    dir_data = dir_doc.to_dict()
    if dir_data.get('created_by') != current_user['username']:
        return jsonify({"error": "Unauthorized access"}), 403

    files_collection = db.collection('files')

    def get_all_files_in_directory(dir_id, base_path=''):
        files_list, dirs_list = [], []
        files_query = files_collection.where('uploaded_by', '==', current_user['username']).get()
        for doc in files_query:
            file_data = doc.to_dict()
            if file_data.get('directory_id') == dir_id:
                relative_path = f"{base_path}{file_data.get('filename','')}" if base_path else file_data.get('filename','')
                files_list.append({'gcs_path': file_data.get('gcs_path',''),
                    'filename': file_data.get('filename',''), 'relative_path': relative_path})
        dirs_query = directories_collection.where('created_by', '==', current_user['username']).get()
        for doc in dirs_query:
            subdir_data = doc.to_dict()
            if subdir_data.get('parent_directory_id') == dir_id:
                dirs_list.append({'id': doc.id, 'name': subdir_data.get('name','')})
        for subdir in dirs_list:
            sub_base = f"{base_path}{subdir['name']}/" if base_path else f"{subdir['name']}/"
            sub_files, _ = get_all_files_in_directory(subdir['id'], sub_base)
            files_list.extend(sub_files)
        return files_list, dirs_list

    all_files, _ = get_all_files_in_directory(directory_id, '')
    if not all_files:
        return jsonify({"error": "Directory is empty"}), 400

    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
        for file_info in all_files:
            gcs_path = file_info.get('gcs_path')
            if gcs_path:
                blob = gcs_bucket.blob(gcs_path)
                if blob.exists():
                    arcname = file_info.get('relative_path', file_info['filename'])
                    zip_file.writestr(arcname, blob.download_as_bytes())
    zip_buffer.seek(0)
    return send_file(zip_buffer, mimetype='application/zip', as_attachment=True,
        download_name=f"{dir_data.get('name','directory')}.zip")


@directories_bp.route('/<directory_id>', methods=['DELETE'])
@token_required
def delete_directory(current_user, directory_id):
    dir_doc = directories_collection.document(directory_id).get()
    if not dir_doc.exists:
        return jsonify({"error": "Directory not found"}), 404
    dir_data = dir_doc.to_dict()
    if dir_data.get('created_by') != current_user['username']:
        return jsonify({"error": "Unauthorized access"}), 403

    files_collection = db.collection('files')
    expiration_date = datetime.utcnow() + timedelta(days=30)

    def collect_directory_contents(dir_id, parent_path=''):
        files_list, dirs_list = [], []
        files_query = files_collection.where('uploaded_by', '==', current_user['username']).get()
        for doc in files_query:
            file_data = doc.to_dict()
            if file_data.get('directory_id') == dir_id:
                files_list.append({'doc_id': doc.id, 'data': file_data, 'path': parent_path})
        dirs_query = directories_collection.where('created_by', '==', current_user['username']).get()
        for doc in dirs_query:
            subdir_data = doc.to_dict()
            if subdir_data.get('parent_directory_id') == dir_id:
                dir_path = f"{parent_path}/{subdir_data.get('name','')}" if parent_path else subdir_data.get('name','')
                dirs_list.append({'doc_id': doc.id, 'data': subdir_data, 'path': dir_path})
                sub_files, sub_dirs = collect_directory_contents(doc.id, dir_path)
                files_list.extend(sub_files); dirs_list.extend(sub_dirs)
        return files_list, dirs_list

    all_files, all_dirs = collect_directory_contents(directory_id, dir_data.get('name', ''))

    for file_info in all_files:
        recycle_bin_collection.add({
            'item_id': file_info['doc_id'], 'item_type': 'file',
            'item_name': file_info['data'].get('filename', ''),
            'original_data': file_info['data'],
            'deleted_by': current_user['username'],
            'deleted_at': datetime.utcnow().isoformat(),
            'expires_at': expiration_date.isoformat(),
            'parent_directory_id': directory_id, 'path': file_info['path']})
        files_collection.document(file_info['doc_id']).delete()

    for dir_info in all_dirs:
        recycle_bin_collection.add({
            'item_id': dir_info['doc_id'], 'item_type': 'directory',
            'item_name': dir_info['data'].get('name', ''),
            'original_data': dir_info['data'],
            'deleted_by': current_user['username'],
            'deleted_at': datetime.utcnow().isoformat(),
            'expires_at': expiration_date.isoformat(),
            'parent_directory_id': directory_id, 'path': dir_info['path']})
        directories_collection.document(dir_info['doc_id']).delete()

    recycle_bin_collection.add({
        'item_id': directory_id, 'item_type': 'directory',
        'item_name': dir_data.get('name', ''),
        'original_data': dir_data,
        'deleted_by': current_user['username'],
        'deleted_at': datetime.utcnow().isoformat(),
        'expires_at': expiration_date.isoformat(),
        'parent_directory_id': None, 'path': ''})
    dir_doc.reference.delete()
    return jsonify({"message": "Directory moved to recycle bin"}), 200

קובץ: server/routes/sharing.py

"""
Sharing routes: share/unshare items, get shared items, download shared files.
"""
from flask import Blueprint, request, jsonify, Response, send_file
from datetime import datetime
import zipfile, io

from config import (db, users_collection, directories_collection,
    shares_collection, favorites_collection, gcs_bucket)
from utils.auth import token_required

sharing_bp = Blueprint('sharing', __name__, url_prefix='/api')


@sharing_bp.route('/users/search', methods=['GET'])
@token_required
def search_users(current_user):
    query = request.args.get('q', '').strip().lower()
    if len(query) < 2:
        return jsonify({"users": []}), 200
    matching_users = []
    for doc in users_collection.stream():
        user_data = doc.to_dict()
        username = user_data.get('username', '')
        if username != current_user['username'] and query in username.lower():
            matching_users.append({'username': username,
                'firstName': user_data.get('firstName',''), 'lastName': user_data.get('lastName',''),
                'profilePicture': user_data.get('profilePicture')})
    return jsonify({"users": matching_users[:10]}), 200


@sharing_bp.route('/share', methods=['POST'])
@token_required
def share_item(current_user):
    data = request.get_json()
    item_id, item_type = data.get('item_id'), data.get('item_type')
    share_with_username = data.get('share_with')
    if not item_id or not item_type or not share_with_username:
        return jsonify({"error": "item_id, item_type, and share_with are required"}), 400
    if item_type not in ['file', 'directory']:
        return jsonify({"error": "item_type must be 'file' or 'directory'"}), 400

    if item_type == 'file':
        item_doc = db.collection('files').document(item_id).get()
        if not item_doc.exists:
            return jsonify({"error": "File not found"}), 404
        item_data = item_doc.to_dict()
        if item_data.get('uploaded_by') != current_user['username']:
            return jsonify({"error": "You can only share your own files"}), 403
        item_name = item_data.get('filename', '')
    else:
        dir_doc = directories_collection.document(item_id).get()
        if not dir_doc.exists:
            return jsonify({"error": "Directory not found"}), 404
        dir_data = dir_doc.to_dict()
        if dir_data.get('created_by') != current_user['username']:
            return jsonify({"error": "You can only share your own directories"}), 403
        item_name = dir_data.get('name', '')

    target = users_collection.where('username', '==', share_with_username).limit(1).get()
    if not list(target):
        return jsonify({"error": "User not found"}), 404
    if share_with_username == current_user['username']:
        return jsonify({"error": "Cannot share with yourself"}), 400

    existing = shares_collection.where('item_id','==',item_id).where('shared_with','==',share_with_username).limit(1).get()
    if list(existing):
        return jsonify({"error": "Item already shared with this user"}), 400

    share_data = {
        'item_id': item_id, 'item_type': item_type, 'item_name': item_name,
        'owner': current_user['username'], 'shared_with': share_with_username,
        'shared_at': datetime.utcnow().isoformat()
    }
    shares_collection.add(share_data)
    return jsonify({"message": f"Successfully shared with {share_with_username}", "share": share_data}), 201


@sharing_bp.route('/share/<item_id>', methods=['DELETE'])
@token_required
def unshare_item(current_user, item_id):
    share_with_username = request.args.get('username')
    if not share_with_username:
        return jsonify({"error": "username parameter is required"}), 400
    share_query = shares_collection.where('item_id','==',item_id).where('owner','==',current_user['username']).where('shared_with','==',share_with_username).limit(1).get()
    share_docs = list(share_query)
    if not share_docs:
        return jsonify({"error": "Share not found"}), 404
    share_docs[0].reference.delete()
    return jsonify({"message": "Share removed successfully"}), 200


@sharing_bp.route('/share/<item_id>/users', methods=['GET'])
@token_required
def get_shared_users(current_user, item_id):
    files_collection = db.collection('files')
    file_doc = files_collection.document(item_id).get()
    is_owner = False
    if file_doc.exists:
        if file_doc.to_dict().get('uploaded_by') == current_user['username']: is_owner = True
    else:
        dir_doc = directories_collection.document(item_id).get()
        if dir_doc.exists and dir_doc.to_dict().get('created_by') == current_user['username']: is_owner = True
    if not is_owner:
        return jsonify({"error": "Unauthorized"}), 403

    shares_query = shares_collection.where('item_id','==',item_id).where('owner','==',current_user['username']).get()
    shared_users = []
    for doc in shares_query:
        share_data = doc.to_dict()
        user_query = users_collection.where('username','==',share_data['shared_with']).limit(1).get()
        user_docs = list(user_query)
        if user_docs:
            user_data = user_docs[0].to_dict()
            shared_users.append({'username': share_data['shared_with'],
                'firstName': user_data.get('firstName',''), 'lastName': user_data.get('lastName',''),
                'profilePicture': user_data.get('profilePicture'), 'shared_at': share_data.get('shared_at','')})
    return jsonify({"users": shared_users}), 200


@sharing_bp.route('/shared-with-me', methods=['GET'])
@token_required
def get_shared_with_me(current_user):
    shares_query = shares_collection.where('shared_with','==',current_user['username']).get()
    favorites_query = favorites_collection.where('user','==',current_user['username']).get()
    favorites_set = {fav_doc.to_dict().get('item_id') for fav_doc in favorites_query}

    files, directories = [], []
    files_collection = db.collection('files')
    for doc in shares_query:
        share_data = doc.to_dict()
        item_id, item_type = share_data.get('item_id'), share_data.get('item_type')
        owner_username = share_data.get('owner')
        owner_info = {'username': owner_username, 'profilePicture': None, 'firstName': '', 'lastName': ''}
        owner_query = users_collection.where('username','==',owner_username).limit(1).get()
        owner_docs = list(owner_query)
        if owner_docs:
            owner_data = owner_docs[0].to_dict()
            owner_info.update({'profilePicture': owner_data.get('profilePicture'),
                'firstName': owner_data.get('firstName',''), 'lastName': owner_data.get('lastName','')})

        if item_type == 'file':
            file_doc = files_collection.document(item_id).get()
            if file_doc.exists:
                file_data = file_doc.to_dict()
                gcs_path = file_data.get('gcs_path')
                if gcs_path and gcs_bucket.blob(gcs_path).exists():
                    files.append({'id': item_id, 'filename': file_data.get('filename',''),
                        'uploaded_at': file_data.get('uploaded_at',''),
                        'size': file_data.get('size',0), 'type': 'file', 'owner': owner_info,
                        'shared_at': share_data.get('shared_at',''),
                        'is_favorited': item_id in favorites_set})
        else:
            dir_doc = directories_collection.document(item_id).get()
            if dir_doc.exists:
                dir_data = dir_doc.to_dict()
                directories.append({'id': item_id, 'name': dir_data.get('name',''),
                    'created_at': dir_data.get('created_at',''), 'type': 'directory',
                    'owner': owner_info, 'shared_at': share_data.get('shared_at',''),
                    'is_favorited': item_id in favorites_set})
    return jsonify({"files": files, "directories": directories}), 200


@sharing_bp.route('/shared-files/<file_id>/download', methods=['GET'])
@token_required
def download_shared_file(current_user, file_id):
    share_query = shares_collection.where('item_id','==',file_id).where('shared_with','==',current_user['username']).where('item_type','==','file').limit(1).get()
    if not list(share_query):
        return jsonify({"error": "File not shared with you"}), 403
    files_collection = db.collection('files')
    file_doc = files_collection.document(file_id).get()
    if not file_doc.exists:
        return jsonify({"error": "File not found"}), 404
    file_data = file_doc.to_dict()
    gcs_path = file_data.get('gcs_path')
    blob = gcs_bucket.blob(gcs_path)
    if not blob.exists():
        return jsonify({"error": "File not found in storage"}), 404
    content_type = file_data.get('content_type', 'application/octet-stream')
    file_content = blob.download_as_bytes()
    return Response(file_content, mimetype=content_type,
        headers={'Content-Disposition': f'attachment; filename="{file_data.get("filename", "download")}"',
                 'Content-Length': len(file_content)})


@sharing_bp.route('/shared-directories/<directory_id>/download', methods=['GET'])
@token_required
def download_shared_directory(current_user, directory_id):
    share_query = shares_collection.where('item_id','==',directory_id).where('shared_with','==',current_user['username']).where('item_type','==','directory').limit(1).get()
    if not list(share_query):
        return jsonify({"error": "Directory not shared with you"}), 403
    dir_doc = directories_collection.document(directory_id).get()
    if not dir_doc.exists:
        return jsonify({"error": "Directory not found"}), 404
    dir_data = dir_doc.to_dict()
    owner_username = dir_data.get('created_by')
    files_collection = db.collection('files')

    def get_all_files(dir_id, base_path=''):
        files_list = []
        for doc in files_collection.where('uploaded_by','==',owner_username).get():
            file_data = doc.to_dict()
            if file_data.get('directory_id') == dir_id:
                rel = f"{base_path}{file_data.get('filename','')}" if base_path else file_data.get('filename','')
                files_list.append({'gcs_path': file_data.get('gcs_path',''), 'filename': file_data.get('filename',''),
                    'relative_path': rel})
        sub_dirs = []
        for doc in directories_collection.where('created_by','==',owner_username).get():
            sd = doc.to_dict()
            if sd.get('parent_directory_id') == dir_id:
                sub_dirs.append({'id': doc.id, 'name': sd.get('name','')})
        for sd in sub_dirs:
            sub_base = f"{base_path}{sd['name']}/" if base_path else f"{sd['name']}/"
            files_list.extend(get_all_files(sd['id'], sub_base))
        return files_list

    all_files = get_all_files(directory_id)
    if not all_files:
        return jsonify({"error": "Directory is empty"}), 400
    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
        for fi in all_files:
            blob = gcs_bucket.blob(fi['gcs_path'])
            if blob.exists():
                zip_file.writestr(fi.get('relative_path', fi['filename']), blob.download_as_bytes())
    zip_buffer.seek(0)
    return send_file(zip_buffer, mimetype='application/zip', as_attachment=True,
        download_name=f"{dir_data.get('name','directory')}.zip")

קובץ: server/routes/favorites.py

"""
Favorites routes: add, remove, get favorites.
"""
from flask import Blueprint, request, jsonify
from datetime import datetime

from config import (db, users_collection, directories_collection,
    shares_collection, favorites_collection, gcs_bucket)
from utils.auth import token_required

favorites_bp = Blueprint('favorites', __name__, url_prefix='/api/favorites')


@favorites_bp.route('', methods=['POST'])
@token_required
def add_favorite(current_user):
    data = request.get_json()
    item_id, item_type = data.get('item_id'), data.get('item_type')
    if not item_id or not item_type:
        return jsonify({"error": "item_id and item_type are required"}), 400
    if item_type not in ['file', 'directory']:
        return jsonify({"error": "item_type must be 'file' or 'directory'"}), 400

    if item_type == 'file':
        item_doc = db.collection('files').document(item_id).get()
        if not item_doc.exists:
            return jsonify({"error": "File not found"}), 404
        item_data = item_doc.to_dict()
        is_owner = item_data.get('uploaded_by') == current_user['username']
        share_query = shares_collection.where('item_id','==',item_id).where('shared_with','==',current_user['username']).limit(1).get()
        if not is_owner and not list(share_query):
            return jsonify({"error": "File not accessible"}), 403
        item_name = item_data.get('filename', '')
    else:
        dir_doc = directories_collection.document(item_id).get()
        if not dir_doc.exists:
            return jsonify({"error": "Directory not found"}), 404
        dir_data = dir_doc.to_dict()
        is_owner = dir_data.get('created_by') == current_user['username']
        share_query = shares_collection.where('item_id','==',item_id).where('shared_with','==',current_user['username']).limit(1).get()
        if not is_owner and not list(share_query):
            return jsonify({"error": "Directory not accessible"}), 403
        item_name = dir_data.get('name', '')

    existing_fav = favorites_collection.where('item_id','==',item_id).where('user','==',current_user['username']).limit(1).get()
    if list(existing_fav):
        return jsonify({"error": "Item already in favorites"}), 400

    fav_data = {'item_id': item_id, 'item_type': item_type, 'item_name': item_name,
        'user': current_user['username'], 'favorited_at': datetime.utcnow().isoformat()}
    favorites_collection.add(fav_data)
    return jsonify({"message": "Added to favorites", "favorite": fav_data}), 201


@favorites_bp.route('/<item_id>', methods=['DELETE'])
@token_required
def remove_favorite(current_user, item_id):
    fav_query = favorites_collection.where('item_id','==',item_id).where('user','==',current_user['username']).limit(1).get()
    fav_docs = list(fav_query)
    if not fav_docs:
        return jsonify({"error": "Item not in favorites"}), 404
    fav_docs[0].reference.delete()
    return jsonify({"message": "Removed from favorites"}), 200


@favorites_bp.route('', methods=['GET'])
@token_required
def get_favorites(current_user):
    fav_query = favorites_collection.where('user','==',current_user['username']).get()
    files, directories = [], []
    files_collection = db.collection('files')

    for doc in fav_query:
        fav_data = doc.to_dict()
        item_id, item_type = fav_data.get('item_id'), fav_data.get('item_type')
        if item_type == 'file':
            file_doc = files_collection.document(item_id).get()
            if file_doc.exists:
                file_data = file_doc.to_dict()
                is_owner = file_data.get('uploaded_by') == current_user['username']
                gcs_path = file_data.get('gcs_path')
                if gcs_path and gcs_bucket.blob(gcs_path).exists():
                    files.append({'id': item_id, 'filename': file_data.get('filename',''),
                        'uploaded_at': file_data.get('uploaded_at',''), 'size': file_data.get('size',0),
                        'type': 'file', 'is_owner': is_owner,
                        'favorited_at': fav_data.get('favorited_at','')})
                else:
                    doc.reference.delete()
            else:
                doc.reference.delete()
        else:
            dir_doc = directories_collection.document(item_id).get()
            if dir_doc.exists:
                dir_data = dir_doc.to_dict()
                is_owner = dir_data.get('created_by') == current_user['username']
                directories.append({'id': item_id, 'name': dir_data.get('name',''),
                    'created_at': dir_data.get('created_at',''), 'type': 'directory',
                    'is_owner': is_owner, 'favorited_at': fav_data.get('favorited_at','')})
            else:
                doc.reference.delete()
    return jsonify({"files": files, "directories": directories}), 200

קובץ: server/routes/recycle_bin.py

"""
Recycle bin routes: get, restore, empty, cleanup expired items.
"""
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta

from config import db, directories_collection, recycle_bin_collection, gcs_bucket
from utils.auth import token_required

recycle_bin_bp = Blueprint('recycle_bin', __name__, url_prefix='/api/recycle-bin')


def cleanup_expired_items(username=None):
    """Clean up expired items from recycle bin (older than 1 month)"""
    try:
        now = datetime.utcnow()
        query = recycle_bin_collection.where('expires_at', '<', now.isoformat())
        if username:
            query = query.where('deleted_by', '==', username)
        expired_items = query.get()
        files_collection = db.collection('files')
        deleted_count = 0
        for doc in expired_items:
            bin_data = doc.to_dict()
            original_data = bin_data.get('original_data', {})
            item_type = bin_data.get('item_type')
            if item_type == 'file':
                gcs_path = original_data.get('gcs_path')
                if gcs_path:
                    try:
                        blob = gcs_bucket.blob(gcs_path)
                        if blob.exists(): blob.delete()
                    except Exception as e: print(f"Error deleting expired file: {e}")
            else:
                dir_id = bin_data.get('item_id')
                deleted_by = bin_data.get('deleted_by')
                for file_doc in files_collection.where('uploaded_by','==',deleted_by).get():
                    file_data = file_doc.to_dict()
                    if file_data.get('directory_id') == dir_id:
                        gcs_path = file_data.get('gcs_path')
                        if gcs_path:
                            try:
                                blob = gcs_bucket.blob(gcs_path)
                                if blob.exists(): blob.delete()
                            except: pass
            doc.reference.delete()
            deleted_count += 1
        return deleted_count
    except Exception as e:
        print(f"Error cleaning up expired items: {e}")
        return 0


@recycle_bin_bp.route('', methods=['GET'])
@token_required
def get_recycle_bin(current_user):
    cleanup_expired_items(current_user['username'])
    recycle_query = recycle_bin_collection.where('deleted_by','==',current_user['username']).get()
    files, directories = [], []
    for doc in recycle_query:
        bin_data = doc.to_dict()
        try:
            expires_at = datetime.fromisoformat(bin_data.get('expires_at',''))
        except:
            expires_at = datetime.utcnow() + timedelta(days=30)
        item_data = {'id': doc.id, 'item_id': bin_data.get('item_id'),
            'item_type': bin_data.get('item_type'), 'item_name': bin_data.get('item_name',''),
            'deleted_at': bin_data.get('deleted_at',''), 'expires_at': bin_data.get('expires_at',''),
            'days_remaining': (expires_at - datetime.utcnow()).days}
        if bin_data.get('item_type') == 'file':
            item_data['size'] = bin_data.get('original_data', {}).get('size', 0)
            files.append(item_data)
        else:
            directories.append(item_data)
    files.sort(key=lambda x: x.get('deleted_at',''), reverse=True)
    directories.sort(key=lambda x: x.get('deleted_at',''), reverse=True)
    return jsonify({"files": files, "directories": directories}), 200


@recycle_bin_bp.route('/<bin_item_id>/restore', methods=['POST'])
@token_required
def restore_item(current_user, bin_item_id):
    bin_doc = recycle_bin_collection.document(bin_item_id).get()
    if not bin_doc.exists:
        return jsonify({"error": "Item not found in recycle bin"}), 404
    bin_data = bin_doc.to_dict()
    if bin_data.get('deleted_by') != current_user['username']:
        return jsonify({"error": "Unauthorized access"}), 403

    item_type = bin_data.get('item_type')
    original_data = bin_data.get('original_data', {})
    item_id = bin_data.get('item_id')
    files_collection = db.collection('files')

    if item_type == 'file':
        if files_collection.document(item_id).get().exists:
            return jsonify({"error": "File already exists"}), 400
        files_collection.document(item_id).set(original_data)
    else:
        if directories_collection.document(item_id).get().exists:
            return jsonify({"error": "Directory already exists"}), 400
        directories_collection.document(item_id).set(original_data)

        def restore_children(parent_dir_id):
            children = recycle_bin_collection.where('parent_directory_id','==',parent_dir_id).where('deleted_by','==',current_user['username']).get()
            for child_doc in children:
                cd = child_doc.to_dict()
                cid, ctype = cd.get('item_id'), cd.get('item_type')
                if ctype == 'file':
                    files_collection.document(cid).set(cd.get('original_data', {}))
                else:
                    directories_collection.document(cid).set(cd.get('original_data', {}))
                    restore_children(cid)
                child_doc.reference.delete()

        restore_children(item_id)

    bin_doc.reference.delete()
    return jsonify({"message": "Item restored successfully"}), 200


@recycle_bin_bp.route('/empty', methods=['POST'])
@token_required
def empty_recycle_bin(current_user):
    recycle_query = recycle_bin_collection.where('deleted_by','==',current_user['username']).get()
    files_collection = db.collection('files')
    deleted_count = 0
    for doc in recycle_query:
        bin_data = doc.to_dict()
        original_data = bin_data.get('original_data', {})
        item_type = bin_data.get('item_type')
        if item_type == 'file':
            gcs_path = original_data.get('gcs_path')
            if gcs_path:
                try:
                    blob = gcs_bucket.blob(gcs_path)
                    if blob.exists(): blob.delete()
                except: pass
        else:
            dir_id = bin_data.get('item_id')
            for file_doc in files_collection.where('uploaded_by','==',current_user['username']).get():
                file_data = file_doc.to_dict()
                if file_data.get('directory_id') == dir_id:
                    gcs_path = file_data.get('gcs_path')
                    if gcs_path:
                        try:
                            blob = gcs_bucket.blob(gcs_path)
                            if blob.exists(): blob.delete()
                        except: pass
        doc.reference.delete()
        deleted_count += 1
    return jsonify({"message": f"Recycle bin emptied. {deleted_count} item(s) permanently deleted."}), 200

קובץ: server/routes/admin.py

"""
Admin routes: system statistics, user management.
"""
from flask import Blueprint, jsonify

from config import db, users_collection, directories_collection, shares_collection, gcs_bucket
from utils.auth import admin_required

admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin')


@admin_bp.route('/stats', methods=['GET'])
@admin_required
def get_admin_stats(current_user):
    total_users = len(list(users_collection.get()))
    files_collection = db.collection('files')
    files_docs = files_collection.get()
    total_files, total_storage = 0, 0
    for doc in files_docs:
        file_data = doc.to_dict()
        if not file_data.get('is_deleted', False):
            total_files += 1
            total_storage += file_data.get('size', 0)
    total_directories = len(list(directories_collection.get()))
    total_shares = len(list(shares_collection.get()))
    return jsonify({"total_users": total_users, "total_files": total_files,
        "total_directories": total_directories, "total_storage": total_storage,
        "total_shares": total_shares}), 200


@admin_bp.route('/users', methods=['GET'])
@admin_required
def get_all_users(current_user):
    users_docs = users_collection.get()
    users = []
    for doc in users_docs:
        user_data = doc.to_dict()
        files_collection = db.collection('files')
        files_query = files_collection.where('uploaded_by','==',user_data.get('username')).get()
        file_count, total_size = 0, 0
        for file_doc in files_query:
            fi = file_doc.to_dict()
            if not fi.get('is_deleted', False):
                file_count += 1
                total_size += fi.get('size', 0)
        users.append({'id': doc.id, 'username': user_data.get('username',''),
            'firstName': user_data.get('firstName',''), 'lastName': user_data.get('lastName',''),
            'profilePicture': user_data.get('profilePicture'),
            'file_count': file_count, 'total_storage': total_size})
    users.sort(key=lambda x: x.get('username','').lower())
    return jsonify({"users": users}), 200


@admin_bp.route('/files', methods=['GET'])
@admin_required
def get_all_files(current_user):
    files_collection = db.collection('files')
    files = []
    for doc in files_collection.get():
        fi = doc.to_dict()
        if not fi.get('is_deleted', False):
            files.append({'id': doc.id, 'filename': fi.get('filename',''),
                'uploaded_by': fi.get('uploaded_by',''), 'uploaded_at': fi.get('uploaded_at',''),
                'size': fi.get('size',0), 'directory_id': fi.get('directory_id')})
    files.sort(key=lambda x: x.get('uploaded_at',''), reverse=True)
    return jsonify({"files": files}), 200


@admin_bp.route('/users/<user_id>', methods=['DELETE'])
@admin_required
def delete_user(current_user, user_id):
    from config import favorites_collection
    user_doc = users_collection.document(user_id).get()
    if not user_doc.exists:
        return jsonify({"error": "User not found"}), 404
    user_data = user_doc.to_dict()
    username = user_data.get('username')
    if username == 'admin':
        return jsonify({"error": "Cannot delete admin user"}), 403

    files_collection = db.collection('files')
    for file_doc in files_collection.where('uploaded_by','==',username).get():
        gcs_path = file_doc.to_dict().get('gcs_path')
        if gcs_path:
            blob = gcs_bucket.blob(gcs_path)
            if blob.exists(): blob.delete()
        files_collection.document(file_doc.id).delete()

    for dir_doc in directories_collection.where('created_by','==',username).get():
        directories_collection.document(dir_doc.id).delete()

    for share_doc in shares_collection.where('owner','==',username).get():
        shares_collection.document(share_doc.id).delete()
    for share_doc in shares_collection.where('shared_with','==',username).get():
        shares_collection.document(share_doc.id).delete()

    for fav_doc in favorites_collection.where('user','==',username).get():
        favorites_collection.document(fav_doc.id).delete()

    users_collection.document(user_id).delete()
    return jsonify({"message": f"User '{username}' and all their data deleted successfully"}), 200

קובץ: server/routes/items.py (Legacy)

"""
Items routes: basic CRUD for items collection.
"""
from flask import Blueprint, request, jsonify

from config import items_collection
from utils.auth import token_required

items_bp = Blueprint('items', __name__, url_prefix='/api/items')


@items_bp.route('', methods=['GET'])
@token_required
def get_items(current_user):
    items = [{'id': d.to_dict().get('id'), 'name': d.to_dict().get('name'),
              'description': d.to_dict().get('description','')} for d in items_collection.stream()]
    return jsonify({"items": items}), 200


@items_bp.route('/<int:item_id>', methods=['GET'])
@token_required
def get_item(current_user, item_id):
    item_query = items_collection.where('id','==',item_id).limit(1).get()
    item_docs = list(item_query)
    if item_docs:
        return jsonify(item_docs[0].to_dict()), 200
    return jsonify({"error": "Item not found"}), 404


@items_bp.route('', methods=['POST'])
@token_required
def create_item(current_user):
    data = request.get_json()
    if not data or not data.get("name"):
        return jsonify({"error": "Name is required"}), 400
    max_id = 0
    for doc in items_collection.stream():
        d = doc.to_dict()
        if 'id' in d and d['id'] > max_id: max_id = d['id']
    new_item = {"id": max_id+1, "name": data.get("name"), "description": data.get("description","")}
    items_collection.add(new_item)
    return jsonify(new_item), 201


@items_bp.route('/<int:item_id>', methods=['PUT'])
@token_required
def update_item(current_user, item_id):
    item_query = items_collection.where('id','==',item_id).limit(1).get()
    item_docs = list(item_query)
    if not item_docs:
        return jsonify({"error": "Item not found"}), 404
    data = request.get_json()
    update_data = {}
    if data.get("name"): update_data["name"] = data["name"]
    if data.get("description") is not None: update_data["description"] = data["description"]
    item_docs[0].reference.update(update_data)
    return jsonify(item_docs[0].reference.get().to_dict()), 200


@items_bp.route('/<int:item_id>', methods=['DELETE'])
@token_required
def delete_item(current_user, item_id):
    item_query = items_collection.where('id','==',item_id).limit(1).get()
    item_docs = list(item_query)
    if not item_docs:
        return jsonify({"error": "Item not found"}), 404
    item_docs[0].reference.delete()
    return jsonify({"message": "Item deleted successfully"}), 200

קובץ: server/routes/bulk.py (תקציר — 558 שורות בקובץ המקור)

בשל אורך הקובץ, להלן תקציר של חתימות כל הפונקציות. את הקוד המלא ניתן למצוא ברפו (commit b6e4cda).

"""
Bulk operations routes: delete, move, copy, download multiple items.
"""
from flask import Blueprint, request, jsonify, send_file
from werkzeug.utils import secure_filename
from datetime import datetime, timedelta
import zipfile, io

from config import db, directories_collection, recycle_bin_collection, gcs_bucket
from utils.auth import token_required

bulk_bp = Blueprint('bulk', __name__, url_prefix='/api/bulk')


@bulk_bp.route('/delete', methods=['POST'])
@token_required
def bulk_delete(current_user):
    """Move multiple files/directories to recycle bin atomically.
    For each item: verify ownership, recursively collect contents (for directories),
    add all items to recycle_bin with 30-day expiry, delete from main collections."""
    # See full implementation in source repo
    pass


@bulk_bp.route('/move', methods=['POST'])
@token_required
def bulk_move(current_user):
    """Move items to destination_id (None=root).
    Checks: ownership, duplicate name at destination,
    `is_subdirectory(parent_id, check_id)` recursive guard against loops."""
    pass


@bulk_bp.route('/copy', methods=['POST'])
@token_required
def bulk_copy(current_user):
    """Recursively copy files and directories.
    Uses gcs_bucket.copy_blob() for GCS object copy,
    'Copy of ...' / 'Copy (N) of ...' naming with collision avoidance."""
    pass


@bulk_bp.route('/download', methods=['POST'])
@token_required
def bulk_download(current_user):
    """Build a single ZIP file in memory containing selected files
    and all files within selected directories (recursively)."""
    pass

# Internal helper functions (defined inside route bodies):
#   collect_directory_contents(dir_id, parent_path='')
#   is_subdirectory(parent_id, check_id)
#   copy_file(file_id, dest_dir_id)
#   copy_directory(dir_id, dest_parent_id, name_prefix='')
#   collect_directory_files(dir_id, base_path='')

קובץ: client/theme.js

// Theme Management System
(function() {
    'use strict';

    const THEME_KEY = 'cloudStorageTheme';
    const THEMES = { light: 'light', dark: 'dark' };

    function initTheme() {
        const savedTheme = localStorage.getItem(THEME_KEY) || THEMES.dark;
        setTheme(savedTheme);
    }

    function setTheme(theme) {
        if (!Object.values(THEMES).includes(theme)) theme = THEMES.dark;
        document.documentElement.setAttribute('data-theme', theme);
        localStorage.setItem(THEME_KEY, theme);
        updateThemeToggle(theme);
    }

    function toggleTheme() {
        const currentTheme = document.documentElement.getAttribute('data-theme') || THEMES.dark;
        const newTheme = currentTheme === THEMES.dark ? THEMES.light : THEMES.dark;
        setTheme(newTheme);
    }

    function updateThemeToggle(theme) {
        const toggleBtn = document.getElementById('themeToggle');
        if (!toggleBtn) return;
        const icon = toggleBtn.querySelector('svg');
        if (!icon) return;
        // SVG path differs by theme (sun icon for dark, moon icon for light) ...
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initTheme);
    } else {
        initTheme();
    }

    window.toggleTheme = toggleTheme;
    window.setTheme = setTheme;
    window.getCurrentTheme = function() {
        return document.documentElement.getAttribute('data-theme') || THEMES.dark;
    };
})();

קובץ: client/index/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Cloud Storage</title>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <div class="container">
        <header>
            <h1>Cloud Storage</h1>
            <p>Welcome to your cloud storage service</p>
        </header>
        <div class="redirect-message">
            <p>Redirecting to login...</p>
        </div>
    </div>
    <script src="index.js"></script>
</body>
</html>

קובץ: client/index/index.js

// Redirect to login page
window.addEventListener('DOMContentLoaded', () => {
    window.location.href = '/login.html';
});

קובץ: client/login/login.js

// API Configuration
const API_BASE_URL = 'http://localhost:5000/api';

// Check if user is already logged in
window.addEventListener('DOMContentLoaded', () => {
    const currentPath = window.location.pathname;
    if (!currentPath.includes('login.html') && !currentPath.endsWith('/login.html')) return;

    const urlParams = new URLSearchParams(window.location.search);
    const fromHome = urlParams.get('from') === 'home';
    const cameFromHome = sessionStorage.getItem('cameFromHome');

    if (fromHome || cameFromHome) {
        sessionStorage.removeItem('cameFromHome');
        localStorage.removeItem('authToken');
        localStorage.removeItem('username');
        if (fromHome) {
            window.history.replaceState({}, document.title, window.location.pathname);
        }
        setupFormValidation();
        return;
    }

    const token = localStorage.getItem('authToken');
    if (token) verifyToken(token);
    setupFormValidation();
});

async function verifyToken(token) {
    try {
        const response = await fetch(`${API_BASE_URL}/auth/verify`, {
            method: 'GET',
            headers: { 'Authorization': `Bearer ${token}` }
        });
        if (response.ok) {
            window.location.replace('/home.html');
        } else {
            localStorage.removeItem('authToken');
            localStorage.removeItem('username');
        }
    } catch (error) {
        localStorage.removeItem('authToken');
        localStorage.removeItem('username');
    }
}

function setupFormValidation() {
    const form = document.getElementById('loginForm');
    if (!form) return;
    const usernameInput = document.getElementById('username');
    const passwordInput = document.getElementById('password');
    usernameInput.addEventListener('blur', () => validateField(usernameInput, document.getElementById('username-error'), 'Username is required'));
    passwordInput.addEventListener('blur', () => validateField(passwordInput, document.getElementById('password-error'), 'Password is required'));
    usernameInput.addEventListener('input', () => clearFieldError(usernameInput, document.getElementById('username-error')));
    passwordInput.addEventListener('input', () => clearFieldError(passwordInput, document.getElementById('password-error')));
}

function validateField(input, errorElement, errorMessage) {
    const value = input.value.trim();
    if (!value) { showFieldError(input, errorElement, errorMessage); return false; }
    clearFieldError(input, errorElement); return true;
}

function showFieldError(input, errorElement, message) {
    input.classList.add('error');
    input.setAttribute('aria-invalid', 'true');
    if (errorElement) { errorElement.textContent = message; errorElement.classList.add('show'); }
}

function clearFieldError(input, errorElement) {
    input.classList.remove('error');
    input.setAttribute('aria-invalid', 'false');
    if (errorElement) { errorElement.textContent = ''; errorElement.classList.remove('show'); }
}

document.getElementById('loginForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    const usernameInput = document.getElementById('username');
    const passwordInput = document.getElementById('password');
    const username = usernameInput.value.trim();
    const password = passwordInput.value;
    const loginBtn = document.getElementById('loginBtn');
    const errorMessage = document.getElementById('errorMessage');

    if (!username || !password) {
        if (!username) showFieldError(usernameInput, document.getElementById('username-error'), 'Username is required');
        if (!password) showFieldError(passwordInput, document.getElementById('password-error'), 'Password is required');
        return;
    }

    loginBtn.disabled = true; loginBtn.textContent = 'Logging in...';
    try {
        const response = await fetch(`${API_BASE_URL}/auth/login`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ username, password })
        });
        const data = await response.json();
        if (response.ok) {
            localStorage.setItem('authToken', data.token);
            localStorage.setItem('username', data.username);
            window.location.replace('/home.html');
        } else {
            errorMessage.textContent = data.error || 'Login failed';
            errorMessage.classList.add('show');
            loginBtn.disabled = false; loginBtn.textContent = 'Log in';
        }
    } catch (error) {
        errorMessage.textContent = 'Network error. Please check if the server is running';
        errorMessage.classList.add('show');
        loginBtn.disabled = false; loginBtn.textContent = 'Log in';
    }
});

קובץ: client/signup/signup.js

// API Configuration
const API_BASE_URL = 'http://localhost:5000/api';

// Form validation
function setupFormValidation() {
    const firstNameInput = document.getElementById('firstName');
    const lastNameInput = document.getElementById('lastName');
    const usernameInput = document.getElementById('username');
    const passwordInput = document.getElementById('password');
    const confirmPasswordInput = document.getElementById('confirmPassword');

    firstNameInput.addEventListener('blur', () => validateName(firstNameInput, document.getElementById('firstName-error'), 'First name'));
    lastNameInput.addEventListener('blur', () => validateName(lastNameInput, document.getElementById('lastName-error'), 'Last name'));
    usernameInput.addEventListener('blur', () => validateUsername(usernameInput, document.getElementById('username-error')));
    passwordInput.addEventListener('blur', () => validatePassword(passwordInput, document.getElementById('password-error')));
    confirmPasswordInput.addEventListener('blur', () => validatePasswordMatch(passwordInput, confirmPasswordInput, document.getElementById('confirmPassword-error')));
}

function validateName(input, errorElement, fieldName) {
    const value = input.value.trim();
    if (!value) { showFieldError(input, errorElement, `${fieldName} is required`); return false; }
    if (value.length < 2) { showFieldError(input, errorElement, `${fieldName} must be at least 2 characters`); return false; }
    if (value.length > 50) { showFieldError(input, errorElement, `${fieldName} must be less than 50 characters`); return false; }
    if (!/^[a-zA-Z\s'-]+$/.test(value)) { showFieldError(input, errorElement, `${fieldName} invalid characters`); return false; }
    clearFieldError(input, errorElement); return true;
}

function validateUsername(input, errorElement) {
    const value = input.value.trim();
    if (!value || value.length < 3 || value.length > 20 || !/^[a-zA-Z0-9_]+$/.test(value)) {
        showFieldError(input, errorElement, 'Username 3-20 chars, alphanumeric + _'); return false;
    }
    clearFieldError(input, errorElement); return true;
}

function validatePassword(input, errorElement) {
    if (input.value.length < 6) { showFieldError(input, errorElement, 'Password must be at least 6 characters'); return false; }
    clearFieldError(input, errorElement); return true;
}

function validatePasswordMatch(passwordInput, confirmPasswordInput, errorElement) {
    if (passwordInput.value !== confirmPasswordInput.value) {
        showFieldError(confirmPasswordInput, errorElement, 'Passwords do not match'); return false;
    }
    clearFieldError(confirmPasswordInput, errorElement); return true;
}

document.getElementById('signupForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    const firstName = document.getElementById('firstName').value.trim();
    const lastName = document.getElementById('lastName').value.trim();
    const username = document.getElementById('username').value.trim();
    const password = document.getElementById('password').value;

    // [validate all fields]

    try {
        const response = await fetch(`${API_BASE_URL}/auth/signup`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ firstName, lastName, username, password })
        });
        const data = await response.json();
        if (response.ok) {
            localStorage.setItem('authToken', data.token);
            localStorage.setItem('username', data.username);
            window.location.replace('/home.html');
        }
    } catch (error) {
        // handle network error
    }
});

קובץ: client/home/home.js (תקציר — ~1500 שורות)

בשל אורך הקובץ (קובץ הליבה של דף הבית), להלן תקציר מבני. כל הפונקציות מתועדות. המבנה לפי תחום:

// API Configuration
const API_BASE_URL = 'http://localhost:5000/api';

// === STATE ===
let selectedFile = null;
let currentDirectoryId = null;
let directoryPath = [];
let currentTab = 'myFiles';
let shareItemData = null;
let allFiles = [], allDirectories = [];
let allSharedFiles = [], allSharedDirectories = [];
let allFavoriteFiles = [], allFavoriteDirectories = [];
let selectedItems = [];
let singleItemToMove = null, singleItemToCopy = null;
let sortState = {
    myFiles:      { column: null, direction: 'asc' },
    sharedWithMe: { column: null, direction: 'asc' },
    favorites:    { column: null, direction: 'asc' },
    recycleBin:   { column: null, direction: 'asc' }
};

// === AUTH / NAVIGATION ===
function redirectToLogin() { /* clear tokens + sessionStorage flag + replace('/login.html?from=home') */ }
async function verifyToken(token) { /* GET /api/auth/verify, redirect on 401 */ }
function getAuthToken() { return localStorage.getItem('authToken'); }

// === UPLOAD ===
function setupUploadModal() { /* open/close handlers */ }
function setupDragAndDrop() { /* dragenter/dragover/dragleave/drop on uploadZone */ }
function setupFileInput() { /* browse button + file input change */ }
function handleFileSelect(file) { /* set selectedFile, show fileInfo */ }
async function uploadFile(file) { /* FormData + POST /api/upload */ }
function showUploadStatus(isUploading, isSuccess) { /* spinner / success icon */ }
function showUploadError(message) { /* error banner inside modal */ }

// === DIRECTORIES & BREADCRUMBS ===
function setupDirectoryCreation() { /* modal + POST /api/directories */ }
function enterDirectory(dirId, dirName) { /* push to directoryPath, reload */ }
function renderBreadcrumbs() { /* dynamic chain from directoryPath */ }

// === FILE LIST ===
async function loadUserFiles() { /* GET /api/files?directory_id=... */ }
function renderFilesTable(files, directories) { /* table rows with checkboxes */ }
function formatFileSize(bytes) { /* B / KB / MB / GB */ }
function formatDate(iso) { /* localized date */ }

// === SHARING ===
function setupShareModal() { /* events */ }
async function openShareDialog(item) { /* show modal, load existing shares */ }
async function searchUsersForShare(query) { /* GET /api/users/search?q= */ }
async function shareWithUser(targetUsername) { /* POST /api/share */ }
async function unshareWithUser(itemId, username) { /* DELETE /api/share/{id}?username= */ }
async function loadCurrentSharedUsers(itemId) { /* GET /api/share/{id}/users */ }

// === FAVORITES ===
async function toggleFavorite(itemId, itemType, isCurrentlyFavorited) { /* POST/DELETE /api/favorites */ }
async function loadFavorites() { /* GET /api/favorites */ }

// === RECYCLE BIN ===
function setupRecycleBin() { /* events */ }
async function loadRecycleBin() { /* GET /api/recycle-bin */ }
async function restoreFromBin(binId) { /* POST /api/recycle-bin/{id}/restore */ }
async function emptyRecycleBin() { /* confirm + POST /api/recycle-bin/empty */ }

// === SEARCH ===
function setupSearch() { /* input event for each tab's search box */ }
async function performSearch(query, tab) { /* GET /api/files/search?q= */ }

// === SORTING ===
function setupTableSorting() { /* click handlers on TH.sortable */ }
function sortTable(tab, column) { /* sort allFiles / allDirectories arrays */ }

// === BULK OPERATIONS ===
function setupBulkOperations() { /* checkboxes, toolbar */ }
function toggleItemSelection(item) { /* push/pop from selectedItems */ }
function updateBulkToolbar() { /* show/hide toolbar based on selectedItems.length */ }
async function bulkDownload() { /* POST /api/bulk/download → blob → trigger ZIP download */ }
async function bulkMove(destinationId) { /* POST /api/bulk/move */ }
async function bulkCopy(destinationId) { /* POST /api/bulk/copy */ }
async function bulkDelete() { /* POST /api/bulk/delete */ }

// === TABS ===
function setupTabs() { /* switch between myFiles / sharedWithMe / favorites / recycleBin */ }
function switchTab(tabName) { /* show one section, hide others, load data */ }

// === DELETE MODAL ===
function setupDeleteModal() { /* confirm dialog before DELETE */ }

// === ENTRY POINT ===
window.addEventListener('DOMContentLoaded', () => {
    if (!getAuthToken()) { redirectToLogin(); return; }
    const username = localStorage.getItem('username');
    document.getElementById('usernameDisplay').textContent = username;
    if (username === 'admin') document.getElementById('adminBtn').style.display = 'inline-flex';
    verifyToken(getAuthToken());
    setupUploadModal(); setupDragAndDrop(); setupFileInput(); setupSubmitButton();
    setupDirectoryCreation(); setupDeleteModal(); setupShareModal();
    setupTabs(); setupSearch(); setupRecycleBin();
    setupTableSorting(); setupBulkOperations();
    loadUserFiles();
});

קובץ: client/profile/profile.js

const API_BASE_URL = 'http://localhost:5000/api';
let originalData = {}, profilePictureUrl = null;

function redirectToLogin() {
    localStorage.removeItem('authToken');
    localStorage.removeItem('username');
    sessionStorage.setItem('cameFromHome', 'true');
    window.location.replace('/login.html?from=home');
}

window.addEventListener('DOMContentLoaded', () => {
    const token = localStorage.getItem('authToken');
    if (!token) { redirectToLogin(); return; }
    loadProfile();
    setupFormHandlers();
    setupPictureUpload();
});

async function loadProfile() {
    const token = localStorage.getItem('authToken');
    const response = await fetch(`${API_BASE_URL}/user/profile`, {
        method: 'GET',
        headers: { 'Authorization': `Bearer ${token}` }
    });
    if (response.status === 401) { redirectToLogin(); return; }
    const data = await response.json();
    originalData = { firstName: data.firstName || '', lastName: data.lastName || '', username: data.username || '' };
    document.getElementById('firstName').value = originalData.firstName;
    document.getElementById('lastName').value = originalData.lastName;
    document.getElementById('username').value = originalData.username;
    profilePictureUrl = data.profilePicture;
    if (profilePictureUrl) {
        await loadProfilePicture(profilePictureUrl);
        document.getElementById('removePictureBtn').style.display = 'block';
    } else { showPlaceholder(); }
}

async function loadProfilePicture(url) {
    const token = localStorage.getItem('authToken');
    const response = await fetch(`http://localhost:5000${url}`, {
        headers: { 'Authorization': `Bearer ${token}` }
    });
    if (response.ok) {
        const blob = await response.blob();
        const imageUrl = URL.createObjectURL(blob);
        const img = document.getElementById('profilePicture');
        img.src = imageUrl; img.classList.add('show');
    }
}

function showPlaceholder() { /* initials placeholder DOM element */ }
function setupFormHandlers() { /* form events + logout */ }

async function saveProfile() {
    const firstName = document.getElementById('firstName').value.trim();
    const lastName  = document.getElementById('lastName').value.trim();
    const response = await fetch(`${API_BASE_URL}/user/profile`, {
        method: 'PUT',
        headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ firstName, lastName })
    });
    // [handle response, update originalData, show success]
}

async function uploadProfilePicture(file) {
    const formData = new FormData();
    formData.append('file', file);
    const response = await fetch(`${API_BASE_URL}/user/profile/picture`, {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}` },
        body: formData
    });
    // [handle response, reload picture]
}

async function removeProfilePicture() {
    if (!confirm('Are you sure?')) return;
    await fetch(`${API_BASE_URL}/user/profile`, {
        method: 'PUT',
        headers: { 'Authorization': `Bearer ${localStorage.getItem('authToken')}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ profilePicture: null })
    });
    showPlaceholder();
}

קובץ: client/admin/admin.js

// Admin Dashboard JavaScript
const API_BASE = 'http://localhost:5000/api';

document.addEventListener('DOMContentLoaded', function() { checkAdminAccess(); });

function checkAdminAccess() {
    const token = localStorage.getItem('authToken');
    const username = localStorage.getItem('username');
    if (!token) { redirectToLogin(); return; }
    if (username !== 'admin') {
        alert('Access denied. Admin privileges required.');
        window.location.href = '/home.html'; return;
    }
    initDashboard();
}

function initDashboard() {
    loadStats(); loadUsers();
    setupTabs(); setupLogout(); setupSearch(); setupDeleteModal();
}

async function loadStats() {
    const response = await fetch(`${API_BASE}/admin/stats`, {
        headers: { 'Authorization': `Bearer ${getAuthToken()}` }
    });
    if (response.status === 403) {
        window.location.href = '/home.html'; return;
    }
    const data = await response.json();
    animateValue('totalUsers', data.total_users);
    animateValue('totalFiles', data.total_files);
    animateValue('totalStorage', formatSize(data.total_storage), true);
    animateValue('totalShares', data.total_shares);
}

function animateValue(elementId, value, isString = false) {
    const element = document.getElementById(elementId);
    if (isString) { element.textContent = value; return; }
    const duration = 1000, start = 0, end = parseInt(value);
    const startTime = performance.now();
    function update(currentTime) {
        const elapsed = currentTime - startTime;
        const progress = Math.min(elapsed / duration, 1);
        const easeOut = 1 - Math.pow(1 - progress, 3);
        const current = Math.floor(start + (end - start) * easeOut);
        element.textContent = current.toLocaleString();
        if (progress < 1) requestAnimationFrame(update);
    }
    requestAnimationFrame(update);
}

function formatSize(bytes) {
    if (bytes === 0) return '0 B';
    const k = 1024, sizes = ['B','KB','MB','GB','TB'];
    const i = Math.floor(Math.log(bytes) / Math.log(k));
    return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}

let allUsers = [], allFiles = [];

async function loadUsers() {
    const response = await fetch(`${API_BASE}/admin/users`, {
        headers: { 'Authorization': `Bearer ${getAuthToken()}` }
    });
    const data = await response.json();
    allUsers = data.users;
    renderUsers(allUsers);
}

function renderUsers(users) {
    const tbody = document.getElementById('usersTableBody');
    if (users.length === 0) { tbody.innerHTML = '<tr><td colspan="5">No users found</td></tr>'; return; }
    // ... generates user rows with avatars, file_count, storage, delete button
}

async function loadFiles() {
    const response = await fetch(`${API_BASE}/admin/files`, {
        headers: { 'Authorization': `Bearer ${getAuthToken()}` }
    });
    const data = await response.json();
    allFiles = data.files;
    renderFiles(allFiles);
}

function setupTabs() { /* users vs files */ }
function setupLogout() { /* clear + redirect */ }
function setupSearch() { /* live filter in JS */ }

let userToDelete = null;
function confirmDeleteUser(userId, username) {
    userToDelete = { id: userId, username };
    document.getElementById('deleteUserMessage').textContent = `Are you sure you want to delete user "${username}"?`;
    document.getElementById('deleteUserModal').style.display = 'block';
}

function setupDeleteModal() {
    document.getElementById('confirmDeleteUserBtn').addEventListener('click', async function() {
        if (!userToDelete) return;
        const response = await fetch(`${API_BASE}/admin/users/${userToDelete.id}`, {
            method: 'DELETE',
            headers: { 'Authorization': `Bearer ${getAuthToken()}` }
        });
        if (response.ok) { closeDeleteModal(); loadStats(); loadUsers(); allFiles = []; }
    });
}

קבצים שנוספו לאחר הגרסה הראשונית (הצפנה, Push, מודלים, לקוח שולחני)

הקבצים הבאים נוספו / הורחבו בסבב הפיתוח השני: שכבת הצפנת המעטפה, שרת ה-push על socket גולמי, בוטסטרפ HTTPS, שכבת המודלים (OOP) שמרכזת את ההצפנה, והלקוח השולחני. שני קבצי ה-UI הגדולים של הלקוח השולחני (app.py, components.py) מובאים כתקציר מבני, בעקבות אותה מוסכמה שננקטה ל-home.js ו-bulk.py.

קובץ: server/utils/crypto.py

"""
Envelope encryption for file bytes stored in Google Cloud Storage, plus the
asymmetric (RSA) key material that backs cryptographic file sharing.

GCS already encrypts at rest with Google-managed keys (AES-256). This layer
adds a second envelope under keys the application owns, so an attacker who
exfiltrates the GCS bucket (or the Firestore database) without also
compromising the API host sees only ciphertext.

There are three kinds of key here:
  * Master KEK   -- one symmetric Fernet key the app owns (FILE_MASTER_KEY env
                    var, or server/.file_master_key). It wraps per-file DEKs
                    and each user's RSA private key; never file bytes directly.
  * Per-file DEK -- a fresh symmetric Fernet key per uploaded file. File bytes
                    are encrypted under the DEK; the DEK is wrapped under the
                    master KEK and stored on the Firestore doc.
  * RSA keypair  -- one per user. The public key wraps a file's DEK when shared
                    with that user; the private key (wrapped under the KEK)
                    unwraps it on download.

Threat model boundary: this does NOT protect against compromise of the API
host -- the master KEK lives there. It defends against theft of the storage
bucket or the database alone.

Symmetric algorithm: Fernet (AES-128-CBC + HMAC-SHA256, authenticated).
Asymmetric algorithm: RSA-2048 with OAEP(SHA-256) padding.
"""
import os
import base64

from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa


def _load_or_create_master_key():
    """Use FILE_MASTER_KEY env var if set; otherwise persist a random Fernet
    key to server/.file_master_key (gitignored). This is the master KEK."""
    env_value = os.environ.get('FILE_MASTER_KEY')
    if env_value:
        return env_value.encode('utf-8')
    key_file = os.path.join(os.path.dirname(__file__), '..', '.file_master_key')
    key_file = os.path.abspath(key_file)
    if os.path.exists(key_file):
        with open(key_file, 'rb') as fh:
            value = fh.read().strip()
            if value:
                return value
    value = Fernet.generate_key()  # 32 random bytes, url-safe base64
    with open(key_file, 'wb') as fh:
        fh.write(value)
    return value


_fernet = Fernet(_load_or_create_master_key())


# --- Global-key helpers (legacy file blobs + profile pictures) ---
def encrypt_bytes(plaintext: bytes) -> bytes:
    """Encrypt arbitrary bytes directly under the master key (profile pics)."""
    return _fernet.encrypt(plaintext)


def decrypt_bytes(ciphertext: bytes) -> bytes:
    """Decrypt bytes previously produced by encrypt_bytes."""
    return _fernet.decrypt(ciphertext)


# --- Per-file envelope encryption (symmetric) ---
def generate_dek() -> bytes:
    """Generate a fresh per-file Data Encryption Key (a Fernet key)."""
    return Fernet.generate_key()


def encrypt_with_dek(plaintext: bytes, dek: bytes) -> bytes:
    return Fernet(dek).encrypt(plaintext)


def decrypt_with_dek(ciphertext: bytes, dek: bytes) -> bytes:
    return Fernet(dek).decrypt(ciphertext)


def wrap_dek_with_master(dek: bytes) -> str:
    """Wrap (encrypt) a DEK under the master KEK. Returns a str for Firestore."""
    return _fernet.encrypt(dek).decode('utf-8')


def unwrap_dek_with_master(wrapped_dek: str) -> bytes:
    return _fernet.decrypt(wrapped_dek.encode('utf-8'))


def decrypt_stored_file(ciphertext: bytes, wrapped_dek=None) -> bytes:
    """Unified decryptor for file blobs fetched from GCS. If wrapped_dek is
    present, unwrap it with the master KEK and decrypt; otherwise try the
    global-key path, and finally fall back to returning the bytes as-is for
    blobs uploaded before any encryption was added."""
    if wrapped_dek:
        dek = unwrap_dek_with_master(wrapped_dek)
        return decrypt_with_dek(ciphertext, dek)
    try:
        return decrypt_bytes(ciphertext)
    except InvalidToken:
        return ciphertext  # pre-encryption upload: raw bytes


# --- Per-user RSA keypairs (asymmetric): power cryptographic file sharing ---
def generate_rsa_keypair():
    """Generate an RSA-2048 keypair. Returns (private_pem, public_pem) bytes."""
    private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption(),
    )
    public_pem = private_key.public_key().public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    return private_pem, public_pem


def wrap_private_key(private_pem: bytes) -> str:
    """Wrap a private-key PEM under the master KEK for storage. Returns a str."""
    return _fernet.encrypt(private_pem).decode('utf-8')


def unwrap_private_key(wrapped_private_key: str) -> bytes:
    return _fernet.decrypt(wrapped_private_key.encode('utf-8'))


def rsa_wrap_dek(dek: bytes, public_pem) -> str:
    """RSA-OAEP-encrypt a DEK under a recipient's public key. Returns base64 str."""
    if isinstance(public_pem, str):
        public_pem = public_pem.encode('utf-8')
    public_key = serialization.load_pem_public_key(public_pem)
    ciphertext = public_key.encrypt(
        dek,
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None,
        ),
    )
    return base64.b64encode(ciphertext).decode('utf-8')


def rsa_unwrap_dek(wrapped_b64: str, private_pem) -> bytes:
    """Reverse rsa_wrap_dek using the recipient's private-key PEM. Returns DEK bytes."""
    if isinstance(private_pem, str):
        private_pem = private_pem.encode('utf-8')
    private_key = serialization.load_pem_private_key(private_pem, password=None)
    return private_key.decrypt(
        base64.b64decode(wrapped_b64),
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None,
        ),
    )

קובץ: server/socket_server.py

"""
Raw TCP socket server for real-time push notifications.

Separate from Flask: it listens on its own port (default 5001) and speaks a tiny
hand-rolled application protocol -- newline-delimited JSON, one object per line --
over a plain TCP socket. It lets the server PUSH events to a connected desktop
client without polling, which HTTP/REST cannot do cleanly.

Protocol (each message is one UTF-8 JSON object terminated by '\\n'):
    Client -> Server : {"type": "auth", "token": "<jwt>"}   (must be first)
    Client -> Server : {"type": "ping"}
    Server -> Client : {"type": "auth_ok", "username": "bob"}
    Server -> Client : {"type": "auth_error", "error": "..."}  then close
    Server -> Client : {"type": "pong"}
    Server -> Client : {"type": "shared", ...}
    Server -> Client : {"type": "file_changed", ...}

The hub runs inside the same process as Flask; the module-level
`notification_hub` singleton is shared between socket threads and route handlers.
"""
import json
import socket
import threading

from utils.auth import resolve_user_from_token


class NotificationHub:
    """Owns the listening socket and the registry of authenticated connections,
    and fans events out to them."""

    def __init__(self):
        # username -> set of connected client sockets
        self._connections = {}
        self._lock = threading.Lock()
        self._server_socket = None

    def register(self, username, conn):
        with self._lock:
            self._connections.setdefault(username, set()).add(conn)

    def unregister(self, username, conn):
        with self._lock:
            conns = self._connections.get(username)
            if conns:
                conns.discard(conn)
                if not conns:
                    del self._connections[username]

    def push_to_user(self, username, event):
        """Send one JSON event to every live socket for `username` (best-effort)."""
        line = (json.dumps(event) + "\n").encode("utf-8")
        with self._lock:
            conns = list(self._connections.get(username, ()))
        dead = []
        for conn in conns:
            try:
                conn.sendall(line)
            except OSError:
                dead.append(conn)
        for conn in dead:
            self.unregister(username, conn)
            try:
                conn.close()
            except OSError:
                pass

    def start(self, host="127.0.0.1", port=5001):
        """Bind, listen, and spawn the accept loop on a daemon thread."""
        srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        srv.bind((host, port))
        srv.listen(16)
        self._server_socket = srv
        threading.Thread(target=self._accept_loop, name="socket-accept-loop",
                         daemon=True).start()

    def _accept_loop(self):
        while True:
            try:
                conn, addr = self._server_socket.accept()
            except OSError:
                break
            threading.Thread(target=self._handle_client, args=(conn, addr),
                             name=f"socket-client-{addr}", daemon=True).start()

    def _handle_client(self, conn, addr):
        username = None
        reader = _LineReader(conn)
        try:
            first = reader.readline()
            msg = _parse(first)
            token = msg.get("token") if msg and msg.get("type") == "auth" else None
            user = resolve_user_from_token(token) if token else None
            if user is None:
                _send(conn, {"type": "auth_error", "error": "Invalid token"})
                return
            username = user["username"]
            self.register(username, conn)
            _send(conn, {"type": "auth_ok", "username": username})
            print(f"[socket] {username} connected from {addr}")
            while True:
                line = reader.readline()
                if not line:
                    break
                msg = _parse(line)
                if msg and msg.get("type") == "ping":
                    _send(conn, {"type": "pong"})
        except OSError:
            pass
        finally:
            if username:
                self.unregister(username, conn)
            try:
                conn.close()
            except OSError:
                pass


class _LineReader:
    """Buffers bytes off a socket and yields one '\\n'-terminated line at a time."""

    def __init__(self, conn):
        self._conn = conn
        self._buf = b""

    def readline(self):
        while b"\n" not in self._buf:
            chunk = self._conn.recv(4096)
            if not chunk:
                line, self._buf = self._buf, b""
                return line.decode("utf-8", "replace") if line else ""
            self._buf += chunk
        line, _, self._buf = self._buf.partition(b"\n")
        return line.decode("utf-8", "replace")


def _parse(line):
    if not line:
        return None
    try:
        obj = json.loads(line)
        return obj if isinstance(obj, dict) else None
    except json.JSONDecodeError:
        return None


def _send(conn, event):
    conn.sendall((json.dumps(event) + "\n").encode("utf-8"))


# Module-level singleton shared by socket threads and Flask route handlers.
notification_hub = NotificationHub()

קובץ: server/generate_dev_cert.py

"""
Mint a TLS certificate for local development.

Preferred path: use `mkcert` so the browser trusts the cert. If mkcert is
missing, try to install it via winget on Windows. If that also fails, fall back
to a self-signed cert (browsers will warn). Idempotent: exits silently if both
server/certs/dev-cert.pem and dev-key.pem already exist. LOCAL DEV ONLY.
"""
import os
import datetime
import ipaddress
import shutil
import subprocess
import sys


CERTS_DIR = os.path.join(os.path.dirname(__file__), 'certs')
CERT_PATH = os.path.join(CERTS_DIR, 'dev-cert.pem')
KEY_PATH = os.path.join(CERTS_DIR, 'dev-key.pem')


def _which_mkcert():
    return shutil.which('mkcert')


def _try_install_mkcert():
    """Best-effort install of mkcert via winget on Windows. Returns path or None."""
    if sys.platform != 'win32':
        return None
    if not shutil.which('winget'):
        return None
    print('mkcert not found. Attempting `winget install FiloSottile.mkcert`...')
    try:
        subprocess.run(
            ['winget', 'install', '--id', 'FiloSottile.mkcert',
             '--silent', '--accept-source-agreements', '--accept-package-agreements'],
            check=True,
        )
    except subprocess.CalledProcessError as e:
        print(f'winget install failed (exit {e.returncode}). Skipping mkcert.')
        return None
    candidates = [
        shutil.which('mkcert'),
        os.path.expandvars(r'%LOCALAPPDATA%\Microsoft\WinGet\Links\mkcert.exe'),
    ]
    for path in candidates:
        if path and os.path.exists(path):
            return path
    return None


def _generate_with_mkcert(mkcert_path):
    """Use mkcert. Returns True on success, False to trigger fallback."""
    os.makedirs(CERTS_DIR, exist_ok=True)
    try:
        subprocess.run([mkcert_path, '-install'], check=True)
    except subprocess.CalledProcessError as e:
        print(f'mkcert -install failed (exit {e.returncode}). Falling back to self-signed.')
        return False
    try:
        subprocess.run(
            [mkcert_path, '-cert-file', CERT_PATH, '-key-file', KEY_PATH,
             'localhost', '127.0.0.1', '::1'],
            check=True,
        )
    except subprocess.CalledProcessError as e:
        print(f'mkcert cert generation failed (exit {e.returncode}). Falling back to self-signed.')
        return False
    return True


def _generate_self_signed():
    """Fallback: self-signed cert. Browsers will warn."""
    from cryptography import x509
    from cryptography.x509.oid import NameOID
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa

    os.makedirs(CERTS_DIR, exist_ok=True)
    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
    subject = issuer = x509.Name([
        x509.NameAttribute(NameOID.COUNTRY_NAME, 'IL'),
        x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'CloudStorage Local Dev'),
        x509.NameAttribute(NameOID.COMMON_NAME, 'localhost'),
    ])
    now = datetime.datetime.now(datetime.timezone.utc)
    cert = (
        x509.CertificateBuilder()
        .subject_name(subject)
        .issuer_name(issuer)
        .public_key(key.public_key())
        .serial_number(x509.random_serial_number())
        .not_valid_before(now)
        .not_valid_after(now + datetime.timedelta(days=365))
        .add_extension(
            x509.SubjectAlternativeName([
                x509.DNSName('localhost'),
                x509.IPAddress(ipaddress.IPv4Address('127.0.0.1')),
                x509.IPAddress(ipaddress.IPv6Address('::1')),
            ]),
            critical=False,
        )
        .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
        .sign(private_key=key, algorithm=hashes.SHA256())
    )
    with open(CERT_PATH, 'wb') as fh:
        fh.write(cert.public_bytes(serialization.Encoding.PEM))
    with open(KEY_PATH, 'wb') as fh:
        fh.write(key.private_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PrivateFormat.TraditionalOpenSSL,
            encryption_algorithm=serialization.NoEncryption(),
        ))


def generate():
    if os.path.exists(CERT_PATH) and os.path.exists(KEY_PATH):
        return False, 'existing'
    mkcert = _which_mkcert() or _try_install_mkcert()
    if mkcert and _generate_with_mkcert(mkcert):
        return True, 'mkcert'
    print('Using self-signed cert. Browsers will show a "Not secure" warning.')
    _generate_self_signed()
    return True, 'self-signed'


if __name__ == '__main__':
    created, mode = generate()
    if not created:
        print('Dev cert already present, skipping.')
    elif mode == 'mkcert':
        print(f'Generated browser-trusted cert via mkcert: {CERT_PATH}')
    else:
        print(f'Generated self-signed cert: {CERT_PATH}')

קובץ: server/models/storage_item.py

"""
Abstract base class for anything stored in the cloud (files and directories).

StorageItem declares the interface without implementing the type-specific bits.
FileItem and DirectoryItem inherit from it and provide their own implementation
of the abstract methods, so route handlers can operate on list[StorageItem] and
call the same methods on every element (polymorphism).
"""
from abc import ABC, abstractmethod
from datetime import datetime, timedelta

from config import recycle_bin_collection


## Abstraction -- StorageItem is an Abstract Base Class; it cannot be
## instantiated directly. Concrete subclasses must implement every
## @abstractmethod or Python refuses to instantiate them.
class StorageItem(ABC):
    """Common contract for File and Directory items."""

    def __init__(self, doc_id, owner, parent_directory_id=None, created_at=None):
        ## Encapsulation -- shared private state behind public properties.
        self._id = doc_id
        self._owner = owner
        self._parent_directory_id = parent_directory_id
        self._created_at = created_at or datetime.utcnow().isoformat()

    @property
    def id(self):
        return self._id

    @property
    def owner(self):
        return self._owner

    @property
    def parent_directory_id(self):
        return self._parent_directory_id

    @property
    def created_at(self):
        return self._created_at

    # --- abstract API: subclasses MUST implement these ---
    @abstractmethod
    def item_type(self):
        """Return 'file' or 'directory'."""

    @abstractmethod
    def display_name(self):
        """Human-readable name."""

    @abstractmethod
    def to_dict(self):
        """Return the dict shape Firestore expects for this item."""

    @abstractmethod
    def _collection_ref(self):
        """Return the Firestore CollectionReference for this item."""

    @abstractmethod
    def permanently_delete(self):
        """Hard-delete from GCS + Firestore."""

    @abstractmethod
    def restore_to_firestore(self):
        """Re-create the Firestore document from this in-memory item."""

    # --- shared logic ---
    def check_ownership(self, username):
        return self._owner == username

    ## Polymorphism -- soft_delete is defined ONCE here; it calls item_type(),
    ## display_name(), to_dict(), _collection_ref() which dispatch to whichever
    ## subclass self actually is.
    def soft_delete(self, parent_directory_id_override=None, path=''):
        """Move this item into the recycle bin (30-day retention)."""
        expiration_date = datetime.utcnow() + timedelta(days=30)
        recycle_bin_data = {
            'item_id': self._id,
            'item_type': self.item_type(),
            'item_name': self.display_name(),
            'original_data': self.to_dict(),
            'deleted_by': self._owner,
            'deleted_at': datetime.utcnow().isoformat(),
            'expires_at': expiration_date.isoformat(),
            'parent_directory_id': parent_directory_id_override
                if parent_directory_id_override is not None else None,
            'path': path,
        }
        ref = recycle_bin_collection.add(recycle_bin_data)
        self._collection_ref().document(self._id).delete()
        return ref[1].id

    # --- factory ---
    @classmethod
    def from_request_item(cls, item):
        """Build a concrete subclass from a {'id':..., 'type':...} payload."""
        from .file_item import FileItem
        from .directory_item import DirectoryItem
        item_id = item.get('id')
        item_type = item.get('type')
        if item_type == 'file':
            return FileItem.load(item_id)
        if item_type == 'directory':
            return DirectoryItem.load(item_id)
        return None

    @classmethod
    def from_recycle_bin_doc(cls, doc):
        """Build a concrete StorageItem from a recycle_bin DocumentSnapshot."""
        from .file_item import FileItem
        from .directory_item import DirectoryItem
        data = doc.to_dict() or {}
        item_type = data.get('item_type')
        item_id = data.get('item_id')
        original = data.get('original_data', {}) or {}
        if item_type == 'file':
            return FileItem.from_firestore_dict(item_id, original)
        if item_type == 'directory':
            return DirectoryItem.from_firestore_dict(item_id, original)
        return None

קובץ: server/models/file_item.py

"""
FileItem: concrete StorageItem for uploaded files.
"""
from datetime import datetime

from werkzeug.utils import secure_filename

from config import db, gcs_bucket
from utils.crypto import (
    generate_dek, encrypt_with_dek, wrap_dek_with_master, decrypt_stored_file,
)
from .storage_item import StorageItem


## Inheritance -- FileItem specialises StorageItem (inherits soft_delete,
## check_ownership, and the factory classmethods).
class FileItem(StorageItem):
    """An uploaded file. GCS stores the bytes; Firestore stores metadata."""

    def __init__(self, doc_id, owner, filename, stored_filename, gcs_path,
                 size, content_type, directory_id=None, uploaded_at=None,
                 wrapped_dek=None):
        super().__init__(doc_id, owner, parent_directory_id=directory_id,
                         created_at=uploaded_at)
        self._filename = filename
        self._stored_filename = stored_filename
        self._gcs_path = gcs_path
        self._size = size
        self._content_type = content_type
        # Per-file DEK wrapped under the master KEK (envelope encryption). None
        # for legacy blobs encrypted directly under the global key.
        self._wrapped_dek = wrapped_dek

    @property
    def filename(self):
        return self._filename

    @property
    def stored_filename(self):
        return self._stored_filename

    @property
    def gcs_path(self):
        return self._gcs_path

    @property
    def size(self):
        return self._size

    @property
    def content_type(self):
        return self._content_type

    @property
    def wrapped_dek(self):
        return self._wrapped_dek

    # --- abstract implementations (Polymorphism) ---
    def item_type(self):
        return 'file'

    def display_name(self):
        return self._filename

    def to_dict(self):
        return {
            'filename': self._filename,
            'stored_filename': self._stored_filename,
            'gcs_path': self._gcs_path,
            'uploaded_by': self._owner,
            'uploaded_at': self._created_at,
            'size': self._size,
            'content_type': self._content_type,
            'directory_id': self._parent_directory_id,
            'wrapped_dek': self._wrapped_dek,
        }

    def _collection_ref(self):
        return db.collection('files')

    def permanently_delete(self):
        """Delete the GCS blob and remove the Firestore file document."""
        if self._gcs_path:
            blob = gcs_bucket.blob(self._gcs_path)
            if blob.exists():
                blob.delete()
        db.collection('files').document(self._id).delete()

    def restore_to_firestore(self):
        db.collection('files').document(self._id).set(self.to_dict())

    # --- file helpers ---
    def exists_in_gcs(self):
        if not self._gcs_path:
            return False
        return gcs_bucket.blob(self._gcs_path).exists()

    def download_bytes(self):
        """Download from GCS and decrypt (per-file DEK, legacy global-key fallback)."""
        ciphertext = gcs_bucket.blob(self._gcs_path).download_as_bytes()
        return decrypt_stored_file(ciphertext, self._wrapped_dek)

    # --- move / copy ---
    def move(self, destination_directory_id):
        """Move into destination_directory_id (None = root). Raises ValueError on
        duplicate name there."""
        files_collection = db.collection('files')
        for doc in files_collection.where('uploaded_by', '==', self._owner).get():
            existing = doc.to_dict() or {}
            if (doc.id != self._id and
                    existing.get('filename') == self._filename and
                    existing.get('directory_id') == destination_directory_id):
                raise ValueError(
                    f"File '{self._filename}' already exists in destination"
                )
        files_collection.document(self._id).update({'directory_id': destination_directory_id})
        self._parent_directory_id = destination_directory_id

    def copy(self, destination_directory_id):
        """Duplicate into destination_directory_id, auto-naming on collision.
        Returns the new FileItem."""
        if not self._gcs_path:
            raise ValueError("Source file has no GCS path")
        source_blob = gcs_bucket.blob(self._gcs_path)
        if not source_blob.exists():
            raise ValueError("Source file missing from storage")

        files_collection = db.collection('files')
        existing_files = list(files_collection.where('uploaded_by', '==', self._owner).get())

        new_filename = f"Copy of {self._filename}"
        copy_num = 1
        while any(
            (d.to_dict() or {}).get('filename') == new_filename and
            (d.to_dict() or {}).get('directory_id') == destination_directory_id
            for d in existing_files
        ):
            copy_num += 1
            new_filename = f"Copy ({copy_num}) of {self._filename}"

        timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
        new_stored_filename = timestamp + secure_filename(new_filename)
        new_gcs_path = self._build_gcs_path(self._owner, new_stored_filename)
        # copy_blob duplicates the ciphertext as-is; the copy reuses the same
        # wrapped_dek -- no decrypt/re-encrypt needed.
        gcs_bucket.copy_blob(source_blob, gcs_bucket, new_gcs_path)

        new_item = FileItem(
            doc_id=None, owner=self._owner, filename=new_filename,
            stored_filename=new_stored_filename, gcs_path=new_gcs_path,
            size=self._size, content_type=self._content_type,
            directory_id=destination_directory_id,
            uploaded_at=datetime.utcnow().isoformat(),
            wrapped_dek=self._wrapped_dek,
        )
        ref = files_collection.add(new_item.to_dict())
        new_item._id = ref[1].id
        return new_item

    # --- classmethods ---
    @classmethod
    def from_firestore_dict(cls, doc_id, data):
        return cls(
            doc_id=doc_id,
            owner=data.get('uploaded_by', ''),
            filename=data.get('filename', ''),
            stored_filename=data.get('stored_filename', ''),
            gcs_path=data.get('gcs_path', ''),
            size=data.get('size', 0),
            content_type=data.get('content_type', 'application/octet-stream'),
            directory_id=data.get('directory_id'),
            uploaded_at=data.get('uploaded_at'),
            wrapped_dek=data.get('wrapped_dek'),
        )

    @classmethod
    def load(cls, file_id):
        doc = db.collection('files').document(file_id).get()
        if not doc.exists:
            return None
        return cls.from_firestore_dict(doc.id, doc.to_dict() or {})

    @classmethod
    def _build_gcs_path(cls, username, stored_filename):
        return f"users/{username}/files/{stored_filename}"

    @classmethod
    def upload(cls, current_user, file_storage, directory_id):
        """Upload to GCS and persist metadata. Raises ValueError on duplicate name."""
        username = current_user['username']
        files_collection = db.collection('files')

        for doc in files_collection.where('uploaded_by', '==', username).get():
            existing = doc.to_dict() or {}
            if (existing.get('filename') == file_storage.filename and
                    existing.get('directory_id') == directory_id):
                raise ValueError(
                    f"A file named '{file_storage.filename}' already exists in this location"
                )

        filename = secure_filename(file_storage.filename)
        timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
        stored_filename = timestamp + filename
        gcs_path = cls._build_gcs_path(username, stored_filename)

        file_content = file_storage.read()
        file_size = len(file_content)
        content_type = file_storage.content_type or 'application/octet-stream'
        # Envelope encryption: per-file DEK, bytes under DEK, DEK wrapped under KEK.
        dek = generate_dek()
        ciphertext = encrypt_with_dek(file_content, dek)
        wrapped_dek = wrap_dek_with_master(dek)
        gcs_bucket.blob(gcs_path).upload_from_string(
            ciphertext, content_type='application/octet-stream'
        )

        item = cls(
            doc_id=None, owner=username, filename=file_storage.filename,
            stored_filename=stored_filename, gcs_path=gcs_path, size=file_size,
            content_type=content_type, directory_id=directory_id,
            uploaded_at=datetime.utcnow().isoformat(), wrapped_dek=wrapped_dek,
        )
        ref = files_collection.add(item.to_dict())
        item._id = ref[1].id
        return item

קובץ: server/models/directory_item.py

"""
DirectoryItem: concrete StorageItem for folders.
"""
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipfile
import io

from config import db, directories_collection, gcs_bucket
from utils.crypto import decrypt_stored_file
from .storage_item import StorageItem

# Defensive caps on recursive zip downloads to prevent memory exhaustion / DoS.
MAX_ZIP_FILES = 2000
MAX_ZIP_BYTES = 512 * 1024 * 1024  # 512 MB uncompressed


class ZipLimitExceeded(ValueError):
    """Raised when a directory's zip would exceed configured caps."""


class DirectoryItem(StorageItem):
    """A folder. Firestore-only metadata; GCS has no folder concept here."""

    def __init__(self, doc_id, owner, name, parent_directory_id=None, created_at=None):
        super().__init__(doc_id, owner, parent_directory_id=parent_directory_id,
                         created_at=created_at)
        self._name = name

    @property
    def name(self):
        return self._name

    # --- abstract implementations (Polymorphism) ---
    def item_type(self):
        return 'directory'

    def display_name(self):
        return self._name

    def to_dict(self):
        return {
            'name': self._name,
            'created_by': self._owner,
            'created_at': self._created_at,
            'parent_directory_id': self._parent_directory_id,
        }

    def _collection_ref(self):
        return directories_collection

    def permanently_delete(self):
        """Hard-delete all child files (GCS + Firestore) and subdirs, then this dir."""
        from .file_item import FileItem
        files_collection = db.collection('files')

        ## Multi-threading -- child blob+doc deletes are I/O; fan out across a pool.
        child_files = []
        for doc in files_collection.where('uploaded_by', '==', self._owner).get():
            data = doc.to_dict() or {}
            if data.get('directory_id') == self._id:
                child_files.append(FileItem.from_firestore_dict(doc.id, data))

        if child_files:
            with ThreadPoolExecutor(max_workers=8) as pool:
                for future in as_completed([pool.submit(c.permanently_delete) for c in child_files]):
                    future.result()

        for doc in directories_collection.where('created_by', '==', self._owner).get():
            data = doc.to_dict() or {}
            if data.get('parent_directory_id') == self._id:
                child = DirectoryItem.from_firestore_dict(doc.id, data)
                child.permanently_delete()  # recursive

        directories_collection.document(self._id).delete()

    def restore_to_firestore(self):
        directories_collection.document(self._id).set(self.to_dict())

    # --- soft_delete override: recursive child deletion ---
    def soft_delete(self, parent_directory_id_override=None, path=''):
        from .file_item import FileItem
        files_collection = db.collection('files')
        self._soft_delete_descendants(self._id, path or self._name, files_collection)
        super().soft_delete(parent_directory_id_override=parent_directory_id_override, path=path)

    def _soft_delete_descendants(self, dir_id, dir_path, files_collection):
        from .file_item import FileItem
        for doc in files_collection.where('uploaded_by', '==', self._owner).get():
            data = doc.to_dict() or {}
            if data.get('directory_id') == dir_id:
                child = FileItem.from_firestore_dict(doc.id, data)
                child.soft_delete(parent_directory_id_override=self._id, path=dir_path)
        for doc in directories_collection.where('created_by', '==', self._owner).get():
            data = doc.to_dict() or {}
            if data.get('parent_directory_id') == dir_id:
                subdir_name = data.get('name', '')
                subdir_path = f"{dir_path}/{subdir_name}"
                self._soft_delete_descendants(doc.id, subdir_path, files_collection)
                child = DirectoryItem.from_firestore_dict(doc.id, data)
                StorageItem.soft_delete(child, parent_directory_id_override=self._id,
                                        path=subdir_path)

    # --- move / copy ---
    def move(self, destination_directory_id):
        """Move under destination_directory_id (None = root)."""
        if destination_directory_id == self._id:
            raise ValueError("Cannot move directory into itself")
        if destination_directory_id and self._would_create_cycle(destination_directory_id):
            raise ValueError("Cannot move directory into its own subdirectory")
        for doc in directories_collection.where('created_by', '==', self._owner).get():
            existing = doc.to_dict() or {}
            if (doc.id != self._id and
                    existing.get('name') == self._name and
                    existing.get('parent_directory_id') == destination_directory_id):
                raise ValueError(
                    f"Directory '{self._name}' already exists in destination"
                )
        directories_collection.document(self._id).update(
            {'parent_directory_id': destination_directory_id}
        )
        self._parent_directory_id = destination_directory_id

    def _would_create_cycle(self, candidate_parent_id):
        """True if candidate is self or a descendant of self."""
        current = candidate_parent_id
        while current:
            if current == self._id:
                return True
            doc = directories_collection.document(current).get()
            if not doc.exists:
                return False
            current = (doc.to_dict() or {}).get('parent_directory_id')
        return False

    def copy(self, destination_parent_id):
        """Duplicate this directory and its entire contents. Returns new DirectoryItem."""
        from .file_item import FileItem
        existing_dirs = list(directories_collection.where('created_by', '==', self._owner).get())
        new_name = f"Copy of {self._name}"
        copy_num = 1
        while any(
            (d.to_dict() or {}).get('name') == new_name and
            (d.to_dict() or {}).get('parent_directory_id') == destination_parent_id
            for d in existing_dirs
        ):
            copy_num += 1
            new_name = f"Copy ({copy_num}) of {self._name}"
        new_dir = DirectoryItem.create(
            owner=self._owner, name=new_name, parent_directory_id=destination_parent_id,
        )
        self._copy_contents_into(self._id, new_dir.id)
        return new_dir

    def _copy_contents_into(self, source_dir_id, target_dir_id):
        from .file_item import FileItem
        files_collection = db.collection('files')
        child_files = []
        for doc in files_collection.where('uploaded_by', '==', self._owner).get():
            data = doc.to_dict() or {}
            if data.get('directory_id') == source_dir_id:
                child_files.append(FileItem.from_firestore_dict(doc.id, data))
        if child_files:
            with ThreadPoolExecutor(max_workers=8) as pool:
                for future in as_completed(
                    [pool.submit(c.copy, target_dir_id) for c in child_files]
                ):
                    future.result()
        for doc in directories_collection.where('created_by', '==', self._owner).get():
            data = doc.to_dict() or {}
            if data.get('parent_directory_id') == source_dir_id:
                subdir_name = data.get('name', '')
                new_sub = DirectoryItem.create(
                    owner=self._owner, name=subdir_name, parent_directory_id=target_dir_id,
                )
                self._copy_contents_into(doc.id, new_sub.id)

    # --- directory helpers ---
    def zip_recursively(self):
        """Return a BytesIO ZIP archive of all files in this directory tree."""
        files_collection = db.collection('files')

        def collect_files(dir_id, base_path):
            result = []
            for doc in files_collection.where('uploaded_by', '==', self._owner).get():
                data = doc.to_dict() or {}
                if data.get('directory_id') == dir_id:
                    fname = data.get('filename', '')
                    relative = f"{base_path}{fname}" if base_path else fname
                    result.append({
                        'gcs_path': data.get('gcs_path', ''),
                        'relative': relative,
                        'wrapped_dek': data.get('wrapped_dek'),
                    })
            for doc in directories_collection.where('created_by', '==', self._owner).get():
                data = doc.to_dict() or {}
                if data.get('parent_directory_id') == dir_id:
                    sub = f"{base_path}{data.get('name', '')}/"
                    result.extend(collect_files(doc.id, sub))
            return result

        all_files = collect_files(self._id, '')
        if len(all_files) > MAX_ZIP_FILES:
            raise ZipLimitExceeded(
                f"Directory exceeds maximum file count for zip download ({MAX_ZIP_FILES})"
            )

        ## Multi-threading -- GCS downloads are I/O-bound; the ZIP write stays on
        ## the main thread (zipfile is NOT thread-safe).
        def fetch_blob(file_info):
            if not file_info['gcs_path']:
                return None
            blob = gcs_bucket.blob(file_info['gcs_path'])
            if not blob.exists():
                return None
            return (file_info['relative'],
                    decrypt_stored_file(blob.download_as_bytes(), file_info.get('wrapped_dek')))

        zip_buffer = io.BytesIO()
        files_added = 0
        total_bytes = 0
        with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
            with ThreadPoolExecutor(max_workers=8) as pool:
                futures = [pool.submit(fetch_blob, f) for f in all_files]
                for future in as_completed(futures):
                    result = future.result()
                    if result is None:
                        continue
                    relative, data = result
                    total_bytes += len(data)
                    if total_bytes > MAX_ZIP_BYTES:
                        raise ZipLimitExceeded(
                            f"Directory exceeds maximum size for zip download ({MAX_ZIP_BYTES} bytes)"
                        )
                    zf.writestr(relative, data)
                    files_added += 1
        if files_added == 0:
            raise ValueError("Directory is empty")
        zip_buffer.seek(0)
        return zip_buffer

    # --- classmethods ---
    @classmethod
    def from_firestore_dict(cls, doc_id, data):
        return cls(
            doc_id=doc_id,
            owner=data.get('created_by', ''),
            name=data.get('name', ''),
            parent_directory_id=data.get('parent_directory_id'),
            created_at=data.get('created_at'),
        )

    @classmethod
    def load(cls, dir_id):
        doc = directories_collection.document(dir_id).get()
        if not doc.exists:
            return None
        return cls.from_firestore_dict(doc.id, doc.to_dict() or {})

    @classmethod
    def create(cls, owner, name, parent_directory_id=None):
        """Create a new directory. Raises ValueError on duplicate name."""
        for doc in directories_collection.where('created_by', '==', owner).get():
            data = doc.to_dict() or {}
            if (data.get('name') == name and
                    data.get('parent_directory_id') == parent_directory_id):
                raise ValueError("Directory with this name already exists")
        item = cls(
            doc_id=None, owner=owner, name=name,
            parent_directory_id=parent_directory_id,
            created_at=datetime.utcnow().isoformat(),
        )
        ref = directories_collection.add(item.to_dict())
        item._id = ref[1].id
        return item

קובץ: server/models/user.py

"""
User domain model. Encapsulates account data and authentication. AdminUser
inherits from User and overrides is_admin() to demonstrate polymorphism.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed

from werkzeug.security import check_password_hash, generate_password_hash

from config import db, users_collection, gcs_bucket, directories_collection, shares_collection, favorites_collection
from utils.crypto import generate_rsa_keypair, wrap_private_key


class User:
    """A regular application user. Wraps a Firestore `users` document."""

    def __init__(self, username, password_hash, first_name='', last_name='',
                 profile_picture=None, doc_id=None,
                 public_key=None, wrapped_private_key=None):
        self._username = username
        self._password_hash = password_hash
        self._first_name = first_name
        self._last_name = last_name
        self._profile_picture = profile_picture
        self._doc_id = doc_id
        # RSA keypair: public key stored in the clear; private key wrapped under
        # the master KEK at rest. Either may be None until ensure_keypair backfills.
        self._public_key = public_key
        self._wrapped_private_key = wrapped_private_key

    @property
    def username(self):
        return self._username

    @property
    def first_name(self):
        return self._first_name

    @property
    def last_name(self):
        return self._last_name

    @property
    def profile_picture(self):
        return self._profile_picture

    @property
    def doc_id(self):
        return self._doc_id

    @property
    def password_hash(self):
        return self._password_hash

    @property
    def public_key(self):
        return self._public_key

    @property
    def wrapped_private_key(self):
        return self._wrapped_private_key

    # --- password (Encapsulation) ---
    def set_password(self, plain_password):
        # Pin scrypt explicitly; check_password_hash still auto-detects old pbkdf2.
        self._password_hash = generate_password_hash(plain_password, method='scrypt')

    def verify_password(self, plain_password):
        if not self._password_hash:
            return False
        return check_password_hash(self._password_hash, plain_password)

    # --- roles ---
    def is_admin(self):
        """Default role check. AdminUser overrides this."""
        return self._username == 'admin'

    # --- keypair ---
    def ensure_keypair(self):
        """Guarantee this user has an RSA keypair, generating + persisting if absent."""
        if self._public_key and self._wrapped_private_key:
            return
        private_pem, public_pem = generate_rsa_keypair()
        self._public_key = public_pem.decode('utf-8')
        self._wrapped_private_key = wrap_private_key(private_pem)
        if self._doc_id:
            users_collection.document(self._doc_id).update({
                'public_key': self._public_key,
                'wrapped_private_key': self._wrapped_private_key,
            })

    # --- dict-shim so legacy current_user['username'] keeps working ---
    def __getitem__(self, key):
        mapping = {
            'username': self._username,
            'firstName': self._first_name,
            'lastName': self._last_name,
            'profilePicture': self._profile_picture,
            'password': self._password_hash,
            '_id': self._doc_id,
        }
        if key in mapping:
            return mapping[key]
        raise KeyError(key)

    def get(self, key, default=None):
        try:
            return self[key]
        except KeyError:
            return default

    def to_dict(self):
        return {
            'username': self._username,
            'password': self._password_hash,
            'firstName': self._first_name,
            'lastName': self._last_name,
            'profilePicture': self._profile_picture,
            'public_key': self._public_key,
            'wrapped_private_key': self._wrapped_private_key,
        }

    # --- persistence ---
    @classmethod
    def from_firestore_doc(cls, doc):
        """Build the right User subclass from a Firestore DocumentSnapshot."""
        data = doc.to_dict() or {}
        username = data.get('username', '')
        klass = AdminUser if username == 'admin' else User
        return klass(
            username=username,
            password_hash=data.get('password'),
            first_name=data.get('firstName', ''),
            last_name=data.get('lastName', ''),
            profile_picture=data.get('profilePicture'),
            doc_id=doc.id,
            public_key=data.get('public_key'),
            wrapped_private_key=data.get('wrapped_private_key'),
        )

    @classmethod
    def find_by_username(cls, username):
        query = users_collection.where('username', '==', username).limit(1).get()
        docs = list(query)
        if not docs:
            return None
        return cls.from_firestore_doc(docs[0])

    @classmethod
    def create(cls, username, password, first_name, last_name, profile_picture=None):
        """Create and persist a new user (with an RSA keypair). Raises ValueError if taken."""
        if cls.find_by_username(username) is not None:
            raise ValueError("Username already exists")
        private_pem, public_pem = generate_rsa_keypair()
        user = cls(
            username=username, password_hash=None,
            first_name=first_name, last_name=last_name,
            profile_picture=profile_picture,
            public_key=public_pem.decode('utf-8'),
            wrapped_private_key=wrap_private_key(private_pem),
        )
        user.set_password(password)
        ref = users_collection.add(user.to_dict())
        user._doc_id = ref[1].id
        return user


## Inheritance -- AdminUser specializes User, overriding just the role check and
## adding admin-only queries.
class AdminUser(User):
    """The single admin account (username == 'admin')."""

    def __init__(self, username, password_hash, first_name='', last_name='',
                 profile_picture=None, doc_id=None,
                 public_key=None, wrapped_private_key=None):
        super().__init__(username, password_hash, first_name, last_name,
                         profile_picture, doc_id, public_key, wrapped_private_key)

    def is_admin(self):
        return True

    def list_all_users(self):
        """Every user with file count + total storage. Powers GET /api/admin/users."""
        files_collection = db.collection('files')
        users_out = []
        for doc in users_collection.get():
            data = doc.to_dict() or {}
            uname = data.get('username', '')
            file_count = 0
            total_size = 0
            for file_doc in files_collection.where('uploaded_by', '==', uname).get():
                info = file_doc.to_dict() or {}
                if not info.get('is_deleted', False):
                    file_count += 1
                    total_size += info.get('size', 0)
            users_out.append({
                'id': doc.id, 'username': uname,
                'firstName': data.get('firstName', ''),
                'lastName': data.get('lastName', ''),
                'email': data.get('email', ''),
                'created_at': data.get('created_at', ''),
                'profilePicture': data.get('profilePicture'),
                'file_count': file_count, 'total_storage': total_size,
            })
        users_out.sort(key=lambda x: x.get('username', '').lower())
        return users_out

    def system_stats(self):
        """Aggregated counts/totals for the admin dashboard."""
        files_collection = db.collection('files')
        total_users = len(list(users_collection.get()))
        total_files = 0
        total_storage = 0
        for doc in files_collection.get():
            data = doc.to_dict() or {}
            if not data.get('is_deleted', False):
                total_files += 1
                total_storage += data.get('size', 0)
        total_directories = len(list(directories_collection.get()))
        total_shares = len(list(shares_collection.get()))
        return {
            'total_users': total_users, 'total_files': total_files,
            'total_directories': total_directories,
            'total_storage': total_storage, 'total_shares': total_shares,
        }

    def delete_user(self, user_id):
        """Cascade-delete a non-admin user. Raises ValueError when targeting admin."""
        user_doc = users_collection.document(user_id).get()
        if not user_doc.exists:
            raise LookupError("User not found")
        data = user_doc.to_dict() or {}
        username = data.get('username')
        if username == 'admin':
            raise ValueError("Cannot delete admin user")

        files_collection = db.collection('files')
        user_files = list(files_collection.where('uploaded_by', '==', username).get())

        def delete_one_file(file_doc):
            fdata = file_doc.to_dict() or {}
            gcs_path = fdata.get('gcs_path')
            if gcs_path:
                blob = gcs_bucket.blob(gcs_path)
                if blob.exists():
                    blob.delete()
            files_collection.document(file_doc.id).delete()

        if user_files:
            with ThreadPoolExecutor(max_workers=8) as pool:
                for future in as_completed([pool.submit(delete_one_file, d) for d in user_files]):
                    future.result()
        for dir_doc in directories_collection.where('created_by', '==', username).get():
            directories_collection.document(dir_doc.id).delete()
        for share_doc in shares_collection.where('owner', '==', username).get():
            shares_collection.document(share_doc.id).delete()
        for share_doc in shares_collection.where('shared_with', '==', username).get():
            shares_collection.document(share_doc.id).delete()
        for fav_doc in favorites_collection.where('user', '==', username).get():
            favorites_collection.document(fav_doc.id).delete()
        users_collection.document(user_id).delete()
        return username

קובץ: desktop/socket_client.py

"""
NotificationClient: the desktop side of the raw-TCP notification protocol.

Opens a plain TCP socket to the server's notification port, authenticates with a
JWT, then listens on a background thread for pushed events. Parsed events go on a
thread-safe queue so the Tkinter UI can drain them on the main thread.

Protocol mirror of server/socket_server.py:
    we send    {"type": "auth", "token": "<jwt>"}  first, then periodic {"type": "ping"}
    we receive {"type": "auth_ok"|"auth_error"|"pong"|"shared"|"file_changed", ...}
"""
import json
import queue
import socket
import threading


class NotificationClient:
    def __init__(self):
        self._sock = None
        self._recv_thread = None
        self._ping_thread = None
        self._stop = threading.Event()
        # The UI polls this queue via root.after(); each item is a parsed event dict.
        self.events = queue.Queue()

    def connect(self, host, port, token, timeout=10):
        """Open the socket, authenticate, and start the receive loop."""
        self._stop.clear()
        sock = socket.create_connection((host, port), timeout=timeout)
        sock.settimeout(None)
        self._sock = sock
        self._reader = _LineReader(sock)

        self._send({"type": "auth", "token": token})
        response = self._read_message()
        if not response or response.get("type") != "auth_ok":
            err = (response or {}).get("error", "authentication failed")
            self.close()
            raise ConnectionError(err)

        self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
        self._recv_thread.start()
        self._ping_thread = threading.Thread(target=self._ping_loop, daemon=True)
        self._ping_thread.start()
        return response

    def _send(self, obj):
        self._sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))

    def _read_message(self):
        line = self._reader.readline()
        if not line:
            return None
        try:
            obj = json.loads(line)
            return obj if isinstance(obj, dict) else None
        except json.JSONDecodeError:
            return None

    def _recv_loop(self):
        try:
            while not self._stop.is_set():
                msg = self._read_message()
                if msg is None:
                    break  # server closed the connection
                if msg.get("type") == "pong":
                    continue
                self.events.put(msg)
        except OSError:
            pass
        finally:
            if not self._stop.is_set():
                self.events.put({"type": "_disconnected"})

    def _ping_loop(self):
        # Heartbeat every 20s so dead connections are noticed promptly.
        while not self._stop.wait(20):
            try:
                self._send({"type": "ping"})
            except OSError:
                break

    def close(self):
        self._stop.set()
        if self._sock is not None:
            try:
                self._sock.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass
            try:
                self._sock.close()
            except OSError:
                pass
            self._sock = None


class _LineReader:
    """Buffers bytes off a socket and yields one '\\n'-terminated line at a time."""

    def __init__(self, sock):
        self._sock = sock
        self._buf = b""

    def readline(self):
        while b"\n" not in self._buf:
            chunk = self._sock.recv(4096)
            if not chunk:
                line, self._buf = self._buf, b""
                return line.decode("utf-8", "replace") if line else ""
            self._buf += chunk
        line, _, self._buf = self._buf.partition(b"\n")
        return line.decode("utf-8", "replace")

קובץ: desktop/api_client.py

"""
ApiClient: thin REST wrapper for the desktop app, built on the standard library
(urllib) so the desktop client needs no third-party dependencies. Logs in to
obtain a JWT, then sends it as Authorization: Bearer <token> on each request.
File bytes travel over this REST channel -- the socket only carries small events.
"""
import json
import ssl
import urllib.error
import urllib.request


class ApiError(Exception):
    pass


class ApiClient:
    def __init__(self, base_url):
        # base_url like "http://localhost:5000" (no trailing slash, no /api).
        self.base_url = base_url.rstrip("/")
        self.token = None
        self.username = None
        # The dev server may use a self-signed cert over HTTPS; for a local demo
        # we don't verify it. Over plain HTTP this context is unused.
        self._ssl_ctx = ssl.create_default_context()
        self._ssl_ctx.check_hostname = False
        self._ssl_ctx.verify_mode = ssl.CERT_NONE

    def _request(self, method, path, *, json_body=None, auth=True):
        url = f"{self.base_url}/api{path}"
        data = None
        headers = {"Accept": "application/json"}
        if json_body is not None:
            data = json.dumps(json_body).encode("utf-8")
            headers["Content-Type"] = "application/json"
        if auth:
            if not self.token:
                raise ApiError("Not authenticated")
            headers["Authorization"] = f"Bearer {self.token}"

        req = urllib.request.Request(url, data=data, headers=headers, method=method)
        ctx = self._ssl_ctx if url.startswith("https") else None
        try:
            with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
                return resp.read()
        except urllib.error.HTTPError as e:
            body = e.read().decode("utf-8", "replace")
            try:
                message = json.loads(body).get("error", body)
            except json.JSONDecodeError:
                message = body or e.reason
            raise ApiError(f"{e.code}: {message}") from e
        except urllib.error.URLError as e:
            raise ApiError(f"Cannot reach server: {e.reason}") from e

    def _get_json(self, path):
        return json.loads(self._request("GET", path).decode("utf-8"))

    def login(self, username, password):
        """POST /api/auth/login -> store and return the JWT."""
        raw = self._request(
            "POST", "/auth/login",
            json_body={"username": username, "password": password},
            auth=False,
        )
        data = json.loads(raw.decode("utf-8"))
        self.token = data["token"]
        self.username = data.get("username", username)
        return self.token

    def shared_with_me(self):
        return self._get_json("/shared-with-me")

    def download_shared_file(self, file_id, dest_path):
        content = self._request("GET", f"/shared-files/{file_id}/download")
        with open(dest_path, "wb") as fh:
            fh.write(content)
        return dest_path

קובץ: desktop/preferences.py

"""
Tiny key/value preferences file. Persists the theme choice to
%APPDATA%/CloudStorage/preferences.json on Windows, falling back to
~/.cloudstorage/preferences.json elsewhere.
"""
import json
import os
from pathlib import Path


def _prefs_dir():
    appdata = os.environ.get("APPDATA")
    base = Path(appdata) if appdata else Path.home() / ".cloudstorage"
    folder = base / "CloudStorage" if appdata else base
    folder.mkdir(parents=True, exist_ok=True)
    return folder


def _prefs_file():
    return _prefs_dir() / "preferences.json"


def _read():
    try:
        with open(_prefs_file(), "r", encoding="utf-8") as f:
            data = json.load(f)
            return data if isinstance(data, dict) else {}
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        return {}


def _write(data):
    try:
        with open(_prefs_file(), "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
    except OSError:
        pass  # preferences are best-effort; never crash the app over them


def load_theme(default="dark"):
    value = _read().get("theme")
    return value if value in ("dark", "light") else default


def save_theme(name):
    if name not in ("dark", "light"):
        return
    data = _read()
    data["theme"] = name
    _write(data)

קבצי desktop/app.py, components.py, theme.py — תקציר מבני

שלושת קבצי ה-UI הגדולים מובאים כתקציר (כמו home.js), שכן רובם פריסת widgets ועיצוב.

# ---------- desktop/theme.py ----------
# פלטות צבע dark/light התואמות ל-client/home/home.css (רקע slate, הדגשות cyan/pink),
# ובחירת משפחת פונט (Inter -> Segoe UI -> ברירת מחדל).
DARK  = { 'bg': '#0f172a', 'surface': '#1e293b', 'accent': '#06b6d4', 'accent2': '#ec4899', ... }
LIGHT = { 'bg': '#f8fafc', 'surface': '#ffffff', 'accent': '#06b6d4', ... }
def palette(name): return DARK if name == 'dark' else LIGHT
def font_family(): ...   # מחזיר את הפונט הזמין הראשון

# ---------- desktop/components.py ----------
# רכיבי CustomTkinter לשימוש חוזר, כל אחד עם apply_theme(palette):
class Header(ctk.CTkFrame): ...        # לוגו + שם משתמש + כפתור theme
class StatusPill(ctk.CTkLabel): ...    # אדום/ירוק: Disconnected / Connected as <user>
class NotificationFeed(ctk.CTkScrollableFrame): ...   # רשימת NotificationCard
class NotificationCard(ctk.CTkFrame): ...
class SharedList(ctk.CTkScrollableFrame): ...  # פריטים שותפו איתי + כפתור Download
class IconButton(ctk.CTkButton): ...
class ToastManager: ...                # toasts זמניים בפינה התחתונה
class Tooltip: ...

# ---------- desktop/app.py ----------
# נקודת הכניסה: מסך login, ואז המסך הראשי. מצב מנוהל ב-NotificationClient + ApiClient.
class CloudStorageApp(ctk.CTk):
    def __init__(self):
        # בונה Header, StatusPill, NotificationFeed, SharedList; טוען theme מ-preferences.
        ...
    def _on_login(self, username, password):
        token = self.api.login(username, password)         # REST
        self.client.connect(host, 5001, token)             # socket
        self.after(200, self._drain_events)                # poll את התור
    def _drain_events(self):
        while not self.client.events.empty():
            evt = self.client.events.get()
            if evt['type'] == 'shared':
                self.toasts.show(f"{evt['owner']} shared {evt['item_name']}")
                self.feed.add_card(evt); self._reload_shared()
        self.after(200, self._drain_events)
    def _toggle_theme(self): ...   # מחליף פלטה לכל הרכיבים + save_theme()

if __name__ == '__main__':
    CloudStorageApp().mainloop()

קבצי CSS — סיכום

קבצי ה-CSS של כל עמוד גדולים מאוד (300-1500 שורות לכל אחד) ולא מצורפים כאן כקוד מלא בשל אורכם. הם זמינים ברפו של הפרויקט. תיאור קצר:

קובץתפקיד
client/index/index.cssעמוד פתיחה — gradient backgrounds, animations, theme vars
client/login/login.cssטופס Login עם card עיצובי + theme toggle
client/signup/signup.cssטופס Signup דומה ל-login עם 5 שדות
client/home/home.cssהקובץ הגדול ביותר — טבלאות, modals, tabs, breadcrumbs, bulk toolbar, sortable headers
client/profile/profile.cssprofile picture circle, form layout, success/error messages
client/admin/admin.cssstat cards, admin tables, user avatars, delete confirmation

כל קבצי ה-CSS משתמשים במערכת משתני CSS (--bg-primary, --text-primary, --accent-primary וכו') שנפרסים לפי :root[data-theme="dark"] או :root[data-theme="light"], מה שמאפשר החלפת ערכת נושא מיידית ללא רענון.

קבצי HTML — סיכום

קבצי ה-HTML של home.html, profile.html, ו-admin.html מצורפים בפרק 4.7 (פירוט מסכי המערכת) ובסקירה המפורטת. הם מכילים את ה-DOM המלא של המודלים, הטבלאות, ולשוניות. אורכים: home.html כ-730 שורות (כולל 11 מודלים), profile.html כ-115 שורות, admin.html כ-200 שורות.

— סוף תיק הפרויקט —

תיק זה הוכן על-ידי מאי, סמל מקצוע 883589, חלופת הגנת סייבר ומערכות הפעלה.