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

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). השרטוט הבא מציג את הקישורים:

דפדפן (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 — ארכיטקטורת החומרה והקשרים בין הרכיבים

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

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

שכבהטכנולוגיהגרסהתפקיד
שפת תכנות שרת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)
בסיס נתוניםGoogle Firestore(via firebase-admin 6.5.0)NoSQL document store בענן
אחסון קבציםGoogle Cloud Storage(via google-cloud-storage 2.14.0)אחסון אובייקטים (blobs)
שפת לקוחHTML5 / CSS3 / ES6 JavaScriptממשק משתמש (ללא framework)
תקשורתHTTP/1.1 + Fetch APIפרוטוקול REST על-גבי TCP
איחסון לקוחיlocalStorageשמירת authToken, username, cloudStorageTheme
בקרת גרסאותGitניהול קוד והיסטוריה

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

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

זרימת התחברות (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)

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

אלגוריתם 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).

סביבת פיתוח

כלי פיתוח

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

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

המערכת משתמשת ב-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.

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

תרשים זרימת מסכים (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] הצגת כותרת "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] טופס התחברות במצב כהה עם כפתור החלפת ערכת נושא בפינה

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

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

[צילום מסך: עמוד signup.html] טופס הרשמה עם 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 Tab] טבלת קבצים עם breadcrumbs, סרגל Tabs (My Files / Shared / Favorites / Recycle Bin), וסרגל Bulk Actions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

[צילום מסך: Move Modal / Copy Modal / Bulk Delete Modal] סדרת חלונות אישור עם רשימת תיקיות יעד

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

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

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

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

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

[צילום מסך: 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_..."לא

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

קולקציה: 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לא

הערה: קולקציה זו מאוחזרת כ-db.collection('files') בכל מודול, ואינה מיוצאת מ-config.py.

קולקציה: 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"

קולקציה: 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)

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

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

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

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

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

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

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)

מה הקוד עושה היום: ב-home.js מסוימים נעשה שימוש ב-innerHTML לחיבור HTML דינמי (לדוגמה, יצירת שורות טבלת קבצים). שמות קבצים שמוצגים בטבלאות מגיעים מ-Firestore — היכן שהמקור הוא שם הקובץ שהמשתמש העלה. שם קובץ ש-secure_filename כבר ניקה אותו לא יכיל אופייני HTML מסוכנים, אך אם בשם הקובץ נשאר תו XSS-relevant ייתכן וקטור.

בייצור: מעבר עקבי ל-textContent, או הוספת ספריית escape, או שימוש ב-template engine. בנוסף: הגדרת Content-Security-Policy ב-Headers.

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 נקרא מ-Environment Variable, אך במקרה היעדרו יש fallback קבוע 'your-secret-key-change-in-production' ב- config.py. בסביבת פיתוח זה ה-fallback. אם סוד זה ידוע לתוקף, ניתן לזייף JWT.

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

6. הצפנה (Encryption) ו-MITM

מה הקוד עושה היום: השרת רץ ב-debug=True על HTTP פשוט (ללא TLS). בקשות מהדפדפן מגיעות בטקסט גלוי – כולל ה-JWT. ה-CORS פתוח לחלוטין (origins="*") — ראוי לסביבת פיתוח, מסוכן בייצור.

בייצור: חובה לאכוף HTTPS דרך reverse proxy (nginx + Let's Encrypt), HSTS header, CORS מצומצם ל-domain ספציפי, הצפנה נוספת ברמת השדה לנתונים רגישים (Field-Level Encryption).

7. DoS / DDoS

מה הקוד עושה היום: אין rate-limiting בכלל. שום endpoint לא מוגן מפני flood של בקשות. MAX_CONTENT_LENGTH=16MB מגביל גודל קובץ בודד, אבל לא מספר ההעלאות הכולל. פעולות Bulk לא מוגבלות בכמות הפריטים — תוקף יכול לבקש Bulk Download של 100,000 פריטים ולגרום ל-OOM.

בייצור: 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 ב-app.py מחזיר את str(e) ל-client רק כאשר app.debug=True. השרת רץ עם debug=True תמיד, ולכן בפועל stack traces ופרטי שגיאה אחרים נחשפים.

בייצור: debug=False, ניתוב הודעות שגיאה אמיתיות ללוג בלבד, החזרת hash/error-id ללקוח.

11. הצפנת נתונים במנוחה (At-Rest)

מה הקוד עושה היום: GCS מצפין כל אובייקט אוטומטית ב-AES-256 (Google Default Encryption). Firestore מצפין נתונים אוטומטית כברירת מחדל. הגנה זו מקבלת ע"י תשתית הענן ללא קונפיגורציה.

בייצור: שימוש ב-Customer-Managed Encryption Keys (CMEK) דרך KMS לבקרה מלאה.

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

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

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

שכבת התעבורה (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

בסביבת הפיתוח אין TLS — תקשורת על HTTP פשוט (פורט 5000). בייצור: HTTPS חובה. החיבור בין שרת Flask ל-Firestore ול-GCS עובר על HTTPS/TLS אוטומטית דרך firebase-admin ו-google-cloud-storage. כלומר, ההפסד הוא רק במקטע Browser ↔ Flask.

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

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

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

מודולים מיובאים (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

מודולים פותחו עצמאית — ארכיטקטורת ה-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.

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

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;
    }
});

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

הבדיקות נערכו ידנית בדפדפן 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
│   ├── config.py                   # Singletons (Flask app, Firestore, GCS)
│   ├── seed_db.py                  # סקריפט אתחול חד-פעמי של משתמש admin
│   ├── cloudstorageproject-privatekey.json   # Service Account Key
│   ├── utils/
│   │   ├── __init__.py
│   │   └── auth.py                 # @token_required / @admin_required
│   └── 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)
│
├── requirements.txt                # Python dependencies
├── start_all.bat                   # הפעלת השרת + פתיחת דפדפן
├── start_server.bat                # רק הפעלת שרת
├── start_server.ps1                # גרסת PowerShell
├── 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 (רק שרת).
  7. פתיחת דפדפן בכתובת http://localhost:5000/

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

משתמש מנהל (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]סדרת שלושה מסכים

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

  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]4 stat cards + טבלת משתמשים + טבלת קבצים

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

פרויקט ה-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.
"""
from flask import jsonify

from config import app
from routes import all_blueprints


# Global error handler - Flask-CORS will add headers automatically
@app.errorhandler(Exception)
def handle_exception(e):
    """Handle all uncaught exceptions"""
    error_message = str(e)

    # Log the error
    app.logger.error(f"Unhandled exception: {error_message}")

    # Return a generic error response
    return jsonify({
        "error": "An internal error occurred",
        "details": error_message if app.debug else None
    }), 500


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


if __name__ == '__main__':
    print("Starting Flask server...")
    print("Server running at http://localhost:5000")
    app.run(debug=True, host='0.0.0.0', port=5000)

קובץ: 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

# Create Flask app
app = Flask(__name__, static_folder='../client', static_url_path='')
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production')
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)

# Configure CORS - allow all origins for development
CORS(app,
     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 = []; }
    });
}

קבצי 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, חלופת הגנת סייבר ומערכות הפעלה.