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). המערכת תומכת בשלושה סוגי משתמשים:
admin בלבד, מקבל בנוסף גישה ל-Admin
Dashboard המציג סטטיסטיקות מערכת וניהול משתמשים.הבעיה המרכזית שהפרויקט פותר היא הצורך באחסון קבצים מרוחק, נגיש מכל מכשיר, ללא תלות במחשב מקומי, אך עדיין פרטי לאדם הספציפי. תועלות שהמשתמש מקבל:
| מאפיין | 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 (מסמך הבדיקות) מובא הפירוט המלא של כל בדיקה: מטרה, מה בוצע, תוצאות, ובעיות שהתגלו וכיצד נפתרו. תקציר הבדיקות:
הפרויקט פותח במהלך כשנת לימודים. הטבלה הבאה מציגה את אבני הדרך המרכזיות, על בסיס היסטוריית ה-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. |
חלק זה מציג את היכולות בשני הצדדים — שרת ולקוח — בפורמט מובנה. עבור כל יכולת מצוין שמה, מהותה, אוסף תת-היכולות הנדרשות למימושה, והאובייקטים העיקריים שמעורבים בה.
מהות: רישום משתמש חדש – קליטת firstName / lastName / username / password, אימות שדות, גיבוב סיסמה, יצירת מסמך משתמש ב-Firestore, ויצירת JWT.
תת-יכולות: אימות שדות (אורך, אלפא-נומרי), בדיקת ייחודיות שם משתמש, גיבוב pbkdf2:sha256, הוספה ל-collection users, חתימת JWT HS256 עם תפוגה.
אובייקטים נחוצים: Flask request, users_collection, generate_password_hash, jwt.encode.
מהות: אימות username + password אל מול Firestore, וייצור אסימון JWT.
תת-יכולות: שליפת המסמך לפי שם משתמש, השוואת סיסמה ב-check_password_hash, יצירת JWT עם exp = now + 24h, החזרה ללקוח.
אובייקטים נחוצים: request, users_collection, check_password_hash, jwt.encode.
מהות: בדיקה האם JWT שנשלח בכותרת Authorization תקף; כל בקשה למסלול מוגן עוברת דרך המעטפת.
תת-יכולות: חילוץ Bearer Token מה-header, פענוח JWT, איתור המסמך של המשתמש, החזרת current_user לפונקציה העטופה.
אובייקטים נחוצים: @token_required, jwt.decode, users_collection.
מהות: קבלת 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.
מהות: איסוף רקורסיבי של כל הקבצים והתתי-תיקיות, הורדה מ-GCS לזיכרון, וייצוא ZIP יחיד.
תת-יכולות: סריקת files לפי directory_id, מעבר רקורסיבי לתתי-תיקיות, blob.download_as_bytes, יצירת zipfile.ZipFile ב-io.BytesIO, send_file.
אובייקטים נחוצים: directories_collection, files_collection, gcs_bucket, zipfile, io.BytesIO.
מהות: רישום זוג (item, target_user) ב-collection shares לאחר אימות בעלות, אימות שהנמען קיים, ובדיקת כפילות.
תת-יכולות: חיפוש קובץ/תיקייה, אימות בעלות, חיפוש משתמש יעד, בדיקת share קיים, יצירת מסמך share חדש.
אובייקטים נחוצים: shares_collection, users_collection, files_collection/directories_collection.
מהות: במקום מחיקה פיזית, יצירת רשומת 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).
מהות: מחיקה פיזית של פריטים שב-recycle_bin שפג תוקפם — כולל מחיקה מ-GCS. רצה אופורטוניסטית בכניסה ל-endpoints של ה-recycle-bin.
תת-יכולות: שאילתה where('expires_at', '<', now), איטרציה על פריטים, מחיקת ה-blob מ-GCS, מחיקה מ-Firestore.
מהות: חיפוש בקבצים ובתיקיות לפי תת-מחרוזת, עם הצגת הנתיב המלא לכל תוצאה.
תת-יכולות: שליפת כל הקבצים והתיקיות של המשתמש, בניית map של תיקיות, חישוב נתיב רקורסיבי לפי parent_directory_id, סינון בפייתון לפי q in name.lower().
מהות: קבלת רשימת פריטים מעורבת (קבצים ותיקיות), איסוף קבצים רקורסיבית מתוך תיקיות, ויצירת ZIP יחיד.
מהות: קליטת תמונה, אימות סיומת תמונה, מחיקת תמונה קודמת מ-GCS, העלאת תמונה חדשה, עדכון profilePicture במסמך המשתמש.
מהות: ספירת משתמשים, קבצים, תיקיות, שיתופים, וסכום סך נפח אחסון — לצורך תצוגה ב-Dashboard.
מהות: בכל עמוד מוגן (home/profile/admin), ב-DOMContentLoaded הלקוח בודק קיום authToken ב-localStorage; אם חסר, מבצע window.location.replace('/login.html?from=home'). אם קיים, שולח GET /api/auth/verify כדי לוודא תוקף; במקרה של 401 — redirect ל-login.
אובייקטים: localStorage, fetch, window.location.
מהות: בלוק ולידציה לקליינט (blur, input events) לפני שליחת fetch ל-/api/auth/login או /api/auth/signup. מציג שדה-שדה הודעת שגיאה ספציפית עם ARIA live region לנגישות.
מהות: רכיב uploadZone מאזין ל-dragenter/dragover/dragleave/drop, מציג סטטוס visual highlight, ושולח FormData עם הקובץ ו-directory_id ל-POST /api/upload.
מהות: ארבע לשוניות בעמוד הראשי — My Files / Shared With Me / Favorites / Recycle Bin. החלפה דינמית של תוכן ה-section המוצג ו-fetch של הנתונים המתאימים.
מהות: מערך directoryPath שמרכיב breadcrumbs דינמי; לחיצה על breadcrumb מובילה לתיקייה זו ומקצרת את המערך.
מהות: בעת הקלדה בתיבת החיפוש, fetch ל-/api/users/search?q=..., הצגת רשימת תוצאות עם אווטאר ושם מלא, הוספה לרשימת המשותפים בלחיצה.
מהות: checkboxes לכל שורה, "Select All" בכותרת, מערך selectedItems ב-state, סרגל פעולות (Download ZIP / Move / Copy / Delete) שמופיע כשהבחירה אינה ריקה.
מהות: לחיצה על כותרת עמודה מסמנת אותה כמיון פעיל, מציגה חץ למעלה/למטה, ומסדרת את הטבלה לפי הערכים. מיון נתמך בכל ארבע הלשוניות.
מהות: מודול theme.js משותף; כפתור Toggle בכל עמוד; שמירה ב-localStorage תחת המפתח cloudStorageTheme; מצב ברירת מחדל = dark.
מהות: בעמוד profile.html — העלאה / מחיקה / עריכת שם פרטי ושם משפחה. שולח PUT /api/user/profile ו-POST /api/user/profile/picture.
מהות: כרטיסי סטטיסטיקות, טבלת משתמשים, טבלת כל הקבצים, ומחיקת משתמש עם confirm modal. גישה רק עבור משתמש admin.
המערכת מורכבת מרכיבי חומרה שמתקשרים ברשת. הלקוח הראשי – דפדפן רגיל – מתקשר ב-HTTP/HTTPS עם שרת Flask, וזה מתקשר מצידו עם שירותי Google Cloud (Firestore + GCS). בנוסף קיים לקוח שולחני (אפליקציית התראות) המתחבר ב-socket TCP גולמי אל אותו תהליך שרת בפורט נפרד (5001) ומקבל דחיפת התראות בזמן אמת. השרטוט הבא מציג את הקישורים:
תיאור הרכיבים:
clientstorage-6978a.firebasestorage.app.
Browser ──REST (HTTP/HTTPS :5000)──► Flask app ──┐
│ אותו תהליך (same process)
Desktop ──TCP socket (:5001)────────► NotificationHub (socket_server.py)
Desktop ◄──push events────────────── (registry: username → open sockets)
Desktop ──REST download─────────────► Flask app
Flask app ──gRPC/TLS──► Firestore (metadata + wrapped keys)
Flask app ──HTTPS/TLS─► Google Cloud Storage (encrypted blobs)
| שכבה | טכנולוגיה | גרסה | תפקיד |
|---|---|---|---|
| שפת תכנות שרת | Python | 3.11+ | שפת השרת |
| Web framework | Flask | 3.0.0 | שרת REST API ושירות קבצים סטטיים |
| CORS | flask-cors | 4.0.0 | מתן הרשאות Cross-Origin לקריאות מהלקוח |
| אימות | PyJWT | 2.8.0 | חתימה ופענוח של אסימוני JWT באלגוריתם HS256 |
| גיבוב סיסמאות | Werkzeug.security | 3.0.1 | generate_password_hash / check_password_hash (pbkdf2:sha256) |
| הצפנה | cryptography | — | Fernet (AES-128-CBC + HMAC-SHA256) להצפנת קבצים, RSA-2048 / OAEP-SHA256 לעטיפת מפתחות בשיתוף |
| בסיס נתונים | Google Firestore | (via firebase-admin 6.5.0) | NoSQL document store בענן |
| אחסון קבצים | Google Cloud Storage | (via google-cloud-storage 2.14.0) | אחסון אובייקטים (blobs) — מוצפנים בשכבת מעטפה (envelope) של האפליקציה |
| שפת לקוח | HTML5 / CSS3 / ES6 JavaScript | — | ממשק משתמש (ללא framework) |
| לקוח שולחני | CustomTkinter (Python) | 5.2.0+ | אפליקציית התראות שולחנית עם push בזמן אמת |
| תקשורת REST | HTTP/1.1 + Fetch API | — | פרוטוקול REST על-גבי TCP (פורט 5000); HTTPS/TLS נתמך בפיתוח דרך mkcert |
| תקשורת Push | Raw TCP Socket | — | פרוטוקול JSON תחום-שורות מתוצרת עצמית (פורט 5001) לדחיפת התראות לשרת↔לקוח שולחני |
| איחסון לקוחי | localStorage | — | שמירת authToken, username, cloudStorageTheme |
| בקרת גרסאות | Git | — | ניהול קוד והיסטוריה |
תחומי עניין מרכזיים: רשתות (HTTP/REST/TCP, socket גולמי דו-כיווני), מערכות הפעלה (קבצים, תהליכים, threads, ניהול זיכרון בעת ZIP), הגנת סייבר (אימות, הצפנה סימטרית ואסימטרית, ניהול מפתחות, בקרת גישה, JWT), בסיסי נתונים (NoSQL Document Model), ארכיטקטורת שרתים מבוססת בלוקים (Blueprints) ושכבת מודלים (OOP).
החל מהוספת הצפנת המעטפה (envelope encryption), בייט הקובץ אינו נכתב ל-GCS בטקסט גלוי. כל קובץ מקבל מפתח נתונים ייעודי (DEK), והקובץ מוצפן תחתיו; ה-DEK עצמו "נעטף" (מוצפן) תחת מפתח המסטר (KEK) שהאפליקציה מחזיקה, ונשמר על מסמך הקובץ ב-Firestore.
1. Client ──POST multipart + Bearer JWT──► Flask /api/upload
2. Server: dek = generate_dek() # מפתח Fernet אקראי טרי לקובץ זה
3. Server: ciphertext = encrypt_with_dek(file_bytes, dek) # AES-128-CBC + HMAC
4. Server: wrapped_dek = wrap_dek_with_master(dek) # עטיפת ה-DEK תחת ה-KEK
5. Server ──blob.upload_from_string(ciphertext)──► GCS # נשמר רק הצופן
6. Server ──files.add({..., wrapped_dek})──► Firestore # ה-DEK העטוף נשמר במטא-דאטה
7. Server ──200 OK──► Client
בהורדה: unwrap_dek_with_master(wrapped_dek) → decrypt_with_dek(ciphertext, dek)
בעת שיתוף קובץ, ה-DEK של הקובץ נעטף מחדש תחת המפתח הציבורי (RSA) של הנמען ונשמר על מסמך השיתוף. כך רק הנמען — המחזיק במפתח הפרטי המתאים — יכול לחלץ את ה-DEK ולפענח את הקובץ. במקביל נשלחת התראת push דרך ה-socket אם הנמען מחובר בלקוח השולחני.
1. Owner ──POST /api/share {item_id, share_with}──► Flask
2. Server: dek = unwrap_dek_with_master(file.wrapped_dek) # חילוץ ה-DEK תחת ה-KEK
3. Server: share.wrapped_dek = rsa_wrap_dek(dek, recipient.public_key) # עטיפה למפתח הנמען
4. Server ──shares.add({owner, shared_with, wrapped_dek})──► Firestore
5. Server ──notification_hub.push_to_user(recipient, {"type":"shared", ...})──► socket
6. Desktop (אם מחובר): מציג התראה מיידית "שותף איתך קובץ"
בהורדת הקובץ המשותף:
private_pem = unwrap_private_key(recipient.wrapped_private_key) # תחת ה-KEK
dek = rsa_unwrap_dek(share.wrapped_dek, private_pem) # RSA-OAEP
plaintext = decrypt_with_dek(ciphertext, dek)
ניסוח הבעיה: יש לאחסן סיסמאות באופן שלא יאפשר לאף אחד (כולל מנהל המערכת) לשחזר אותן, אך עם זאת לאפשר אימות שהמשתמש הקליד את הסיסמה הנכונה בעת התחברות.
סקירת פתרונות קיימים:
הפתרון הנבחר: pbkdf2:sha256 דרך werkzeug.security.generate_password_hash.
הבחירה נומקה ב-(א) זמינות מובנית בספריית התשתית של Flask – ללא תלות נוספת; (ב) תקן NIST מומלץ; (ג) Salt
אקראי המוטמע אוטומטית בפלט הגיבוב; (ד) פלט הכולל את האלגוריתם, ה-salt וה-iterations באותה מחרוזת — נוח לאחסון.
אלטרנטיבה (bcrypt / Argon2) היתה דורשת התקנת ספרייה נוספת.
מקור: Werkzeug Documentation, Security Helpers.
ניסוח: יש לתת ללקוח אסימון שיוכל להוכיח באמצעותו את זהותו לכל בקשה עתידית, מבלי שהשרת יצטרך לשמור 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) נומקה בכך ששרת בודד מטפל גם בחתימה וגם באימות, ואין צורך באסימטריה.
ניסוח: משתמש לחץ "הורד" על תיקייה שמכילה תתי-תיקיות וקבצים – יש לארוז את כל המבנה לקובץ ZIP יחיד.
פתרונות שנשקלו: (א) הורדה של כל קובץ בנפרד וייצוא ZIP ב-client (קשה לסנכרון, מסורבל);
(ב) שמירת ZIP זמני בדיסק ב-server (צריך לנקות, רגיש לחיבורים מקבילים); (ג) ZIP-in-memory עם
io.BytesIO ו-zipfile.ZipFile ו-send_file.
הפתרון הנבחר: (ג). האלגוריתם:
get_all_files_in_directory(dir_id, base_path) שמרכיבה
רשימת קבצים שטוחה ולכל אחד את ה-relative path המלא.blob.download_as_bytes() ו-zip_file.writestr(arcname, content).send_file(zip_buffer, as_attachment=True).היתרון: אין כתיבה לדיסק, אין concurrency issues, וזמן ההורדה מתחיל מיד עם תחילת ה-stream. החיסרון: שימוש זיכרון פרופורציונלי לגודל ה-ZIP – הגביל אותנו לתיקיות בנות עד מאות MB. בייצור היה ראוי לעבור ל-streaming ZIP אמיתי (zipstream).
מקור: Python zipfile module documentation, Flask send_file.
ניסוח: פריטים בסל המיחזור צריכים להימחק פיזית 30 יום אחרי המחיקה הלוגית. אין Cron Job בענן, אז יש להפעיל את הניקוי באופן אופורטוניסטי.
הפתרון הנבחר: פונקציה cleanup_expired_items(username) שמופעלת
בתחילת כל GET ל-/api/recycle-bin. השאילתה:
where('expires_at', '<', now.isoformat()) מסתמכת על העובדה ש-ISO 8601 mortgage
לקסיקוגרפי תואם לזמן כרונולוגי. עבור כל פריט: מחיקה מ-GCS, מחיקה מ-Firestore.
ניסוח: אסור להעביר תיקייה לתוך תת-תיקייה שלה (לולאה אינסופית בעץ).
הפתרון: פונקציה רקורסיבית is_subdirectory(parent_id, check_id)
שמטפסת מ-check_id בעץ ה-parents עד שמגיעה ל-parent_id (return True) או ל-root (return False).
ניסוח: רוצים להצפין כל קובץ במנוחה תחת מפתח של האפליקציה (מעבר להצפנת ברירת-המחדל של הענן), ולאפשר רוטציה זולה של מפתח המסטר מבלי להצפין מחדש כל קובץ. בנוסף, שיתוף קובץ עם משתמש מסוים צריך לאפשר לאותו נמען בלבד לפענח אותו.
הפתרון הנבחר — שלוש שכבות מפתחות:
FILE_MASTER_KEY
או הקובץ server/.file_master_key, gitignored). זהו מפתח שמצפין מפתחות — לעולם אינו
מצפין בייטים של קבצים ישירות.AES-128-CBC + HMAC-SHA256); ה-DEK עצמו נעטף תחת ה-KEK ונשמר על מסמך הקובץ. רוטציית KEK = עטיפה
מחדש של ה-DEKs הקטנים בלבד, ללא הצפנה מחדש של ה-blobs.RSA-OAEP / SHA-256); המפתח הפרטי (עטוף בעצמו תחת ה-KEK במנוחה) פותח אותו בהורדה.גבול מודל האיום (דיווח כן): ההגנה עומדת מול גניבה של דלי ה-GCS או של בסיס הנתונים בלבד — התוקף רואה צופן בלבד. היא אינה מגינה מפני פריצה למארח ה-API עצמו (שם יושב ה-KEK ויכול לפתוח כל DEK וכל מפתח פרטי). הצפנה End-to-End אמיתית הייתה דורשת החזקת מפתחות פרטיים בדפדפן — דבר ששובר תצוגה מקדימה/חיפוש/ZIP בצד שרת, ולכן הוצא מהיקף הפרויקט במכוון. תמונות פרופיל ו-blobs ישנים (מלפני ההצפנה) מוצפנים/נקראים דרך מסלול המפתח הגלובלי, עם נפילה-לאחור לבייטים גולמיים לתאימות.
מקור: cryptography (Fernet, RSA-OAEP) — pyca/cryptography documentation; NIST SP 800-57 (Key Management).
ניסוח: HTTP/REST אינו מאפשר לשרת לדחוף אירוע ללקוח ללא polling. רצינו ערוץ דו-כיווני שבו השרת יודיע מיידית "שותף איתך קובץ".
הפתרון: שרת socket TCP גולמי (מודול socket של ספריית התקן בלבד) הרץ באותו
תהליך כמו Flask, בפורט 5001. כל הודעה היא אובייקט JSON אחד ב-UTF-8 המסתיים ב-\n; המקבל צובר
בייטים ומפצל על \n (מחלקה _LineReader). ההודעה הראשונה מהלקוח
חייבת להיות {"type":"auth","token":"<jwt>"} — נבדקת דרך אותה לוגיקת
utils/auth.py של ה-REST, כך שאין מערכת התחברות נפרדת. NotificationHub מחזיק
registry של username → set(sockets) (משתמש יכול לפתוח כמה sessions), מוגן ב-Lock; thread נפרד לכל
חיבור. פונקציית route ב-Flask קוראת push_to_user(username, event) שכותב את האירוע לכל socket חי
של אותו משתמש (best-effort: socket שנכשל מנותק).
מקור: Python socket + threading documentation.
requirements.txt).
המערכת משתמשת ב-HTTP/1.1 על TCP כפרוטוקול תקשורת בסיסי. ההודעות הן REST בפורמט JSON
(או multipart/form-data להעלאות קבצים). כל בקשה למסלול מוגן כוללת את הכותרת
Authorization: Bearer <JWT>. תגובות מוחזרות עם status codes מקובלים
(200, 201, 400, 401, 403, 404, 500). הטבלה הבאה מציגה את כל ההודעות שזורמות במערכת:
| שם הודעה | Method + Path | נשלחת מ→אל | שדות בקשה | שדות תגובה |
|---|---|---|---|---|
| הרשמה | POST /api/auth/signup | Client → Server | firstName, lastName, username, password | token, username, message |
| התחברות | POST /api/auth/login | Client → Server | username, password | token, username, message |
| אימות אסימון | GET /api/auth/verify | Client → Server | Header: Bearer JWT | valid (bool), username |
| התנתקות | POST /api/auth/logout | Client → Server | Header: Bearer JWT | message |
| שם הודעה | Method + Path | שדות בקשה | שדות תגובה |
|---|---|---|---|
| פרופיל אישי | GET /api/user/profile | JWT | username, firstName, lastName, profilePicture |
| עדכון פרופיל | PUT /api/user/profile | firstName?, lastName?, profilePicture? | profile object |
| העלאת תמונה | POST /api/user/profile/picture | multipart file | profilePicture (URL) |
| הורדת תמונת פרופיל | GET /api/user/profile/picture/<filename> | filename | image bytes |
| תמונת משתמש אחר | GET /api/users/<username>/profile-picture | username | image bytes |
| שם הודעה | Method + Path | שדות בקשה | שדות תגובה |
|---|---|---|---|
| העלאת קובץ | POST /api/upload | multipart file, directory_id? | filename, stored_filename, directory_id, message |
| רשימת קבצים | GET /api/files?directory_id=... | directory_id? | files[], directories[] |
| חיפוש רקורסיבי | GET /api/files/search?q=... | q | files[], directories[] (עם path מלא) |
| הורדת קובץ | GET /api/files/<id>/download | file_id | file bytes (Content-Disposition: attachment) |
| מחיקה רכה | DELETE /api/files/<id> | file_id | message "moved to recycle bin" |
| שם הודעה | Method + Path | שדות בקשה | שדות תגובה |
|---|---|---|---|
| רשימת תיקיות | GET /api/directories | — | directories[] |
| יצירת תיקייה | POST /api/directories | name, parent_directory_id? | directory object |
| הורדת תיקייה כ-ZIP | GET /api/directories/<id>/download | directory_id | ZIP bytes |
| מחיקת תיקייה | DELETE /api/directories/<id> | directory_id | message |
| שם הודעה | Method + Path | שדות בקשה | שדות תגובה |
|---|---|---|---|
| חיפוש משתמשים | GET /api/users/search?q=... | q (min 2 chars) | users[] (max 10) |
| שיתוף פריט | POST /api/share | item_id, item_type, share_with | share object |
| הסרת שיתוף | DELETE /api/share/<item_id>?username=... | item_id, username | message |
| שותפים נוכחיים | GET /api/share/<item_id>/users | item_id | users[] |
| ששותף איתי | GET /api/shared-with-me | — | files[], directories[] |
| הורדת קובץ משותף | GET /api/shared-files/<id>/download | file_id | file bytes |
| הורדת תיקייה משותפת | GET /api/shared-directories/<id>/download | directory_id | ZIP bytes |
| שם הודעה | 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 Delete | POST /api/bulk/delete | מחיקה רכה מרובת פריטים |
| Bulk Move | POST /api/bulk/move | העברה מרובת פריטים עם בדיקת לולאה |
| Bulk Copy | POST /api/bulk/copy | העתקה רקורסיבית של קבצים ותיקיות ב-GCS |
| Bulk Download ZIP | POST /api/bulk/download | הורדה משולבת של פריטים נבחרים |
| סטטיסטיקות מנהל | GET /api/admin/stats | users / files / storage / shares (admin only) |
| כל המשתמשים | GET /api/admin/users | users[] עם file_count + total_storage (admin) |
| כל הקבצים | GET /api/admin/files | files[] (admin) |
| מחיקת משתמש | DELETE /api/admin/users/<id> | מחיקה מלאה של משתמש וכל נכסיו (admin) |
| Health Check | GET /api/health | healthy / message |
סה"כ ~40 endpoints מפוצלים ל-11 Blueprints.
לצד ה-REST, השרת מפעיל ערוץ שני: socket TCP גולמי בפורט 5001 לדחיפת התראות בזמן אמת ללקוח השולחני.
הפרוטוקול הוא JSON תחום-שורות — כל הודעה היא אובייקט JSON אחד ב-UTF-8 המסתיים ב-\n.
ההודעה הראשונה מהלקוח חייבת לאמת עם JWT (אותו אסימון של הדפדפן). הטבלה מציגה את כל סוגי ההודעות:
| כיוון | הודעה (type) | תוכן / משמעות |
|---|---|---|
| Client → Server | auth | {"type":"auth","token":"<jwt>"} — חייבת להיות ההודעה הראשונה |
| Client → Server | ping | keep-alive / זיהוי ניתוק |
| Server → Client | auth_ok | {"type":"auth_ok","username":"bob"} |
| Server → Client | auth_error | אסימון לא תקף — השרת סוגר את החיבור מיד אחרי |
| Server → Client | pong | תגובה ל-ping |
| Server → Client | shared | פריט שותף איתי: item_name, item_type, item_id, owner, shared_at |
| Server → Client | file_changed | אחד מהקבצים שלי השתנה: filename, directory_id |
תפקיד: נקודת כניסה — מציג כותרת ומפנה אוטומטית ל-login.html.
הצגת כותרת "Cloud Storage" וטקסט "Redirecting to login..." לפני הניתוב האוטומטיתפקיד: איסוף Username + Password, ולידציה בצד לקוח, שליחה ל-POST /api/auth/login, שמירת ה-token והפניה ל-home. בנוסף — קישור לעמוד ההרשמה וכפתור החלפת ערכת נושא.
אלמנטים: שדה username, שדה password, כפתור "Log in" עם מצב loading, הודעות שגיאה inline פר-שדה, error region כללי, קישור "Sign up".
טופס התחברות במצב כהה עם כפתור החלפת ערכת נושא בפינהתפקיד: איסוף First/Last Name, Username, Password ו-Confirm Password, ולידציה (אורך, ייחודיות, מורכבות), שליחה ל-POST /api/auth/signup.
טופס הרשמה עם 5 שדות וולידציה ב-blur לכל שדהתפקיד: המרכז העיקרי של היישום. מציג טבלת קבצים ותיקיות של המשתמש, עם breadcrumbs לניווט בעץ, תיבת חיפוש, כפתורי Upload / Create Directory, מיון עמודות, checkboxes ל-Bulk Actions, וסרגל פעולות Bulk שמופיע מתחת לטבלה כשנבחרו פריטים.
פעולות: העלאה (Drag&Drop או Browse), יצירת תיקייה, כניסה לתיקייה (double-click), הורדה, מחיקה, שיתוף, סימון מועדף, חיפוש, מיון, Bulk Download / Move / Copy / Delete.
טבלת קבצים עם breadcrumbs, סרגל Tabs (My Files / Shared / Favorites / Recycle Bin), וסרגל Bulk Actionsתפקיד: רשימת קבצים ותיקיות שהמשתמש קיבל שיתוף אליהם, עם הצגת בעלים (Owner) לכל פריט.
טבלה עם עמודת Owner (אווטאר + שם)תפקיד: רשימת פריטים שהמשתמש סימן כמועדפים. תומך בחיפוש ומיון.
טבלת מועדפים עם עמודת Source (שלי / שותף אליי)תפקיד: רשימת פריטים שנמחקו, עם עמודת Days Remaining וכפתורי Restore / Empty Recycle Bin.
טבלת פריטים שנמחקו עם עמודת ימים נותריםתפקיד: אזור Drag&Drop גדול, כפתור "Browse File", spinner העלאה, success icon, ושדה בחירת תיקיית יעד.
חלון מודאל לבחירת קובץ עם Drag&Drop וויזואליתפקיד: חיפוש משתמשים בזמן אמת, רשימת משותפים נוכחית, יכולת לבטל שיתוף.
תיבת חיפוש משתמשים + רשימת משותפים עם כפתור Remove לכל אחדתפקיד: Modals לאישור פעולות ובחירת יעדים.
חלון בחירת תיקיית יעד לפעולת העברה (Move) — מבנה דומה משמש גם ל-Copy ול-Bulk Deleteתפקיד: עריכת firstName / lastName, העלאה והסרת תמונת פרופיל, שם משתמש לקריאה בלבד.
טופס פרופיל עם תמונה ושדות עריכהתפקיד: זמין רק ל-admin. ארבעה כרטיסי סטטיסטיקה (Total Users, Total Files, Total Storage, Active Shares), טבלת משתמשים עם File Count + Storage Used + כפתור מחיקה, וטבלת All Files.
4 stat cards למעלה, טבלת משתמשים מתחתבסיס הנתונים הוא Google Firestore – NoSQL מסוג Document Store. כל אוסף (collection) מכיל מסמכים (documents) בעלי מזהה (id) ייחודי שמופק אוטומטית על-ידי Firestore. ה-Schema אינו נאכף ברמת ה-DB, אלא נשמר באופן עקבי ברמת קוד היישום.
| שדה | טיפוס | דוגמה | חובה |
|---|---|---|---|
| username | string | "galco" | כן (מפתח עסקי + Unique) |
| password | string (hashed) | "pbkdf2:sha256:600000$...$..." | כן |
| firstName | string | "Gal" | כן (בהרשמה) |
| lastName | string | "Cohen" | כן (בהרשמה) |
| profilePicture | string | null | "/api/user/profile/picture/profile_galco_20260101_..." | לא |
| public_key | string (PEM) | "-----BEGIN PUBLIC KEY-----..." (RSA-2048) | כן (נוצר בהרשמה / backfill) |
| wrapped_private_key | string | המפתח הפרטי (PEM) עטוף תחת ה-KEK — נשמר מוצפן | כן |
| token_revoked_after | string (ISO8601) | — | "2026-05-20T08:30:00" — נכתב ב-logout; JWT שהונפק לפניו נדחה | לא |
מפתח ראשי: Document ID (אוטומטי). מפתח עסקי: username.
הערת אבטחה: ה-public_key נשמר בטקסט גלוי; המפתח הפרטי לעולם אינו נשמר גלוי —
הוא נעטף תחת מפתח המסטר (KEK) של האפליקציה. ראו פרק "סקירת חולשות ואיומים" — At-Rest Encryption.
| שדה | טיפוס | דוגמה | חובה |
|---|---|---|---|
| filename | string | "report.pdf" | כן |
| stored_filename | string | "20260101_123045_report.pdf" | כן |
| gcs_path | string | "users/galco/files/20260101_123045_report.pdf" | כן |
| uploaded_by | string (username) | "galco" | כן |
| uploaded_at | string (ISO8601) | "2026-05-20T08:30:00.000000" | כן |
| size | number (bytes) | 1024567 | כן |
| content_type | string (MIME) | "application/pdf" | כן |
| directory_id | string | null | "abc123..." או null עבור Root | לא |
| wrapped_dek | string | null | מפתח ההצפנה של הקובץ (DEK) עטוף תחת ה-KEK | לא (חסר ב-blobs מלפני ההצפנה) |
הערה: קולקציה זו מאוחזרת כ-db.collection('files') בכל מודול,
ואינה מיוצאת מ-config.py. השדה wrapped_dek נוסף עם הצפנת המעטפה:
בייטי הקובץ ב-GCS מוצפנים תחת ה-DEK, וה-DEK נשמר כאן עטוף. קובץ ללא השדה (העלאה ישנה) נקרא כבייטים גולמיים בנפילה-לאחור.
| שדה | טיפוס | דוגמה | חובה |
|---|---|---|---|
| name | string | "Documents" | כן |
| created_by | string (username) | "galco" | כן |
| created_at | string (ISO8601) | "2026-05-20T08:30:00.000000" | כן |
| parent_directory_id | string | null | null עבור root, אחרת id של תיקיית הורה | לא |
| שדה | טיפוס | דוגמה |
|---|---|---|
| item_id | string | "file_doc_id_xyz" |
| item_type | string | "file" | "directory" |
| item_name | string | "report.pdf" |
| owner | string | "galco" |
| shared_with | string | "daniela" |
| shared_at | string (ISO8601) | "2026-05-20T08:30:00.000000" |
| wrapped_dek | string (base64) | — | ה-DEK של הקובץ עטוף תחת המפתח הציבורי (RSA) של shared_with |
הערה: wrapped_dek קיים רק בשיתופי קבצים (לא תיקיות) ומאפשר לנמען בלבד
לפענח את הקובץ — נעטף בנפרד עבור כל נמען תחת המפתח הציבורי שלו.
| שדה | טיפוס | דוגמה |
|---|---|---|
| item_id | string | "file_doc_id_xyz" |
| item_type | string | "file" | "directory" |
| item_name | string | "report.pdf" |
| user | string | "galco" |
| favorited_at | string (ISO8601) | "2026-05-20T08:30:00.000000" |
| שדה | טיפוס | דוגמה |
|---|---|---|
| item_id | string | id של הפריט המקורי |
| item_type | string | "file" | "directory" |
| item_name | string | "report.pdf" |
| original_data | object | תוכן המסמך המקורי כדי לאפשר שחזור |
| deleted_by | string (username) | "galco" |
| deleted_at | string (ISO8601) | "2026-05-20T08:30:00.000000" |
| expires_at | string (ISO8601) | "2026-06-19T08:30:00.000000" (deleted_at + 30 ימים) |
| parent_directory_id | string | null | למקרה של מחיקה רקורסיבית של תיקייה |
| path | string | "Documents/Sub" (נתיב מקורי) |
clientstorage-6978a.firebasestorage.appusers/{username}/files/{YYYYMMDD_HHMMSS_}{secure_filename}users/{username}/profile/profile_{username}_{timestamp}_{filename}| קובץ / משתנה | תוכן | תפקיד |
|---|---|---|
server/.secret_key (או env SECRET_KEY) | מחרוזת אקראית 48-byte | סוד החתימה של JWT — נוצר אוטומטית אם חסר |
server/.file_master_key (או env FILE_MASTER_KEY) | מפתח Fernet | מפתח המסטר (KEK) — עוטף כל DEK וכל מפתח פרטי |
שני הקבצים נוספו ל-.gitignore. מחיקת .file_master_key ללא עטיפה
מחדש מתואמת הופכת את כל הקבצים והמפתחות הפרטיים לבלתי-ניתנים לשחזור — מחיקה רק כחלק ממיגרציה מתוכננת.
| מפתח | תוכן | שימוש |
|---|---|---|
| authToken | JWT string | נשלח ככותרת Authorization בכל בקשה מוגנת |
| username | string | תצוגת שם משתמש בכותרת home page |
| cloudStorageTheme | "dark" | "light" | שמירת בחירת ערכת הנושא בין רענונים |
בנוסף: sessionStorage.cameFromHome כדגל זמני בעת חזרה ל-login אחרי 401.
הסעיף הזה מתאר באופן כן ומפורש את מצב האבטחה בקוד הנוכחי (סביבת פיתוח / למידה), ולכל נושא – מה היה ראוי לבצע בסביבת ייצור. אסטרטגיה זו נבחרה במכוון: דיווח אמיתי הוא בעל ערך לימודי גבוה הרבה יותר מהצגת מימוש מושלם.
עדכון (סבב הקשחה): לאחר הגרסה הראשונית בוצע סבב הקשחת אבטחה שסגר חלק מהפערים שתועדו כאן — בין היתר תיקון XSS, סוד JWT אקראי נטען-אוטומטית (ללא fallback קשיח) + שלילת אסימונים, צמצום CORS, הוספת security headers (כולל CSP), הפסקת דליפת פרטי שגיאה, הגבלות DoS על ZIP, הצפנת מעטפה במנוחה, ותמיכת HTTPS בפיתוח. הסעיפים שעודכנו מסומנים "תוקן".
מה הקוד עושה היום: אין שכבת SQL בכלל. ה-DB הוא Firestore (NoSQL). שאילתות מבוצעות
באמצעות builder מתודות (.where('field', '==', value)) שאינן נוצרות מ-concatenation
של מחרוזות. ההגנה היא מבנית.
מקבילה מסוכנת (NoSQL injection): ב-MongoDB עלולה להיות בעיה אם בונים שאילתה מ-dict שמגיע ישירות מהמשתמש. ב-Firestore Python SDK כל ערך עובר type-conversion ל-Firestore native types ולא נוצרת בעיה דומה.
בייצור: שמירה על המודל הקיים; הימנעות מבניית query string דינמיים.
מה היה: רינדור אווטאר משתמש/שיתוף שילב טקסט שמקורו במשתמש (שם משתמש / ראשי תיבות) ישירות
ל-innerHTML, מה שאיפשר XSS מאוחסן (stored XSS).
מה הקוד עושה היום: תוקן — הטקסט שמקורו במשתמש עובר escape לפני הצבה ב-DOM ברינדור האווטארים.
בנוסף, השרת שולח כותרת Content-Security-Policy בכל תגובה
(default-src 'self', frame-ancestors 'none', וכו') כשכבת הגנה שנייה.
בייצור: מעבר עקבי ל-textContent בכל שאר המקומות, צמצום ה-'unsafe-inline'
שעדיין מותר ל-scripts (מעבר ל-CSP nonces), ושימוש ב-template engine.
מה הקוד עושה היום: auth.py:login() אינו מגביל קצב, לא סופר
כישלונות ולא נועל חשבונות. תוקף יכול להריץ ניסיונות אינסופיים.
בייצור: הוספת Rate Limiting (לדוגמה Flask-Limiter), נעילת חשבון
לאחר X כישלונות, CAPTCHA על ניסיון מספר Y, התראות לבעל החשבון.
מה הקוד עושה היום: סיסמאות נשמרות בגיבוב pbkdf2:sha256 עם
Salt אקראי מובנה דרך werkzeug.security.generate_password_hash. בכניסה משתמשים ב-
check_password_hash שמשווה ב-constant-time. זוהי הגנה תקפה.
בייצור: שדרוג ל-Argon2 (Memory-hard), הוספת מדיניות סיסמה (אורך + מורכבות + רוטציה).
מה היה: בהיעדר SECRET_KEY ב-env היה fallback קשיח
'your-secret-key-change-in-production' בקוד — סוד ידוע שאיפשר זיוף JWT.
מה הקוד עושה היום: ה-fallback הקשיח הוסר. _load_or_create_secret_key()
משתמש ב-env SECRET_KEY אם קיים, אחרת מייצר ושומר מפתח אקראי 48-byte ב-server/.secret_key
(gitignored). נוסף גם מנגנון שלילת אסימונים פר-משתמש: logout כותב
token_revoked_after על מסמך המשתמש, וה-decorator דוחה כל JWT שה-iat שלו קודם לחותמת.
בייצור: הגדרה מחייבת של SECRET_KEY דרך Secret Manager (Google / AWS), שימוש ב-Asymmetric (RS256), רוטציה תקופתית.
מה היה: השרת רץ תמיד ב-debug=True על HTTP פשוט; ה-CORS היה פתוח לחלוטין
(origins="*") יחד עם Authorization — צירוף מסוכן.
מה הקוד עושה היום: נתמך HTTPS בפיתוח — סקריפט generate_dev_cert.py
מנפיק תעודה מקומית (mkcert, ובנפילה-לאחור self-signed), וכאשר קיימים server/certs/dev-{cert,key}.pem
השרת מגיש TLS. ה-debug הפך ל-opt-in (FLASK_DEBUG) וברירת המחדל היא האזנה ל-127.0.0.1 בלבד.
ה-CORS צומצם לרשימת היתר של localhost (ניתן לעקיפה דרך CORS_ORIGINS). כותרת
Strict-Transport-Security נשלחת רק על תגובות TLS.
בייצור: אכיפת HTTPS דרך reverse proxy (nginx + Let's Encrypt) במקום תעודת dev, CORS מצומצם ל-domain ספציפי, והצפנה ברמת השדה לנתונים רגישים.
מה היה: פעולות ה-ZIP הרקורסיביות (הורדת תיקייה / Bulk Download) לא היו מוגבלות בכמות — תוקף יכול לבקש ZIP של עשרות-אלפי פריטים ולגרום ל-OOM.
מה הקוד עושה היום: נוספו תקרות MAX_ZIP_FILES=2000 ו-MAX_ZIP_BYTES=512MB
על בניית ה-ZIP (ב-models/directory_item.py, routes/bulk.py ו-sharing.py),
לצד MAX_CONTENT_LENGTH=16MB הקיים על קובץ בודד. עדיין אין rate-limiting כללי על מספר הבקשות.
בייצור: Flask-Limiter, WAF מול השרת (Cloudflare / GCP Armor), Per-Endpoint Quotas, Streaming ZIP אמיתי במקום ZIP-in-memory.
מה הקוד עושה היום: אסימון JWT מאוחסן ב-localStorage (לא ב-cookie),
ולכן אין Auto-Send של ה-token לבקשות cross-origin. הסיכון ל-CSRF מצומצם משמעותית. עם זאת, אין CSRF Token מפורש.
בייצור: אם עוברים ל-HttpOnly Cookie לאחסון JWT, חובה להוסיף CSRF Token. בתצורה הנוכחית הסיכון נמוך.
מה הקוד עושה היום:
secure_filename פעיל — מנקה תווים מסוכנים מהשם.MAX_CONTENT_LENGTH = 16MB אכוף ע"י Flask.{png, jpg, jpeg, gif, webp}.בייצור: שילוב ClamAV או Google Cloud Threat Detection לסריקת קבצים שעולים, allowlist של סוגי קבצים מותרים, חישוב SHA-256 לכל קובץ לזיהוי כפילויות וזיהוי גרסאות, deny-list של סיומות מסוכנות.
מה היה: ה-global error handler החזיר את str(e) ללקוח כאשר
debug=True — והשרת רץ עם debug תמיד, כך ש-stack traces נחשפו.
מה הקוד עושה היום: ה-handler מחזיר תמיד הודעה גנרית ("An internal error occurred")
ורושם את החריגה ל-log בלבד. debug הפך ל-opt-in דרך FLASK_DEBUG (ברירת מחדל: כבוי).
בייצור: ניתוב הודעות שגיאה אמיתיות ל-log מרכזי, החזרת error-id ללקוח למעקב.
מה היה: רק הצפנת ברירת-המחדל של הענן (GCS ו-Firestore ב-AES-256 בניהול Google). תוקף שגונב את הדלי/ה-DB יחד עם גישת ה-API היה רואה טקסט גלוי.
מה הקוד עושה היום: נוספה שכבת הצפנת מעטפה בבעלות האפליקציה מעל הצפנת הענן
(מודול utils/crypto.py): כל קובץ מוצפן תחת DEK ייחודי (Fernet / AES-128-CBC + HMAC),
ה-DEK עטוף תחת מפתח מסטר (KEK) שהאפליקציה מחזיקה. מפתחות פרטיים (RSA) של משתמשים נשמרים עטופים אף הם תחת ה-KEK.
גבול ההגנה: גניבת הדלי או ה-DB בלבד חושפת צופן בלבד; פריצה למארח ה-API (שם יושב ה-KEK) עוקפת זאת.
בייצור: מעבר ל-Customer-Managed Encryption Keys (CMEK) דרך KMS / HSM לאחסון ה-KEK מחוץ למארח, ורוטציה מתוזמנת.
מה הקוד עושה היום: הקובץ cloudstorageproject-privatekey.json
קיים פיזית ברפו של הפרויקט (משמש לפיתוח מקומי). מצב זה אינו מקובל בייצור.
בייצור: מחיקה מההיסטוריה של git, רוטציה של המפתח, אחסון ב-Secret Manager, גישה ל-GCS דרך Workload Identity (Cloud Run / GKE).
נוסף @app.after_request שמצרף לכל תגובה: Content-Security-Policy,
X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Referrer-Policy: no-referrer, Permissions-Policy, ו-HSTS
על תגובות TLS בלבד. ה-CSP עדיין מתיר 'unsafe-inline' ל-scripts בשל stubs מוטמעים — מועמד לצמצום עתידי דרך nonces.
כל endpoint שמחזיר/מערוך נכס בודק בעלות לפי ה-username מה-JWT. בסבב ההקשחה, קולקציית ה-items
(legacy) קיבלה שדה owner וכל ה-CRUD שלה צומצם למשתמש הנוכחי (מניעת IDOR). בנוסף: שחזור מסל המיחזור
כופה בעלות חזרה ל-deleted_by ומחזיר לשורש אם תיקיית ההורה כבר לא קיימת; הורדות משתמשות ב-send_file(download_name=...)
כדי שכותרת Content-Disposition תקודד לפי RFC 5987 (מניעת header injection).
נחסמו שמות-משתמש שמורים (admin/administrator/root
וכו'), נדרשת סיסמה באורך 8+ עם אות וספרה, הודעת שגיאת ההרשמה הפכה גנרית (מניעת user enumeration), ותווים מסוכנים
ל-HTML בשמות נדחים.
הלקוח (דפדפן) פותח חיבור 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.
בסביבת הפיתוח ניתן כעת להפעיל HTTPS: python server/generate_dev_cert.py מנפיק תעודה
(mkcert / self-signed), וכשהיא קיימת השרת מגיש TLS על פורט 5000. החיבור בין Flask ל-Firestore ול-GCS ממילא עובר
HTTPS/TLS אוטומטית דרך firebase-admin ו-google-cloud-storage. ערוץ ה-push (socket TCP פורט 5001) רץ על localhost בלבד.
בייצור: HTTPS חובה דרך reverse proxy עם תעודת CA אמיתית.
| שם המודול | למה משמש |
|---|---|
| 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.BytesIO | buffer זיכרון לקובץ ZIP |
| os / os.path | טיפול במשתני סביבה ובניית paths |
| cryptography.fernet.Fernet | הצפנה סימטרית מאומתת (AES-128-CBC + HMAC) לקבצים ולעטיפת מפתחות |
| cryptography.hazmat ... rsa / padding / serialization | זוגות מפתחות RSA-2048, עטיפת DEK ב-OAEP, סריאליזציית PEM |
| socket / threading | שרת ה-push הגולמי: listening socket, thread לכל חיבור, registry מוגן-Lock |
| customtkinter (לקוח שולחני) | מסגרת ה-UI של אפליקציית ההתראות השולחנית |
תפקיד: אתחול singletons משותפים — Flask app, Firestore client, GCS bucket, וקולקציות.
תכונות (Singletons):
| שם | תפקיד |
|---|---|
| app | Flask application instance |
| db | Firestore client |
| users_collection | collection('users') |
| items_collection | collection('items') – legacy |
| directories_collection | collection('directories') |
| shares_collection | collection('shares') |
| favorites_collection | collection('favorites') |
| recycle_bin_collection | collection('recycle_bin') |
| gcs_bucket | storage.Bucket object |
| GCS_BUCKET_NAME | "clientstorage-6978a.firebasestorage.app" |
תפקיד: מעטפות אימות לכל route מוגן.
| פעולה (Function) | טענת כניסה | טענת יציאה |
|---|---|---|
| token_required(f) | פונקציית route ל-wrap | פונקציה עטופה שמזריקה current_user; מחזירה 401 אם token חסר / לא תקף |
| admin_required(f) | פונקציית route | פונקציה עטופה; 403 אם username != 'admin' |
| פעולה | טענת כניסה (Parameters) | טענת יציאה (Returns) |
|---|---|---|
| signup() | JSON: firstName, lastName, username, password | 201 + {token, username} | 400 שגיאת ולידציה |
| login() | JSON: username, password | 200 + {token, username} | 401 |
| verify_token(current_user) | JWT header | 200 + {valid:true, username} |
| logout(current_user) | JWT header | 200 + {message} |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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) | filename | image bytes |
| get_user_profile_picture(current_user, username) | username | image bytes (למשתמש אחר) |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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_id | file bytes + Content-Disposition |
| delete_file(current_user, file_id) | file_id | {message: "moved to recycle bin"} |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| get_all_directories(current_user) | JWT | {directories[]} |
| create_directory(current_user) | JSON: name, parent_directory_id? | {directory: {...}} |
| download_directory(current_user, directory_id) | directory_id | ZIP bytes |
| delete_directory(current_user, directory_id) | directory_id | soft-delete רקורסיבי עם רישום של כל הילדים |
| get_all_files_in_directory(internal) | dir_id, base_path | (files_list, dirs_list) |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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_id | file bytes |
| download_shared_directory(current_user, directory_id) | directory_id | ZIP bytes |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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 למשותפים) |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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"} |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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 |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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} — מחיקה מלאה |
תפקיד: שירות עמודי HTML/CSS/JS של הלקוח עם כותרות no-cache, ומתן endpoint /api/health.
תפקיד (legacy): CRUD בסיסי לקולקציה items — שריד מהדגמה הראשונית; אינו חלק מהזרם המרכזי של מוצר ה-Cloud Storage. ב-CRUD הוקשח scoping למשתמש הנוכחי דרך שדה owner (מניעת IDOR).
תפקיד: הצפנת מעטפה לבייטי קבצים ב-GCS וחומר המפתחות האסימטרי (RSA) שמאחורי שיתוף קריפטוגרפי.
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| generate_dek() | — | מפתח Fernet טרי (DEK) לקובץ |
| encrypt_with_dek / decrypt_with_dek | bytes, dek | צופן / טקסט גלוי |
| wrap_dek_with_master / unwrap_dek_with_master | dek / wrapped str | עטיפת/חילוץ ה-DEK תחת ה-KEK |
| encrypt_bytes / decrypt_bytes | bytes | מסלול מפתח גלובלי (תמונות פרופיל + blobs ישנים) |
| decrypt_stored_file | ciphertext, wrapped_dek? | מפענח מאוחד עם נפילה-לאחור לבייטים גולמיים |
| generate_rsa_keypair() | — | (private_pem, public_pem) — RSA-2048 |
| wrap_private_key / unwrap_private_key | PEM / wrapped str | עטיפת/חילוץ המפתח הפרטי תחת ה-KEK |
| rsa_wrap_dek / rsa_unwrap_dek | dek + public_pem / wrapped + private_pem | עטיפת DEK ל-RSA-OAEP של נמען / חילוצו |
תפקיד: שרת socket TCP גולמי (פורט 5001) לדחיפת אירועים בזמן אמת. רץ באותו תהליך כמו Flask;
ה-singleton notification_hub משותף בין ה-socket threads לבין ה-route handlers.
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| start(host, port=5001) | host/port | —; bind+listen ו-accept loop על daemon thread |
| register / unregister(username, conn) | username, socket | —; עדכון registry מוגן-Lock |
| push_to_user(username, event) | username, dict | —; שולח JSON לכל socket חי של המשתמש (best-effort) |
| _handle_client(conn, addr) | חיבור נכנס | —; אימות-תחילה (JWT) ואז לולאת ping/pong |
תפקיד: מחלקות שמכמסות את הלוגיקה והנתונים של הישויות, וממחישות את ארבעת עמודי ה-OOP. שכבה זו גם מרכזת את לוגיקת ההצפנה (encrypt-on-create / decrypt-on-read).
| מחלקה / קובץ | תפקיד עיקרי |
|---|---|
StorageItem (storage_item.py) | מחלקת בסיס מופשטת — שדות ופעולות משותפים לקובץ/תיקייה (בעלות, מטא-דאטה, to_dict) |
FileItem (file_item.py) | קובץ: create() מצפין תחת DEK ועוטף תחת KEK; download() מפענח; move() ו-copy() (copy שומר על אותו wrapped_dek) |
DirectoryItem (directory_item.py) | תיקייה: מעבר רקורסיבי, בניית ZIP עם פענוח לכל קובץ ותקרות DoS |
User (user.py) | משתמש: יצירה כולל זוג מפתחות RSA (פרטי עטוף תחת KEK), ו-backfill למשתמשים ותיקים |
לקוח שני, נפרד מהדפדפן, שמדגים תקשורת socket גולמית דו-כיוונית. ההורדות עדיין מתבצעות דרך ה-REST API.
| קובץ | תפקיד |
|---|---|
app.py | ה-UI ונקודת הכניסה: מסך login, header עם status pill, feed התראות חי, רשימת "שותף איתי", toasts, החלפת theme |
components.py | רכיבי UI לשימוש חוזר: Header, StatusPill, NotificationFeed/Card, SharedList, ToastManager, Tooltip — כל אחד עם apply_theme |
theme.py | פלטות dark/light (מראה זהה ל-home.css) ובחירת פונט (Inter → Segoe UI) |
preferences.py | קריאה/כתיבה של %APPDATA%/CloudStorage/preferences.json (בחירת theme) |
socket_client.py | NotificationClient: פותח את ה-socket, מאמת, מקבל אירועים ב-thread רקע לתור thread-safe |
api_client.py | ApiClient: קריאות REST (login, shared-with-me, download) דרך urllib |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| initTheme() | — | —; קורא את localStorage ומאתחל data-theme |
| setTheme(theme) | 'dark'/'light' | —; שומר ב-localStorage + מעדכן UI |
| toggleTheme() | — | —; מחליף בין dark ל-light |
| updateThemeToggle(theme) | theme | —; מחליף את אייקון השמש/ירח |
| window.getCurrentTheme() | — | string (dark|light) |
פונקציות עיקריות: verifyToken(token), setupFormValidation(),
validateField(input,err,msg), showFieldError,
clearFieldError, clearAllErrors(), ומאזין submit לטופס.
פונקציות: setupFormValidation(), validateName / validateUsername / validatePassword / validatePasswordMatch,
מאזיני input/blur/submit, ושליחה ל-/api/auth/signup.
פונקציות עיקריות לפי תחום:
verifyToken, redirectToLogin, getAuthToken.setupUploadModal, setupDragAndDrop, setupFileInput, handleFileSelect, uploadFile, showUploadStatus, showUploadError, hideUploadError.loadUserFiles, renderFilesTable, formatFileSize, formatDate.setupDirectoryCreation, createDirectory, enterDirectory, renderBreadcrumbs.setupShareModal, openShareDialog, searchUsersForShare, shareWithUser, unshareWithUser, loadCurrentSharedUsers.toggleFavorite, loadFavorites, renderFavoritesTable.setupRecycleBin, loadRecycleBin, restoreFromBin, emptyRecycleBin.setupSearch, performSearch (לכל לשונית).setupTableSorting, sortTable.setupBulkOperations, toggleItemSelection, updateBulkToolbar, bulkDownload, bulkMove, bulkCopy, bulkDelete.setupTabs, switchTab.profile.js: loadProfile, uploadProfilePicture, removeProfilePicture, saveProfile. admin.js: loadStats, loadUsers, loadAllFiles, deleteUser.
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
הקטע מציג את לב פונקציית 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)
אלגוריתם רקורסיבי שאוסף את כל הקבצים תוך תתי-תיקיות, מוריד את הבייטים מ-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")
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
@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
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
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; } });
לב FileItem.create(): לכל קובץ נוצר DEK טרי, הבייטים מוצפנים תחתיו, וה-DEK נעטף תחת מפתח המסטר
ונשמר במטא-דאטה. ל-GCS נכתב רק הצופן (עם content-type גנרי כדי לא לחשוף את צורת הקובץ).
# Envelope encryption: per-file DEK, bytes under DEK, DEK wrapped under master KEK. dek = generate_dek() ciphertext = encrypt_with_dek(file_content, dek) wrapped_dek = wrap_dek_with_master(dek) gcs_bucket.blob(gcs_path).upload_from_string( ciphertext, content_type='application/octet-stream' ) item = cls( doc_id=None, owner=username, filename=file_storage.filename, stored_filename=stored_filename, gcs_path=gcs_path, size=file_size, content_type=content_type, directory_id=directory_id, uploaded_at=datetime.utcnow().isoformat(), wrapped_dek=wrapped_dek, ) ref = files_collection.add(item.to_dict())
ה-NotificationHub שומר registry של username → sockets.
push_to_user כותב את האירוע ב-JSON תחום-שורה לכל socket חי (best-effort); הטיפול בלקוח דורש
הודעת auth ראשונה לפני כל דבר אחר.
def push_to_user(self, username, event): line = (json.dumps(event) + "\n").encode("utf-8") with self._lock: conns = list(self._connections.get(username, ())) for conn in conns: try: conn.sendall(line) except OSError: self.unregister(username, conn) # socket מת — מנותק def _handle_client(self, conn, addr): first = _LineReader(conn).readline() msg = _parse(first) token = msg.get("token") if msg and msg.get("type") == "auth" else None user = resolve_user_from_token(token) if token else None if user is None: _send(conn, {"type": "auth_error", "error": "Invalid token"}) return self.register(user["username"], conn) _send(conn, {"type": "auth_ok", "username": user["username"]})
הבדיקות נערכו ידנית בדפדפן Chrome ובכלי Postman עבור endpoints ספציפיים. הטבלאות הבאות מציגות 28 בדיקות מרכזיות שבוצעו במהלך הפיתוח.
| # | מטרת הבדיקה | מה בוצע בפועל | תוצאה | בעיות שהתגלו וכיצד נפתרו |
|---|---|---|---|---|
| 1 | הרשמה תקינה | הרשמה עם firstName="Test", lastName="User", username="testuser1", password="123456" | 201 Created + JWT הוחזר + הפניה ל-home.html | — |
| 2 | הרשמה כפולה | הרשמה חוזרת עם username="testuser1" | 400 Bad Request + הודעה "Username already exists" | — |
| 3 | סיסמה קצרה מ-6 תווים | הרשמה עם password="123" | נחסם בצד לקוח (validation) + 400 בצד שרת כ-defense in depth | — |
| 4 | התחברות תקינה | username + password נכונים | 200 OK + JWT + redirect | — |
| 5 | התחברות שגויה | סיסמה לא נכונה | 401 Unauthorized + הודעה "Invalid username or password" | — |
| 6 | אסימון JWT שפג | שינוי ידני של exp לעבר ושליחה ל-/api/files | 401 + הודעה "Token has expired" | — |
| 7 | גישה לעמוד ללא token | גישה ידנית ל-/home.html אחרי localStorage.clear() | redirect אוטומטי ל-/login.html?from=home | בעיית redirect-loop אינסופי שהתגלתה בתחילה — נפתרה ב-commit 7c6e442 על ידי הוספת בדיקת currentPath בכל DOMContentLoaded |
| 8 | העלאת קובץ תקין | העלאת PDF בגודל 2MB ל-root | 200 OK, הקובץ הופיע בטבלה | — |
| 9 | העלאת קובץ בשם זהה | העלאה שנייה של אותו שם קובץ באותה תיקייה | 400 + הודעה "A file named '...' already exists" | בתחילה ההודעה הוצגה כ-raw response — נפתר ב-commit b6e4cda עם error banner מעוצב במודאל |
| 10 | קובץ גדול מ-16MB | ניסיון העלאת קובץ של 20MB | Flask חוסם אוטומטית עם 413 Request Entity Too Large | — |
| 11 | יצירת תיקייה | POST /api/directories עם name="Documents" | 201 Created, התיקייה הופיעה בטבלה | — |
| 12 | תיקייה כפולה באותה רמה | POST שני עם אותו name + parent | 400 + הודעה "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 נכונה | — |
| 24 | Bulk Download | בחירת 3 קבצים ו-2 תיקיות, לחיצה Download ZIP | ZIP אחד עם כל המבנה הוחזר | — |
| 25 | Bulk Move עם לולאה | ניסיון להעביר תיקיית הורה לתוך הבת שלה | שגיאה "Cannot move directory into its own subdirectory" | — |
| 26 | העלאת תמונת פרופיל | העלאת PNG דרך עמוד פרופיל | התמונה הופיעה, התמונה הקודמת נמחקה אוטומטית מ-GCS | — |
| 27 | גישה ל-Admin ללא הרשאה | פתיחה ידנית של /admin.html עם משתמש רגיל | 403 + redirect ל-home + הסתרת admin button בכותרת home | — |
| 28 | מחיקת משתמש דרך פאנל Admin | כניסה עם admin, לחיצה Delete על משתמש "testuser1" | המשתמש נמחק, כל הקבצים ב-GCS נמחקו, השיתופים והמועדפים שלו נמחקו, הוא לא הופיע יותר ברשימה | — |
| # | מטרה | בוצע | תוצאה | בעיות וכיצד נפתרו |
|---|---|---|---|---|
| A1 | תאימות דפדפנים | בדיקה ב-Chrome, Edge, Firefox | תקין בכולם | — |
| A2 | החלפת ערכת נושא | לחיצה על כפתור Theme Toggle בכל עמוד | החלפה מיידית; שמירה ב-localStorage; שיחזור ברענון | — |
| A3 | מירכוז מודלים בכל הגדלי מסך | שינוי גודל חלון, פתיחת מודלים שונים | נמצאו בעיות עם הרקע + הצמדה — נפתר ב-commit 2be6774 על ידי הוצאת המודלים מה-container הראשי + flexbox centering | תיקון חיוני לחווית משתמש |
| A4 | גישה ישירה ל-URL פנימי | פתיחה של /home.html כשמשתמש לא מחובר | הופיע redirect-loop בתחילה — נפתר ב-commit 7c6e442 ע"י הוספת בדיקת currentPath ושימוש ב-window.location.replace | תיקון קריטי |
| A5 | קובץ ב-Firestore שלא קיים ב-GCS | מחיקה ידנית של blob מ-GCS console, רענון מהקליינט | הקובץ נעלם אוטומטית גם מ-Firestore | תקלה גלויה — האפליקציה מתחזקת את עצמה |
| A6 | מיון עמודות | לחיצה על כותרת עמודה במצבי asc/desc | סדר הטבלה משתנה, חצים מציגים כיוון | — |
| A7 | חיפוש עם שדה ריק | ניקוי תיבת החיפוש | במצב מקורי — לא הוצגו קבצים. נפתר ב-commit 9a4407b: כעת מוצגים כל הקבצים בעת שדה ריק | תיקון UX |
| A8 | קצב בקשות | שליחה של 100 בקשות חיפוש מ-script | השרת מטפל בכולן ללא קריסה אך מאוד איטי — כיוון לעיתיד: rate limiting | — |
| A9 | סטטיסטיקות מנהל | פתיחת admin dashboard עם 5 משתמשים ו-30 קבצים | הסטטיסטיקות תאמו את התוכן בפועל | — |
| A10 | טבלת מועדפים — סנכרון | סימון/ביטול מועדף ב-tab אחר וחזרה ל-Favorites | מסונכרן אחרי refresh | — |
CloudStorage/ ├── client/ # קוד צד-לקוח (Vanilla HTML/CSS/JS) │ ├── theme.js # מודול ערכת נושא משותף (Dark/Light) │ ├── index/ # עמוד פתיחה │ │ ├── index.html │ │ ├── index.css │ │ └── index.js │ ├── login/ # עמוד התחברות │ │ ├── login.html │ │ ├── login.css │ │ └── login.js │ ├── signup/ # עמוד הרשמה │ │ ├── signup.html │ │ ├── signup.css │ │ └── signup.js │ ├── home/ # דף הבית (Tabs, Modals, ניהול קבצים) │ │ ├── home.html │ │ ├── home.css │ │ └── home.js │ ├── profile/ # עמוד פרופיל אישי │ │ ├── profile.html │ │ ├── profile.css │ │ └── profile.js │ ├── admin/ # פאנל מנהל │ │ ├── admin.html │ │ ├── admin.css │ │ └── admin.js │ ├── index.html # stub redirect → /index/index.html │ ├── login.html # stub redirect │ ├── signup.html # stub redirect │ ├── home.html # stub redirect │ └── profile.html # stub redirect │ ├── server/ # קוד צד-שרת (Flask + Python) │ ├── app.py # נקודת כניסה — blueprints + הפעלת socket server │ ├── config.py # Singletons (Flask app, Firestore, GCS, SECRET_KEY) │ ├── socket_server.py # NotificationHub — socket TCP גולמי לדחיפת push (:5001) │ ├── generate_dev_cert.py # הנפקת תעודת HTTPS מקומית (mkcert / self-signed) │ ├── seed_db.py # סקריפט אתחול חד-פעמי של משתמש admin │ ├── cloudstorageproject-privatekey.json # Service Account Key (gitignored) │ ├── .secret_key # סוד JWT אקראי (gitignored, נוצר אוטומטית) │ ├── .file_master_key # מפתח המסטר KEK (gitignored, נוצר אוטומטית) │ ├── certs/ # dev-cert.pem / dev-key.pem ל-HTTPS (אם הונפקו) │ ├── utils/ │ │ ├── __init__.py │ │ ├── auth.py # @token_required / @admin_required / שלילת אסימונים │ │ └── crypto.py # הצפנת מעטפה (DEK/KEK) + RSA לשיתוף │ ├── models/ # שכבת מודלים (OOP) │ │ ├── __init__.py │ │ ├── storage_item.py # מחלקת בסיס מופשטת │ │ ├── file_item.py # FileItem — create/download/move/copy + הצפנה │ │ ├── directory_item.py # DirectoryItem — מעבר רקורסיבי + ZIP │ │ └── user.py # User — כולל זוג מפתחות RSA │ └── routes/ # 11 Blueprints │ ├── __init__.py # all_blueprints list │ ├── auth.py # /api/auth/* │ ├── user.py # /api/user/*, /api/users/* │ ├── files.py # /api/upload, /api/files, /api/files/search, ... │ ├── directories.py # /api/directories/* │ ├── sharing.py # /api/share, /api/shared-with-me, ... │ ├── favorites.py # /api/favorites/* │ ├── recycle_bin.py # /api/recycle-bin/* │ ├── bulk.py # /api/bulk/* │ ├── admin.py # /api/admin/* (admin-only) │ ├── static_files.py # שירות HTML/CSS/JS │ └── items.py # /api/items (legacy) │ ├── desktop/ # לקוח שולחני (CustomTkinter) — התראות push │ ├── app.py # UI ונקודת כניסה │ ├── components.py # רכיבי UI לשימוש חוזר │ ├── theme.py # פלטות dark/light │ ├── preferences.py # שמירת העדפות (theme) ב-APPDATA │ ├── socket_client.py # NotificationClient — חיבור ה-socket │ ├── api_client.py # ApiClient — קריאות REST │ └── README.md │ ├── requirements.txt # Python dependencies (כולל cryptography, customtkinter) ├── start_all.bat # הפעלת השרת + פתיחת דפדפן ├── start_server.bat # רק הפעלת שרת ├── start_server.ps1 # גרסת PowerShell ├── start_server_debug.bat # הפעלה עם FLASK_DEBUG=1 ├── start_desktop.bat # הפעלת הלקוח השולחני ├── check_setup.bat # אבחון התקנה ├── README.md # תיעוד בסיסי (מיושן — ראו CLAUDE.md) └── PROJECT_BOOK.html # תיק הפרויקט הזה
server/cloudstorageproject-privatekey.json.| כלי | גרסה מינימלית | תפקיד |
|---|---|---|
| Python | 3.7 | הרצת השרת |
| pip | 20+ | התקנת תלויות |
| Git | 2.30+ | שכפול הרפו (אופציונלי) |
| דפדפן | Chrome 100+ / Edge 100+ / Firefox 95+ | הרצת הלקוח |
server/cloudstorageproject-privatekey.json. ללא הקובץ — config.py ייכשל בעת import.server/uploads/ בעת הפעלה ראשונה.git clone <repo-url> CloudStoragecd CloudStoragepip install -r requirements.txtpython server/seed_db.py.\start_all.bat (פותח אוטומטית דפדפן) או python server/app.py (רק שרת).
השרת מאזין על פורט 5000 (REST) ו-5001 (socket התראות).http://localhost:5000/python server/generate_dev_cert.py מנפיק תעודה מקומית
(mkcert, ובנפילה-לאחור self-signed) ל-server/certs/; בהרצה הבאה השרת יגיש https://localhost:5000/.python desktop/app.py או .\start_desktop.bat,
ולאחר התחברות מתקבלות התראות שיתוף בזמן אמת.| משתנה | ברירת מחדל | תפקיד |
|---|---|---|
FLASK_DEBUG | כבוי | הפעלת debug mode (opt-in) |
FLASK_HOST | 127.0.0.1 | כתובת האזנה של Flask |
CORS_ORIGINS | localhost:5000/8000 | רשימת מקורות CORS מותרים (מופרדים בפסיק) |
SECRET_KEY | נוצר ב-.secret_key | סוד חתימת JWT |
FILE_MASTER_KEY | נוצר ב-.file_master_key | מפתח המסטר (KEK) להצפנה |
seed_db.py:adminadmin
משתמשים נוספים נוצרים דרך עמוד ההרשמה ב-/signup.html.
5000 חופשי עבור שרת Flask.5001 חופשי עבור שרת ה-socket (דחיפת התראות ללקוח השולחני).8000 חופשי אם משתמשים ב-start_all.bat (Python HTTP server עבור הלקוח — אופציונלי).*.googleapis.com מותרת.| משאב | מינימום | מומלץ |
|---|---|---|
| RAM | 2GB | 4GB+ |
| דיסק | 500MB פנוי (לתלויות Python) | 1GB+ |
| CPU | Dual Core 1.5GHz | Quad Core 2.5GHz+ |
| חיבור | 1Mbps | 5Mbps+ |
http://localhost:5000/.
סדרת שלושת המסכים שמשתמש חדש עובר: התחברות → הרשמה → דף הביתhttp://localhost:5000/.admin ו-Password = admin.
4 stat cards + טבלת משתמשים + טבלת קבציםpython server/app.py.python desktop/app.py (או .\start_desktop.bat).פרויקט ה-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.
REST הוא סגנון ארכיטקטוני לבניית שירותי רשת. הוא משתמש ב-HTTP methods (GET, POST, PUT, DELETE) כדי
לבצע פעולות (קריאה, יצירה, עדכון, מחיקה) על משאבים (Resources) המזוהים ב-URI. כל בקשה היא Stateless —
השרת לא שומר זיכרון בין בקשות, מה שמקל על Scale Out. הפרויקט מממש את העיקרון של "המשאבים מזוהים על-ידי
URI" — לדוגמה /api/files/{id} מייצג קובץ ספציפי, ופעולות עליו (GET = הורדה,
DELETE = מחיקה) מבוצעות באותו URI.
JWT הוא תקן (RFC 7519) לאסימוני אימות חתומים בפורמט JSON. כל אסימון מורכב משלושה חלקים מופרדים בנקודות:
Header (מתאר את אלגוריתם החתימה), Payload (התוכן — claims, למשל
username ו-exp), ו-Signature (חתימה קריפטוגרפית). בפרויקט אנו משתמשים באלגוריתם
HMAC-SHA256 (HS256), כאשר השרת חותם את ה-payload עם SECRET_KEY, והלקוח שולח את
ה-Token בכותרת Authorization: Bearer .... השרת מאמת את החתימה ופותח את
ה-payload בכל בקשה מוגנת.
שלא כמו בסיסי נתונים רלציוניים (MySQL, PostgreSQL) שמאחסנים נתונים בטבלאות עם schema קבוע, Firestore הוא Document Store: מבנה הנתונים הוא collections (אוספים) שמכילים documents (מסמכים). כל מסמך הוא בעצם JSON object גמיש. ייתרון: גמישות בהתפתחות הסכימה. חיסרון: חוסר ב-JOIN, צורך לבנות שאילתות באופן מתחשב במבנה הנתונים. Firestore גם מבטיח עדכוני real-time ו-strong consistency באזור אחד.
ב-Object Storage כל קובץ הוא object בעל שם ייחודי (key) בתוך bucket.
אין מבנה תיקיות פיזי — תיקיות הן רק חלק משם ה-key (לדוגמה users/galco/files/file.pdf).
התכונות העיקריות: redundancy גבוה (אחסון משוכפל גלובלית), מחיר נמוך לבית, hardware abstraction מלא,
תמיכה בקבצים גדולים מאוד, וביצועים מצטיינים בקריאה/כתיבה. גישה דרך REST API או client libraries.
מנגנון של דפדפנים מודרניים שמונע מ-JavaScript בעמוד מ-origin אחד (לדוגמה localhost:8000)
לבצע fetch ל-origin אחר (לדוגמה localhost:5000), אלא אם השרת המקבל מצהיר במפורש
בכותרות HTTP על אישור. בפרויקט הוגדר CORS(app, origins="*") שמאשר את כל ה-origins
— מספיק לפיתוח, אך בייצור יש לצמצם לכתובות ספציפיות.
פרוטוקול 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).
"""
Cloud Storage Server - Main Entry Point
This is the slimmed-down entry point that imports and registers all route blueprints.
"""
import os
from flask import jsonify, request
from werkzeug.exceptions import HTTPException
from config import app
from routes import all_blueprints
@app.errorhandler(Exception)
def handle_exception(e):
"""Handle all uncaught exceptions without leaking internals."""
if isinstance(e, HTTPException):
return e
app.logger.exception("Unhandled exception")
return jsonify({"error": "An internal error occurred"}), 500
@app.after_request
def add_security_headers(response):
"""Apply defensive HTTP headers to every response."""
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'DENY')
response.headers.setdefault('Referrer-Policy', 'no-referrer')
response.headers.setdefault('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
response.headers.setdefault(
'Content-Security-Policy',
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"frame-ancestors 'none'; "
"base-uri 'self'; "
"form-action 'self'"
)
# HSTS only on TLS responses.
if request.is_secure:
response.headers.setdefault(
'Strict-Transport-Security', 'max-age=31536000; includeSubDomains'
)
return response
# Register all blueprints
for blueprint in all_blueprints:
app.register_blueprint(blueprint)
if __name__ == '__main__':
# Debug mode is now opt-in via FLASK_DEBUG=1; bind to localhost by default.
debug_mode = os.environ.get('FLASK_DEBUG', '').lower() in ('1', 'true', 'yes')
host = os.environ.get('FLASK_HOST', '127.0.0.1')
# TLS is opt-in: drop dev-cert.pem / dev-key.pem in server/certs and the
# server serves HTTPS; with no certs present it runs plain HTTP.
cert_path = os.path.join(os.path.dirname(__file__), 'certs', 'dev-cert.pem')
key_path = os.path.join(os.path.dirname(__file__), 'certs', 'dev-key.pem')
ssl_context = None
scheme = 'http'
if os.path.exists(cert_path) and os.path.exists(key_path):
ssl_context = (cert_path, key_path)
scheme = 'https'
# Start the raw-TCP notification socket server alongside Flask (port 5001).
# Gate on WERKZEUG_RUN_MAIN so the debug reloader doesn't bind twice.
if not debug_mode or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
from socket_server import notification_hub
socket_host = os.environ.get('SOCKET_HOST', host)
notification_hub.start(host=socket_host, port=5001)
print(f"Notification socket server listening on {socket_host}:5001")
print("Starting Flask server...")
print(f"Server running at {scheme}://{host}:5000 (debug={debug_mode})")
app.run(debug=debug_mode, host=host, port=5000, ssl_context=ssl_context)
"""
Flask application configuration and initialization.
Contains Flask app, Firebase Admin SDK, and Google Cloud Storage setup.
"""
from flask import Flask
from flask_cors import CORS
import firebase_admin
from firebase_admin import credentials, firestore
from google.cloud import storage
import os
import secrets
def _load_or_create_secret_key():
"""Use SECRET_KEY env var if set; otherwise persist a random 32-byte key
to server/.secret_key (which is .gitignored)."""
env_value = os.environ.get('SECRET_KEY')
if env_value:
return env_value
key_file = os.path.join(os.path.dirname(__file__), '.secret_key')
if os.path.exists(key_file):
with open(key_file, 'r', encoding='utf-8') as fh:
value = fh.read().strip()
if value:
return value
value = secrets.token_urlsafe(48)
with open(key_file, 'w', encoding='utf-8') as fh:
fh.write(value)
return value
# Create Flask app
app = Flask(__name__, static_folder='../client', static_url_path='')
app.config['SECRET_KEY'] = _load_or_create_secret_key()
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), 'uploads')
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
# Create uploads directory if it doesn't exist
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# CORS: dev defaults to localhost only. Override with CORS_ORIGINS env var
# (comma-separated). Wildcard `*` is no longer the default.
_cors_origins_env = os.environ.get('CORS_ORIGINS', '').strip()
if _cors_origins_env:
_cors_origins = [o.strip() for o in _cors_origins_env.split(',') if o.strip()]
else:
_cors_origins = [
'http://localhost:5000',
'http://localhost:8000',
'http://127.0.0.1:5000',
'http://127.0.0.1:8000',
]
CORS(app,
origins=_cors_origins,
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
supports_credentials=True)
# Initialize Firebase Admin SDK
cred_path = os.path.join(os.path.dirname(__file__), 'cloudstorageproject-privatekey.json')
cred = credentials.Certificate(cred_path)
firebase_admin.initialize_app(cred)
# Get Firestore database
db = firestore.client()
users_collection = db.collection('users')
items_collection = db.collection('items')
directories_collection = db.collection('directories')
shares_collection = db.collection('shares')
favorites_collection = db.collection('favorites')
recycle_bin_collection = db.collection('recycle_bin')
# Initialize Google Cloud Storage
GCS_BUCKET_NAME = 'clientstorage-6978a.firebasestorage.app'
storage_client = storage.Client.from_service_account_json(cred_path)
gcs_bucket = storage_client.bucket(GCS_BUCKET_NAME)
"""
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")
# Utils package
"""
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
# 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,
]
"""
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
"""
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
"""
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)
"""
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
"""
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
"""
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")
"""
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
"""
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
"""
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
"""
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
בשל אורך הקובץ, להלן תקציר של חתימות כל הפונקציות. את הקוד המלא ניתן למצוא ברפו (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='')
// 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;
};
})();
<!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>
// Redirect to login page
window.addEventListener('DOMContentLoaded', () => {
window.location.href = '/login.html';
});
// 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';
}
});
// 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
}
});
בשל אורך הקובץ (קובץ הליבה של דף הבית), להלן תקציר מבני. כל הפונקציות מתועדות. המבנה לפי תחום:
// 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();
});
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();
}
// Admin Dashboard JavaScript
const API_BASE = 'http://localhost:5000/api';
document.addEventListener('DOMContentLoaded', function() { checkAdminAccess(); });
function checkAdminAccess() {
const token = localStorage.getItem('authToken');
const username = localStorage.getItem('username');
if (!token) { redirectToLogin(); return; }
if (username !== 'admin') {
alert('Access denied. Admin privileges required.');
window.location.href = '/home.html'; return;
}
initDashboard();
}
function initDashboard() {
loadStats(); loadUsers();
setupTabs(); setupLogout(); setupSearch(); setupDeleteModal();
}
async function loadStats() {
const response = await fetch(`${API_BASE}/admin/stats`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` }
});
if (response.status === 403) {
window.location.href = '/home.html'; return;
}
const data = await response.json();
animateValue('totalUsers', data.total_users);
animateValue('totalFiles', data.total_files);
animateValue('totalStorage', formatSize(data.total_storage), true);
animateValue('totalShares', data.total_shares);
}
function animateValue(elementId, value, isString = false) {
const element = document.getElementById(elementId);
if (isString) { element.textContent = value; return; }
const duration = 1000, start = 0, end = parseInt(value);
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
const current = Math.floor(start + (end - start) * easeOut);
element.textContent = current.toLocaleString();
if (progress < 1) requestAnimationFrame(update);
}
requestAnimationFrame(update);
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024, sizes = ['B','KB','MB','GB','TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
let allUsers = [], allFiles = [];
async function loadUsers() {
const response = await fetch(`${API_BASE}/admin/users`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` }
});
const data = await response.json();
allUsers = data.users;
renderUsers(allUsers);
}
function renderUsers(users) {
const tbody = document.getElementById('usersTableBody');
if (users.length === 0) { tbody.innerHTML = '<tr><td colspan="5">No users found</td></tr>'; return; }
// ... generates user rows with avatars, file_count, storage, delete button
}
async function loadFiles() {
const response = await fetch(`${API_BASE}/admin/files`, {
headers: { 'Authorization': `Bearer ${getAuthToken()}` }
});
const data = await response.json();
allFiles = data.files;
renderFiles(allFiles);
}
function setupTabs() { /* users vs files */ }
function setupLogout() { /* clear + redirect */ }
function setupSearch() { /* live filter in JS */ }
let userToDelete = null;
function confirmDeleteUser(userId, username) {
userToDelete = { id: userId, username };
document.getElementById('deleteUserMessage').textContent = `Are you sure you want to delete user "${username}"?`;
document.getElementById('deleteUserModal').style.display = 'block';
}
function setupDeleteModal() {
document.getElementById('confirmDeleteUserBtn').addEventListener('click', async function() {
if (!userToDelete) return;
const response = await fetch(`${API_BASE}/admin/users/${userToDelete.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${getAuthToken()}` }
});
if (response.ok) { closeDeleteModal(); loadStats(); loadUsers(); allFiles = []; }
});
}
הקבצים הבאים נוספו / הורחבו בסבב הפיתוח השני: שכבת הצפנת המעטפה, שרת ה-push על socket גולמי,
בוטסטרפ HTTPS, שכבת המודלים (OOP) שמרכזת את ההצפנה, והלקוח השולחני. שני קבצי ה-UI הגדולים של הלקוח השולחני
(app.py, components.py) מובאים כתקציר מבני, בעקבות אותה מוסכמה שננקטה
ל-home.js ו-bulk.py.
"""
Envelope encryption for file bytes stored in Google Cloud Storage, plus the
asymmetric (RSA) key material that backs cryptographic file sharing.
GCS already encrypts at rest with Google-managed keys (AES-256). This layer
adds a second envelope under keys the application owns, so an attacker who
exfiltrates the GCS bucket (or the Firestore database) without also
compromising the API host sees only ciphertext.
There are three kinds of key here:
* Master KEK -- one symmetric Fernet key the app owns (FILE_MASTER_KEY env
var, or server/.file_master_key). It wraps per-file DEKs
and each user's RSA private key; never file bytes directly.
* Per-file DEK -- a fresh symmetric Fernet key per uploaded file. File bytes
are encrypted under the DEK; the DEK is wrapped under the
master KEK and stored on the Firestore doc.
* RSA keypair -- one per user. The public key wraps a file's DEK when shared
with that user; the private key (wrapped under the KEK)
unwraps it on download.
Threat model boundary: this does NOT protect against compromise of the API
host -- the master KEK lives there. It defends against theft of the storage
bucket or the database alone.
Symmetric algorithm: Fernet (AES-128-CBC + HMAC-SHA256, authenticated).
Asymmetric algorithm: RSA-2048 with OAEP(SHA-256) padding.
"""
import os
import base64
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
def _load_or_create_master_key():
"""Use FILE_MASTER_KEY env var if set; otherwise persist a random Fernet
key to server/.file_master_key (gitignored). This is the master KEK."""
env_value = os.environ.get('FILE_MASTER_KEY')
if env_value:
return env_value.encode('utf-8')
key_file = os.path.join(os.path.dirname(__file__), '..', '.file_master_key')
key_file = os.path.abspath(key_file)
if os.path.exists(key_file):
with open(key_file, 'rb') as fh:
value = fh.read().strip()
if value:
return value
value = Fernet.generate_key() # 32 random bytes, url-safe base64
with open(key_file, 'wb') as fh:
fh.write(value)
return value
_fernet = Fernet(_load_or_create_master_key())
# --- Global-key helpers (legacy file blobs + profile pictures) ---
def encrypt_bytes(plaintext: bytes) -> bytes:
"""Encrypt arbitrary bytes directly under the master key (profile pics)."""
return _fernet.encrypt(plaintext)
def decrypt_bytes(ciphertext: bytes) -> bytes:
"""Decrypt bytes previously produced by encrypt_bytes."""
return _fernet.decrypt(ciphertext)
# --- Per-file envelope encryption (symmetric) ---
def generate_dek() -> bytes:
"""Generate a fresh per-file Data Encryption Key (a Fernet key)."""
return Fernet.generate_key()
def encrypt_with_dek(plaintext: bytes, dek: bytes) -> bytes:
return Fernet(dek).encrypt(plaintext)
def decrypt_with_dek(ciphertext: bytes, dek: bytes) -> bytes:
return Fernet(dek).decrypt(ciphertext)
def wrap_dek_with_master(dek: bytes) -> str:
"""Wrap (encrypt) a DEK under the master KEK. Returns a str for Firestore."""
return _fernet.encrypt(dek).decode('utf-8')
def unwrap_dek_with_master(wrapped_dek: str) -> bytes:
return _fernet.decrypt(wrapped_dek.encode('utf-8'))
def decrypt_stored_file(ciphertext: bytes, wrapped_dek=None) -> bytes:
"""Unified decryptor for file blobs fetched from GCS. If wrapped_dek is
present, unwrap it with the master KEK and decrypt; otherwise try the
global-key path, and finally fall back to returning the bytes as-is for
blobs uploaded before any encryption was added."""
if wrapped_dek:
dek = unwrap_dek_with_master(wrapped_dek)
return decrypt_with_dek(ciphertext, dek)
try:
return decrypt_bytes(ciphertext)
except InvalidToken:
return ciphertext # pre-encryption upload: raw bytes
# --- Per-user RSA keypairs (asymmetric): power cryptographic file sharing ---
def generate_rsa_keypair():
"""Generate an RSA-2048 keypair. Returns (private_pem, public_pem) bytes."""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
return private_pem, public_pem
def wrap_private_key(private_pem: bytes) -> str:
"""Wrap a private-key PEM under the master KEK for storage. Returns a str."""
return _fernet.encrypt(private_pem).decode('utf-8')
def unwrap_private_key(wrapped_private_key: str) -> bytes:
return _fernet.decrypt(wrapped_private_key.encode('utf-8'))
def rsa_wrap_dek(dek: bytes, public_pem) -> str:
"""RSA-OAEP-encrypt a DEK under a recipient's public key. Returns base64 str."""
if isinstance(public_pem, str):
public_pem = public_pem.encode('utf-8')
public_key = serialization.load_pem_public_key(public_pem)
ciphertext = public_key.encrypt(
dek,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return base64.b64encode(ciphertext).decode('utf-8')
def rsa_unwrap_dek(wrapped_b64: str, private_pem) -> bytes:
"""Reverse rsa_wrap_dek using the recipient's private-key PEM. Returns DEK bytes."""
if isinstance(private_pem, str):
private_pem = private_pem.encode('utf-8')
private_key = serialization.load_pem_private_key(private_pem, password=None)
return private_key.decrypt(
base64.b64decode(wrapped_b64),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
"""
Raw TCP socket server for real-time push notifications.
Separate from Flask: it listens on its own port (default 5001) and speaks a tiny
hand-rolled application protocol -- newline-delimited JSON, one object per line --
over a plain TCP socket. It lets the server PUSH events to a connected desktop
client without polling, which HTTP/REST cannot do cleanly.
Protocol (each message is one UTF-8 JSON object terminated by '\\n'):
Client -> Server : {"type": "auth", "token": "<jwt>"} (must be first)
Client -> Server : {"type": "ping"}
Server -> Client : {"type": "auth_ok", "username": "bob"}
Server -> Client : {"type": "auth_error", "error": "..."} then close
Server -> Client : {"type": "pong"}
Server -> Client : {"type": "shared", ...}
Server -> Client : {"type": "file_changed", ...}
The hub runs inside the same process as Flask; the module-level
`notification_hub` singleton is shared between socket threads and route handlers.
"""
import json
import socket
import threading
from utils.auth import resolve_user_from_token
class NotificationHub:
"""Owns the listening socket and the registry of authenticated connections,
and fans events out to them."""
def __init__(self):
# username -> set of connected client sockets
self._connections = {}
self._lock = threading.Lock()
self._server_socket = None
def register(self, username, conn):
with self._lock:
self._connections.setdefault(username, set()).add(conn)
def unregister(self, username, conn):
with self._lock:
conns = self._connections.get(username)
if conns:
conns.discard(conn)
if not conns:
del self._connections[username]
def push_to_user(self, username, event):
"""Send one JSON event to every live socket for `username` (best-effort)."""
line = (json.dumps(event) + "\n").encode("utf-8")
with self._lock:
conns = list(self._connections.get(username, ()))
dead = []
for conn in conns:
try:
conn.sendall(line)
except OSError:
dead.append(conn)
for conn in dead:
self.unregister(username, conn)
try:
conn.close()
except OSError:
pass
def start(self, host="127.0.0.1", port=5001):
"""Bind, listen, and spawn the accept loop on a daemon thread."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(16)
self._server_socket = srv
threading.Thread(target=self._accept_loop, name="socket-accept-loop",
daemon=True).start()
def _accept_loop(self):
while True:
try:
conn, addr = self._server_socket.accept()
except OSError:
break
threading.Thread(target=self._handle_client, args=(conn, addr),
name=f"socket-client-{addr}", daemon=True).start()
def _handle_client(self, conn, addr):
username = None
reader = _LineReader(conn)
try:
first = reader.readline()
msg = _parse(first)
token = msg.get("token") if msg and msg.get("type") == "auth" else None
user = resolve_user_from_token(token) if token else None
if user is None:
_send(conn, {"type": "auth_error", "error": "Invalid token"})
return
username = user["username"]
self.register(username, conn)
_send(conn, {"type": "auth_ok", "username": username})
print(f"[socket] {username} connected from {addr}")
while True:
line = reader.readline()
if not line:
break
msg = _parse(line)
if msg and msg.get("type") == "ping":
_send(conn, {"type": "pong"})
except OSError:
pass
finally:
if username:
self.unregister(username, conn)
try:
conn.close()
except OSError:
pass
class _LineReader:
"""Buffers bytes off a socket and yields one '\\n'-terminated line at a time."""
def __init__(self, conn):
self._conn = conn
self._buf = b""
def readline(self):
while b"\n" not in self._buf:
chunk = self._conn.recv(4096)
if not chunk:
line, self._buf = self._buf, b""
return line.decode("utf-8", "replace") if line else ""
self._buf += chunk
line, _, self._buf = self._buf.partition(b"\n")
return line.decode("utf-8", "replace")
def _parse(line):
if not line:
return None
try:
obj = json.loads(line)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def _send(conn, event):
conn.sendall((json.dumps(event) + "\n").encode("utf-8"))
# Module-level singleton shared by socket threads and Flask route handlers.
notification_hub = NotificationHub()
"""
Mint a TLS certificate for local development.
Preferred path: use `mkcert` so the browser trusts the cert. If mkcert is
missing, try to install it via winget on Windows. If that also fails, fall back
to a self-signed cert (browsers will warn). Idempotent: exits silently if both
server/certs/dev-cert.pem and dev-key.pem already exist. LOCAL DEV ONLY.
"""
import os
import datetime
import ipaddress
import shutil
import subprocess
import sys
CERTS_DIR = os.path.join(os.path.dirname(__file__), 'certs')
CERT_PATH = os.path.join(CERTS_DIR, 'dev-cert.pem')
KEY_PATH = os.path.join(CERTS_DIR, 'dev-key.pem')
def _which_mkcert():
return shutil.which('mkcert')
def _try_install_mkcert():
"""Best-effort install of mkcert via winget on Windows. Returns path or None."""
if sys.platform != 'win32':
return None
if not shutil.which('winget'):
return None
print('mkcert not found. Attempting `winget install FiloSottile.mkcert`...')
try:
subprocess.run(
['winget', 'install', '--id', 'FiloSottile.mkcert',
'--silent', '--accept-source-agreements', '--accept-package-agreements'],
check=True,
)
except subprocess.CalledProcessError as e:
print(f'winget install failed (exit {e.returncode}). Skipping mkcert.')
return None
candidates = [
shutil.which('mkcert'),
os.path.expandvars(r'%LOCALAPPDATA%\Microsoft\WinGet\Links\mkcert.exe'),
]
for path in candidates:
if path and os.path.exists(path):
return path
return None
def _generate_with_mkcert(mkcert_path):
"""Use mkcert. Returns True on success, False to trigger fallback."""
os.makedirs(CERTS_DIR, exist_ok=True)
try:
subprocess.run([mkcert_path, '-install'], check=True)
except subprocess.CalledProcessError as e:
print(f'mkcert -install failed (exit {e.returncode}). Falling back to self-signed.')
return False
try:
subprocess.run(
[mkcert_path, '-cert-file', CERT_PATH, '-key-file', KEY_PATH,
'localhost', '127.0.0.1', '::1'],
check=True,
)
except subprocess.CalledProcessError as e:
print(f'mkcert cert generation failed (exit {e.returncode}). Falling back to self-signed.')
return False
return True
def _generate_self_signed():
"""Fallback: self-signed cert. Browsers will warn."""
from cryptography import x509
from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
os.makedirs(CERTS_DIR, exist_ok=True)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name([
x509.NameAttribute(NameOID.COUNTRY_NAME, 'IL'),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'CloudStorage Local Dev'),
x509.NameAttribute(NameOID.COMMON_NAME, 'localhost'),
])
now = datetime.datetime.now(datetime.timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=365))
.add_extension(
x509.SubjectAlternativeName([
x509.DNSName('localhost'),
x509.IPAddress(ipaddress.IPv4Address('127.0.0.1')),
x509.IPAddress(ipaddress.IPv6Address('::1')),
]),
critical=False,
)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(private_key=key, algorithm=hashes.SHA256())
)
with open(CERT_PATH, 'wb') as fh:
fh.write(cert.public_bytes(serialization.Encoding.PEM))
with open(KEY_PATH, 'wb') as fh:
fh.write(key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
))
def generate():
if os.path.exists(CERT_PATH) and os.path.exists(KEY_PATH):
return False, 'existing'
mkcert = _which_mkcert() or _try_install_mkcert()
if mkcert and _generate_with_mkcert(mkcert):
return True, 'mkcert'
print('Using self-signed cert. Browsers will show a "Not secure" warning.')
_generate_self_signed()
return True, 'self-signed'
if __name__ == '__main__':
created, mode = generate()
if not created:
print('Dev cert already present, skipping.')
elif mode == 'mkcert':
print(f'Generated browser-trusted cert via mkcert: {CERT_PATH}')
else:
print(f'Generated self-signed cert: {CERT_PATH}')
"""
Abstract base class for anything stored in the cloud (files and directories).
StorageItem declares the interface without implementing the type-specific bits.
FileItem and DirectoryItem inherit from it and provide their own implementation
of the abstract methods, so route handlers can operate on list[StorageItem] and
call the same methods on every element (polymorphism).
"""
from abc import ABC, abstractmethod
from datetime import datetime, timedelta
from config import recycle_bin_collection
## Abstraction -- StorageItem is an Abstract Base Class; it cannot be
## instantiated directly. Concrete subclasses must implement every
## @abstractmethod or Python refuses to instantiate them.
class StorageItem(ABC):
"""Common contract for File and Directory items."""
def __init__(self, doc_id, owner, parent_directory_id=None, created_at=None):
## Encapsulation -- shared private state behind public properties.
self._id = doc_id
self._owner = owner
self._parent_directory_id = parent_directory_id
self._created_at = created_at or datetime.utcnow().isoformat()
@property
def id(self):
return self._id
@property
def owner(self):
return self._owner
@property
def parent_directory_id(self):
return self._parent_directory_id
@property
def created_at(self):
return self._created_at
# --- abstract API: subclasses MUST implement these ---
@abstractmethod
def item_type(self):
"""Return 'file' or 'directory'."""
@abstractmethod
def display_name(self):
"""Human-readable name."""
@abstractmethod
def to_dict(self):
"""Return the dict shape Firestore expects for this item."""
@abstractmethod
def _collection_ref(self):
"""Return the Firestore CollectionReference for this item."""
@abstractmethod
def permanently_delete(self):
"""Hard-delete from GCS + Firestore."""
@abstractmethod
def restore_to_firestore(self):
"""Re-create the Firestore document from this in-memory item."""
# --- shared logic ---
def check_ownership(self, username):
return self._owner == username
## Polymorphism -- soft_delete is defined ONCE here; it calls item_type(),
## display_name(), to_dict(), _collection_ref() which dispatch to whichever
## subclass self actually is.
def soft_delete(self, parent_directory_id_override=None, path=''):
"""Move this item into the recycle bin (30-day retention)."""
expiration_date = datetime.utcnow() + timedelta(days=30)
recycle_bin_data = {
'item_id': self._id,
'item_type': self.item_type(),
'item_name': self.display_name(),
'original_data': self.to_dict(),
'deleted_by': self._owner,
'deleted_at': datetime.utcnow().isoformat(),
'expires_at': expiration_date.isoformat(),
'parent_directory_id': parent_directory_id_override
if parent_directory_id_override is not None else None,
'path': path,
}
ref = recycle_bin_collection.add(recycle_bin_data)
self._collection_ref().document(self._id).delete()
return ref[1].id
# --- factory ---
@classmethod
def from_request_item(cls, item):
"""Build a concrete subclass from a {'id':..., 'type':...} payload."""
from .file_item import FileItem
from .directory_item import DirectoryItem
item_id = item.get('id')
item_type = item.get('type')
if item_type == 'file':
return FileItem.load(item_id)
if item_type == 'directory':
return DirectoryItem.load(item_id)
return None
@classmethod
def from_recycle_bin_doc(cls, doc):
"""Build a concrete StorageItem from a recycle_bin DocumentSnapshot."""
from .file_item import FileItem
from .directory_item import DirectoryItem
data = doc.to_dict() or {}
item_type = data.get('item_type')
item_id = data.get('item_id')
original = data.get('original_data', {}) or {}
if item_type == 'file':
return FileItem.from_firestore_dict(item_id, original)
if item_type == 'directory':
return DirectoryItem.from_firestore_dict(item_id, original)
return None
"""
FileItem: concrete StorageItem for uploaded files.
"""
from datetime import datetime
from werkzeug.utils import secure_filename
from config import db, gcs_bucket
from utils.crypto import (
generate_dek, encrypt_with_dek, wrap_dek_with_master, decrypt_stored_file,
)
from .storage_item import StorageItem
## Inheritance -- FileItem specialises StorageItem (inherits soft_delete,
## check_ownership, and the factory classmethods).
class FileItem(StorageItem):
"""An uploaded file. GCS stores the bytes; Firestore stores metadata."""
def __init__(self, doc_id, owner, filename, stored_filename, gcs_path,
size, content_type, directory_id=None, uploaded_at=None,
wrapped_dek=None):
super().__init__(doc_id, owner, parent_directory_id=directory_id,
created_at=uploaded_at)
self._filename = filename
self._stored_filename = stored_filename
self._gcs_path = gcs_path
self._size = size
self._content_type = content_type
# Per-file DEK wrapped under the master KEK (envelope encryption). None
# for legacy blobs encrypted directly under the global key.
self._wrapped_dek = wrapped_dek
@property
def filename(self):
return self._filename
@property
def stored_filename(self):
return self._stored_filename
@property
def gcs_path(self):
return self._gcs_path
@property
def size(self):
return self._size
@property
def content_type(self):
return self._content_type
@property
def wrapped_dek(self):
return self._wrapped_dek
# --- abstract implementations (Polymorphism) ---
def item_type(self):
return 'file'
def display_name(self):
return self._filename
def to_dict(self):
return {
'filename': self._filename,
'stored_filename': self._stored_filename,
'gcs_path': self._gcs_path,
'uploaded_by': self._owner,
'uploaded_at': self._created_at,
'size': self._size,
'content_type': self._content_type,
'directory_id': self._parent_directory_id,
'wrapped_dek': self._wrapped_dek,
}
def _collection_ref(self):
return db.collection('files')
def permanently_delete(self):
"""Delete the GCS blob and remove the Firestore file document."""
if self._gcs_path:
blob = gcs_bucket.blob(self._gcs_path)
if blob.exists():
blob.delete()
db.collection('files').document(self._id).delete()
def restore_to_firestore(self):
db.collection('files').document(self._id).set(self.to_dict())
# --- file helpers ---
def exists_in_gcs(self):
if not self._gcs_path:
return False
return gcs_bucket.blob(self._gcs_path).exists()
def download_bytes(self):
"""Download from GCS and decrypt (per-file DEK, legacy global-key fallback)."""
ciphertext = gcs_bucket.blob(self._gcs_path).download_as_bytes()
return decrypt_stored_file(ciphertext, self._wrapped_dek)
# --- move / copy ---
def move(self, destination_directory_id):
"""Move into destination_directory_id (None = root). Raises ValueError on
duplicate name there."""
files_collection = db.collection('files')
for doc in files_collection.where('uploaded_by', '==', self._owner).get():
existing = doc.to_dict() or {}
if (doc.id != self._id and
existing.get('filename') == self._filename and
existing.get('directory_id') == destination_directory_id):
raise ValueError(
f"File '{self._filename}' already exists in destination"
)
files_collection.document(self._id).update({'directory_id': destination_directory_id})
self._parent_directory_id = destination_directory_id
def copy(self, destination_directory_id):
"""Duplicate into destination_directory_id, auto-naming on collision.
Returns the new FileItem."""
if not self._gcs_path:
raise ValueError("Source file has no GCS path")
source_blob = gcs_bucket.blob(self._gcs_path)
if not source_blob.exists():
raise ValueError("Source file missing from storage")
files_collection = db.collection('files')
existing_files = list(files_collection.where('uploaded_by', '==', self._owner).get())
new_filename = f"Copy of {self._filename}"
copy_num = 1
while any(
(d.to_dict() or {}).get('filename') == new_filename and
(d.to_dict() or {}).get('directory_id') == destination_directory_id
for d in existing_files
):
copy_num += 1
new_filename = f"Copy ({copy_num}) of {self._filename}"
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
new_stored_filename = timestamp + secure_filename(new_filename)
new_gcs_path = self._build_gcs_path(self._owner, new_stored_filename)
# copy_blob duplicates the ciphertext as-is; the copy reuses the same
# wrapped_dek -- no decrypt/re-encrypt needed.
gcs_bucket.copy_blob(source_blob, gcs_bucket, new_gcs_path)
new_item = FileItem(
doc_id=None, owner=self._owner, filename=new_filename,
stored_filename=new_stored_filename, gcs_path=new_gcs_path,
size=self._size, content_type=self._content_type,
directory_id=destination_directory_id,
uploaded_at=datetime.utcnow().isoformat(),
wrapped_dek=self._wrapped_dek,
)
ref = files_collection.add(new_item.to_dict())
new_item._id = ref[1].id
return new_item
# --- classmethods ---
@classmethod
def from_firestore_dict(cls, doc_id, data):
return cls(
doc_id=doc_id,
owner=data.get('uploaded_by', ''),
filename=data.get('filename', ''),
stored_filename=data.get('stored_filename', ''),
gcs_path=data.get('gcs_path', ''),
size=data.get('size', 0),
content_type=data.get('content_type', 'application/octet-stream'),
directory_id=data.get('directory_id'),
uploaded_at=data.get('uploaded_at'),
wrapped_dek=data.get('wrapped_dek'),
)
@classmethod
def load(cls, file_id):
doc = db.collection('files').document(file_id).get()
if not doc.exists:
return None
return cls.from_firestore_dict(doc.id, doc.to_dict() or {})
@classmethod
def _build_gcs_path(cls, username, stored_filename):
return f"users/{username}/files/{stored_filename}"
@classmethod
def upload(cls, current_user, file_storage, directory_id):
"""Upload to GCS and persist metadata. Raises ValueError on duplicate name."""
username = current_user['username']
files_collection = db.collection('files')
for doc in files_collection.where('uploaded_by', '==', username).get():
existing = doc.to_dict() or {}
if (existing.get('filename') == file_storage.filename and
existing.get('directory_id') == directory_id):
raise ValueError(
f"A file named '{file_storage.filename}' already exists in this location"
)
filename = secure_filename(file_storage.filename)
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S_')
stored_filename = timestamp + filename
gcs_path = cls._build_gcs_path(username, stored_filename)
file_content = file_storage.read()
file_size = len(file_content)
content_type = file_storage.content_type or 'application/octet-stream'
# Envelope encryption: per-file DEK, bytes under DEK, DEK wrapped under KEK.
dek = generate_dek()
ciphertext = encrypt_with_dek(file_content, dek)
wrapped_dek = wrap_dek_with_master(dek)
gcs_bucket.blob(gcs_path).upload_from_string(
ciphertext, content_type='application/octet-stream'
)
item = cls(
doc_id=None, owner=username, filename=file_storage.filename,
stored_filename=stored_filename, gcs_path=gcs_path, size=file_size,
content_type=content_type, directory_id=directory_id,
uploaded_at=datetime.utcnow().isoformat(), wrapped_dek=wrapped_dek,
)
ref = files_collection.add(item.to_dict())
item._id = ref[1].id
return item
"""
DirectoryItem: concrete StorageItem for folders.
"""
from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed
import zipfile
import io
from config import db, directories_collection, gcs_bucket
from utils.crypto import decrypt_stored_file
from .storage_item import StorageItem
# Defensive caps on recursive zip downloads to prevent memory exhaustion / DoS.
MAX_ZIP_FILES = 2000
MAX_ZIP_BYTES = 512 * 1024 * 1024 # 512 MB uncompressed
class ZipLimitExceeded(ValueError):
"""Raised when a directory's zip would exceed configured caps."""
class DirectoryItem(StorageItem):
"""A folder. Firestore-only metadata; GCS has no folder concept here."""
def __init__(self, doc_id, owner, name, parent_directory_id=None, created_at=None):
super().__init__(doc_id, owner, parent_directory_id=parent_directory_id,
created_at=created_at)
self._name = name
@property
def name(self):
return self._name
# --- abstract implementations (Polymorphism) ---
def item_type(self):
return 'directory'
def display_name(self):
return self._name
def to_dict(self):
return {
'name': self._name,
'created_by': self._owner,
'created_at': self._created_at,
'parent_directory_id': self._parent_directory_id,
}
def _collection_ref(self):
return directories_collection
def permanently_delete(self):
"""Hard-delete all child files (GCS + Firestore) and subdirs, then this dir."""
from .file_item import FileItem
files_collection = db.collection('files')
## Multi-threading -- child blob+doc deletes are I/O; fan out across a pool.
child_files = []
for doc in files_collection.where('uploaded_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('directory_id') == self._id:
child_files.append(FileItem.from_firestore_dict(doc.id, data))
if child_files:
with ThreadPoolExecutor(max_workers=8) as pool:
for future in as_completed([pool.submit(c.permanently_delete) for c in child_files]):
future.result()
for doc in directories_collection.where('created_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('parent_directory_id') == self._id:
child = DirectoryItem.from_firestore_dict(doc.id, data)
child.permanently_delete() # recursive
directories_collection.document(self._id).delete()
def restore_to_firestore(self):
directories_collection.document(self._id).set(self.to_dict())
# --- soft_delete override: recursive child deletion ---
def soft_delete(self, parent_directory_id_override=None, path=''):
from .file_item import FileItem
files_collection = db.collection('files')
self._soft_delete_descendants(self._id, path or self._name, files_collection)
super().soft_delete(parent_directory_id_override=parent_directory_id_override, path=path)
def _soft_delete_descendants(self, dir_id, dir_path, files_collection):
from .file_item import FileItem
for doc in files_collection.where('uploaded_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('directory_id') == dir_id:
child = FileItem.from_firestore_dict(doc.id, data)
child.soft_delete(parent_directory_id_override=self._id, path=dir_path)
for doc in directories_collection.where('created_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('parent_directory_id') == dir_id:
subdir_name = data.get('name', '')
subdir_path = f"{dir_path}/{subdir_name}"
self._soft_delete_descendants(doc.id, subdir_path, files_collection)
child = DirectoryItem.from_firestore_dict(doc.id, data)
StorageItem.soft_delete(child, parent_directory_id_override=self._id,
path=subdir_path)
# --- move / copy ---
def move(self, destination_directory_id):
"""Move under destination_directory_id (None = root)."""
if destination_directory_id == self._id:
raise ValueError("Cannot move directory into itself")
if destination_directory_id and self._would_create_cycle(destination_directory_id):
raise ValueError("Cannot move directory into its own subdirectory")
for doc in directories_collection.where('created_by', '==', self._owner).get():
existing = doc.to_dict() or {}
if (doc.id != self._id and
existing.get('name') == self._name and
existing.get('parent_directory_id') == destination_directory_id):
raise ValueError(
f"Directory '{self._name}' already exists in destination"
)
directories_collection.document(self._id).update(
{'parent_directory_id': destination_directory_id}
)
self._parent_directory_id = destination_directory_id
def _would_create_cycle(self, candidate_parent_id):
"""True if candidate is self or a descendant of self."""
current = candidate_parent_id
while current:
if current == self._id:
return True
doc = directories_collection.document(current).get()
if not doc.exists:
return False
current = (doc.to_dict() or {}).get('parent_directory_id')
return False
def copy(self, destination_parent_id):
"""Duplicate this directory and its entire contents. Returns new DirectoryItem."""
from .file_item import FileItem
existing_dirs = list(directories_collection.where('created_by', '==', self._owner).get())
new_name = f"Copy of {self._name}"
copy_num = 1
while any(
(d.to_dict() or {}).get('name') == new_name and
(d.to_dict() or {}).get('parent_directory_id') == destination_parent_id
for d in existing_dirs
):
copy_num += 1
new_name = f"Copy ({copy_num}) of {self._name}"
new_dir = DirectoryItem.create(
owner=self._owner, name=new_name, parent_directory_id=destination_parent_id,
)
self._copy_contents_into(self._id, new_dir.id)
return new_dir
def _copy_contents_into(self, source_dir_id, target_dir_id):
from .file_item import FileItem
files_collection = db.collection('files')
child_files = []
for doc in files_collection.where('uploaded_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('directory_id') == source_dir_id:
child_files.append(FileItem.from_firestore_dict(doc.id, data))
if child_files:
with ThreadPoolExecutor(max_workers=8) as pool:
for future in as_completed(
[pool.submit(c.copy, target_dir_id) for c in child_files]
):
future.result()
for doc in directories_collection.where('created_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('parent_directory_id') == source_dir_id:
subdir_name = data.get('name', '')
new_sub = DirectoryItem.create(
owner=self._owner, name=subdir_name, parent_directory_id=target_dir_id,
)
self._copy_contents_into(doc.id, new_sub.id)
# --- directory helpers ---
def zip_recursively(self):
"""Return a BytesIO ZIP archive of all files in this directory tree."""
files_collection = db.collection('files')
def collect_files(dir_id, base_path):
result = []
for doc in files_collection.where('uploaded_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('directory_id') == dir_id:
fname = data.get('filename', '')
relative = f"{base_path}{fname}" if base_path else fname
result.append({
'gcs_path': data.get('gcs_path', ''),
'relative': relative,
'wrapped_dek': data.get('wrapped_dek'),
})
for doc in directories_collection.where('created_by', '==', self._owner).get():
data = doc.to_dict() or {}
if data.get('parent_directory_id') == dir_id:
sub = f"{base_path}{data.get('name', '')}/"
result.extend(collect_files(doc.id, sub))
return result
all_files = collect_files(self._id, '')
if len(all_files) > MAX_ZIP_FILES:
raise ZipLimitExceeded(
f"Directory exceeds maximum file count for zip download ({MAX_ZIP_FILES})"
)
## Multi-threading -- GCS downloads are I/O-bound; the ZIP write stays on
## the main thread (zipfile is NOT thread-safe).
def fetch_blob(file_info):
if not file_info['gcs_path']:
return None
blob = gcs_bucket.blob(file_info['gcs_path'])
if not blob.exists():
return None
return (file_info['relative'],
decrypt_stored_file(blob.download_as_bytes(), file_info.get('wrapped_dek')))
zip_buffer = io.BytesIO()
files_added = 0
total_bytes = 0
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(fetch_blob, f) for f in all_files]
for future in as_completed(futures):
result = future.result()
if result is None:
continue
relative, data = result
total_bytes += len(data)
if total_bytes > MAX_ZIP_BYTES:
raise ZipLimitExceeded(
f"Directory exceeds maximum size for zip download ({MAX_ZIP_BYTES} bytes)"
)
zf.writestr(relative, data)
files_added += 1
if files_added == 0:
raise ValueError("Directory is empty")
zip_buffer.seek(0)
return zip_buffer
# --- classmethods ---
@classmethod
def from_firestore_dict(cls, doc_id, data):
return cls(
doc_id=doc_id,
owner=data.get('created_by', ''),
name=data.get('name', ''),
parent_directory_id=data.get('parent_directory_id'),
created_at=data.get('created_at'),
)
@classmethod
def load(cls, dir_id):
doc = directories_collection.document(dir_id).get()
if not doc.exists:
return None
return cls.from_firestore_dict(doc.id, doc.to_dict() or {})
@classmethod
def create(cls, owner, name, parent_directory_id=None):
"""Create a new directory. Raises ValueError on duplicate name."""
for doc in directories_collection.where('created_by', '==', owner).get():
data = doc.to_dict() or {}
if (data.get('name') == name and
data.get('parent_directory_id') == parent_directory_id):
raise ValueError("Directory with this name already exists")
item = cls(
doc_id=None, owner=owner, name=name,
parent_directory_id=parent_directory_id,
created_at=datetime.utcnow().isoformat(),
)
ref = directories_collection.add(item.to_dict())
item._id = ref[1].id
return item
"""
User domain model. Encapsulates account data and authentication. AdminUser
inherits from User and overrides is_admin() to demonstrate polymorphism.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from werkzeug.security import check_password_hash, generate_password_hash
from config import db, users_collection, gcs_bucket, directories_collection, shares_collection, favorites_collection
from utils.crypto import generate_rsa_keypair, wrap_private_key
class User:
"""A regular application user. Wraps a Firestore `users` document."""
def __init__(self, username, password_hash, first_name='', last_name='',
profile_picture=None, doc_id=None,
public_key=None, wrapped_private_key=None):
self._username = username
self._password_hash = password_hash
self._first_name = first_name
self._last_name = last_name
self._profile_picture = profile_picture
self._doc_id = doc_id
# RSA keypair: public key stored in the clear; private key wrapped under
# the master KEK at rest. Either may be None until ensure_keypair backfills.
self._public_key = public_key
self._wrapped_private_key = wrapped_private_key
@property
def username(self):
return self._username
@property
def first_name(self):
return self._first_name
@property
def last_name(self):
return self._last_name
@property
def profile_picture(self):
return self._profile_picture
@property
def doc_id(self):
return self._doc_id
@property
def password_hash(self):
return self._password_hash
@property
def public_key(self):
return self._public_key
@property
def wrapped_private_key(self):
return self._wrapped_private_key
# --- password (Encapsulation) ---
def set_password(self, plain_password):
# Pin scrypt explicitly; check_password_hash still auto-detects old pbkdf2.
self._password_hash = generate_password_hash(plain_password, method='scrypt')
def verify_password(self, plain_password):
if not self._password_hash:
return False
return check_password_hash(self._password_hash, plain_password)
# --- roles ---
def is_admin(self):
"""Default role check. AdminUser overrides this."""
return self._username == 'admin'
# --- keypair ---
def ensure_keypair(self):
"""Guarantee this user has an RSA keypair, generating + persisting if absent."""
if self._public_key and self._wrapped_private_key:
return
private_pem, public_pem = generate_rsa_keypair()
self._public_key = public_pem.decode('utf-8')
self._wrapped_private_key = wrap_private_key(private_pem)
if self._doc_id:
users_collection.document(self._doc_id).update({
'public_key': self._public_key,
'wrapped_private_key': self._wrapped_private_key,
})
# --- dict-shim so legacy current_user['username'] keeps working ---
def __getitem__(self, key):
mapping = {
'username': self._username,
'firstName': self._first_name,
'lastName': self._last_name,
'profilePicture': self._profile_picture,
'password': self._password_hash,
'_id': self._doc_id,
}
if key in mapping:
return mapping[key]
raise KeyError(key)
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def to_dict(self):
return {
'username': self._username,
'password': self._password_hash,
'firstName': self._first_name,
'lastName': self._last_name,
'profilePicture': self._profile_picture,
'public_key': self._public_key,
'wrapped_private_key': self._wrapped_private_key,
}
# --- persistence ---
@classmethod
def from_firestore_doc(cls, doc):
"""Build the right User subclass from a Firestore DocumentSnapshot."""
data = doc.to_dict() or {}
username = data.get('username', '')
klass = AdminUser if username == 'admin' else User
return klass(
username=username,
password_hash=data.get('password'),
first_name=data.get('firstName', ''),
last_name=data.get('lastName', ''),
profile_picture=data.get('profilePicture'),
doc_id=doc.id,
public_key=data.get('public_key'),
wrapped_private_key=data.get('wrapped_private_key'),
)
@classmethod
def find_by_username(cls, username):
query = users_collection.where('username', '==', username).limit(1).get()
docs = list(query)
if not docs:
return None
return cls.from_firestore_doc(docs[0])
@classmethod
def create(cls, username, password, first_name, last_name, profile_picture=None):
"""Create and persist a new user (with an RSA keypair). Raises ValueError if taken."""
if cls.find_by_username(username) is not None:
raise ValueError("Username already exists")
private_pem, public_pem = generate_rsa_keypair()
user = cls(
username=username, password_hash=None,
first_name=first_name, last_name=last_name,
profile_picture=profile_picture,
public_key=public_pem.decode('utf-8'),
wrapped_private_key=wrap_private_key(private_pem),
)
user.set_password(password)
ref = users_collection.add(user.to_dict())
user._doc_id = ref[1].id
return user
## Inheritance -- AdminUser specializes User, overriding just the role check and
## adding admin-only queries.
class AdminUser(User):
"""The single admin account (username == 'admin')."""
def __init__(self, username, password_hash, first_name='', last_name='',
profile_picture=None, doc_id=None,
public_key=None, wrapped_private_key=None):
super().__init__(username, password_hash, first_name, last_name,
profile_picture, doc_id, public_key, wrapped_private_key)
def is_admin(self):
return True
def list_all_users(self):
"""Every user with file count + total storage. Powers GET /api/admin/users."""
files_collection = db.collection('files')
users_out = []
for doc in users_collection.get():
data = doc.to_dict() or {}
uname = data.get('username', '')
file_count = 0
total_size = 0
for file_doc in files_collection.where('uploaded_by', '==', uname).get():
info = file_doc.to_dict() or {}
if not info.get('is_deleted', False):
file_count += 1
total_size += info.get('size', 0)
users_out.append({
'id': doc.id, 'username': uname,
'firstName': data.get('firstName', ''),
'lastName': data.get('lastName', ''),
'email': data.get('email', ''),
'created_at': data.get('created_at', ''),
'profilePicture': data.get('profilePicture'),
'file_count': file_count, 'total_storage': total_size,
})
users_out.sort(key=lambda x: x.get('username', '').lower())
return users_out
def system_stats(self):
"""Aggregated counts/totals for the admin dashboard."""
files_collection = db.collection('files')
total_users = len(list(users_collection.get()))
total_files = 0
total_storage = 0
for doc in files_collection.get():
data = doc.to_dict() or {}
if not data.get('is_deleted', False):
total_files += 1
total_storage += data.get('size', 0)
total_directories = len(list(directories_collection.get()))
total_shares = len(list(shares_collection.get()))
return {
'total_users': total_users, 'total_files': total_files,
'total_directories': total_directories,
'total_storage': total_storage, 'total_shares': total_shares,
}
def delete_user(self, user_id):
"""Cascade-delete a non-admin user. Raises ValueError when targeting admin."""
user_doc = users_collection.document(user_id).get()
if not user_doc.exists:
raise LookupError("User not found")
data = user_doc.to_dict() or {}
username = data.get('username')
if username == 'admin':
raise ValueError("Cannot delete admin user")
files_collection = db.collection('files')
user_files = list(files_collection.where('uploaded_by', '==', username).get())
def delete_one_file(file_doc):
fdata = file_doc.to_dict() or {}
gcs_path = fdata.get('gcs_path')
if gcs_path:
blob = gcs_bucket.blob(gcs_path)
if blob.exists():
blob.delete()
files_collection.document(file_doc.id).delete()
if user_files:
with ThreadPoolExecutor(max_workers=8) as pool:
for future in as_completed([pool.submit(delete_one_file, d) for d in user_files]):
future.result()
for dir_doc in directories_collection.where('created_by', '==', username).get():
directories_collection.document(dir_doc.id).delete()
for share_doc in shares_collection.where('owner', '==', username).get():
shares_collection.document(share_doc.id).delete()
for share_doc in shares_collection.where('shared_with', '==', username).get():
shares_collection.document(share_doc.id).delete()
for fav_doc in favorites_collection.where('user', '==', username).get():
favorites_collection.document(fav_doc.id).delete()
users_collection.document(user_id).delete()
return username
"""
NotificationClient: the desktop side of the raw-TCP notification protocol.
Opens a plain TCP socket to the server's notification port, authenticates with a
JWT, then listens on a background thread for pushed events. Parsed events go on a
thread-safe queue so the Tkinter UI can drain them on the main thread.
Protocol mirror of server/socket_server.py:
we send {"type": "auth", "token": "<jwt>"} first, then periodic {"type": "ping"}
we receive {"type": "auth_ok"|"auth_error"|"pong"|"shared"|"file_changed", ...}
"""
import json
import queue
import socket
import threading
class NotificationClient:
def __init__(self):
self._sock = None
self._recv_thread = None
self._ping_thread = None
self._stop = threading.Event()
# The UI polls this queue via root.after(); each item is a parsed event dict.
self.events = queue.Queue()
def connect(self, host, port, token, timeout=10):
"""Open the socket, authenticate, and start the receive loop."""
self._stop.clear()
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(None)
self._sock = sock
self._reader = _LineReader(sock)
self._send({"type": "auth", "token": token})
response = self._read_message()
if not response or response.get("type") != "auth_ok":
err = (response or {}).get("error", "authentication failed")
self.close()
raise ConnectionError(err)
self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
self._recv_thread.start()
self._ping_thread = threading.Thread(target=self._ping_loop, daemon=True)
self._ping_thread.start()
return response
def _send(self, obj):
self._sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))
def _read_message(self):
line = self._reader.readline()
if not line:
return None
try:
obj = json.loads(line)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def _recv_loop(self):
try:
while not self._stop.is_set():
msg = self._read_message()
if msg is None:
break # server closed the connection
if msg.get("type") == "pong":
continue
self.events.put(msg)
except OSError:
pass
finally:
if not self._stop.is_set():
self.events.put({"type": "_disconnected"})
def _ping_loop(self):
# Heartbeat every 20s so dead connections are noticed promptly.
while not self._stop.wait(20):
try:
self._send({"type": "ping"})
except OSError:
break
def close(self):
self._stop.set()
if self._sock is not None:
try:
self._sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self._sock.close()
except OSError:
pass
self._sock = None
class _LineReader:
"""Buffers bytes off a socket and yields one '\\n'-terminated line at a time."""
def __init__(self, sock):
self._sock = sock
self._buf = b""
def readline(self):
while b"\n" not in self._buf:
chunk = self._sock.recv(4096)
if not chunk:
line, self._buf = self._buf, b""
return line.decode("utf-8", "replace") if line else ""
self._buf += chunk
line, _, self._buf = self._buf.partition(b"\n")
return line.decode("utf-8", "replace")
"""
ApiClient: thin REST wrapper for the desktop app, built on the standard library
(urllib) so the desktop client needs no third-party dependencies. Logs in to
obtain a JWT, then sends it as Authorization: Bearer <token> on each request.
File bytes travel over this REST channel -- the socket only carries small events.
"""
import json
import ssl
import urllib.error
import urllib.request
class ApiError(Exception):
pass
class ApiClient:
def __init__(self, base_url):
# base_url like "http://localhost:5000" (no trailing slash, no /api).
self.base_url = base_url.rstrip("/")
self.token = None
self.username = None
# The dev server may use a self-signed cert over HTTPS; for a local demo
# we don't verify it. Over plain HTTP this context is unused.
self._ssl_ctx = ssl.create_default_context()
self._ssl_ctx.check_hostname = False
self._ssl_ctx.verify_mode = ssl.CERT_NONE
def _request(self, method, path, *, json_body=None, auth=True):
url = f"{self.base_url}/api{path}"
data = None
headers = {"Accept": "application/json"}
if json_body is not None:
data = json.dumps(json_body).encode("utf-8")
headers["Content-Type"] = "application/json"
if auth:
if not self.token:
raise ApiError("Not authenticated")
headers["Authorization"] = f"Bearer {self.token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
ctx = self._ssl_ctx if url.startswith("https") else None
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
return resp.read()
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace")
try:
message = json.loads(body).get("error", body)
except json.JSONDecodeError:
message = body or e.reason
raise ApiError(f"{e.code}: {message}") from e
except urllib.error.URLError as e:
raise ApiError(f"Cannot reach server: {e.reason}") from e
def _get_json(self, path):
return json.loads(self._request("GET", path).decode("utf-8"))
def login(self, username, password):
"""POST /api/auth/login -> store and return the JWT."""
raw = self._request(
"POST", "/auth/login",
json_body={"username": username, "password": password},
auth=False,
)
data = json.loads(raw.decode("utf-8"))
self.token = data["token"]
self.username = data.get("username", username)
return self.token
def shared_with_me(self):
return self._get_json("/shared-with-me")
def download_shared_file(self, file_id, dest_path):
content = self._request("GET", f"/shared-files/{file_id}/download")
with open(dest_path, "wb") as fh:
fh.write(content)
return dest_path
"""
Tiny key/value preferences file. Persists the theme choice to
%APPDATA%/CloudStorage/preferences.json on Windows, falling back to
~/.cloudstorage/preferences.json elsewhere.
"""
import json
import os
from pathlib import Path
def _prefs_dir():
appdata = os.environ.get("APPDATA")
base = Path(appdata) if appdata else Path.home() / ".cloudstorage"
folder = base / "CloudStorage" if appdata else base
folder.mkdir(parents=True, exist_ok=True)
return folder
def _prefs_file():
return _prefs_dir() / "preferences.json"
def _read():
try:
with open(_prefs_file(), "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _write(data):
try:
with open(_prefs_file(), "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
except OSError:
pass # preferences are best-effort; never crash the app over them
def load_theme(default="dark"):
value = _read().get("theme")
return value if value in ("dark", "light") else default
def save_theme(name):
if name not in ("dark", "light"):
return
data = _read()
data["theme"] = name
_write(data)
שלושת קבצי ה-UI הגדולים מובאים כתקציר (כמו home.js), שכן רובם פריסת widgets
ועיצוב.
# ---------- desktop/theme.py ----------
# פלטות צבע dark/light התואמות ל-client/home/home.css (רקע slate, הדגשות cyan/pink),
# ובחירת משפחת פונט (Inter -> Segoe UI -> ברירת מחדל).
DARK = { 'bg': '#0f172a', 'surface': '#1e293b', 'accent': '#06b6d4', 'accent2': '#ec4899', ... }
LIGHT = { 'bg': '#f8fafc', 'surface': '#ffffff', 'accent': '#06b6d4', ... }
def palette(name): return DARK if name == 'dark' else LIGHT
def font_family(): ... # מחזיר את הפונט הזמין הראשון
# ---------- desktop/components.py ----------
# רכיבי CustomTkinter לשימוש חוזר, כל אחד עם apply_theme(palette):
class Header(ctk.CTkFrame): ... # לוגו + שם משתמש + כפתור theme
class StatusPill(ctk.CTkLabel): ... # אדום/ירוק: Disconnected / Connected as <user>
class NotificationFeed(ctk.CTkScrollableFrame): ... # רשימת NotificationCard
class NotificationCard(ctk.CTkFrame): ...
class SharedList(ctk.CTkScrollableFrame): ... # פריטים שותפו איתי + כפתור Download
class IconButton(ctk.CTkButton): ...
class ToastManager: ... # toasts זמניים בפינה התחתונה
class Tooltip: ...
# ---------- desktop/app.py ----------
# נקודת הכניסה: מסך login, ואז המסך הראשי. מצב מנוהל ב-NotificationClient + ApiClient.
class CloudStorageApp(ctk.CTk):
def __init__(self):
# בונה Header, StatusPill, NotificationFeed, SharedList; טוען theme מ-preferences.
...
def _on_login(self, username, password):
token = self.api.login(username, password) # REST
self.client.connect(host, 5001, token) # socket
self.after(200, self._drain_events) # poll את התור
def _drain_events(self):
while not self.client.events.empty():
evt = self.client.events.get()
if evt['type'] == 'shared':
self.toasts.show(f"{evt['owner']} shared {evt['item_name']}")
self.feed.add_card(evt); self._reload_shared()
self.after(200, self._drain_events)
def _toggle_theme(self): ... # מחליף פלטה לכל הרכיבים + save_theme()
if __name__ == '__main__':
CloudStorageApp().mainloop()
קבצי ה-CSS של כל עמוד גדולים מאוד (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.css | profile picture circle, form layout, success/error messages |
| client/admin/admin.css | stat cards, admin tables, user avatars, delete confirmation |
כל קבצי ה-CSS משתמשים במערכת משתני CSS (--bg-primary, --text-primary,
--accent-primary וכו') שנפרסים לפי :root[data-theme="dark"] או
:root[data-theme="light"], מה שמאפשר החלפת ערכת נושא מיידית ללא רענון.
קבצי ה-HTML של home.html, profile.html, ו-admin.html מצורפים בפרק 4.7 (פירוט מסכי המערכת) ובסקירה המפורטת. הם מכילים את ה-DOM המלא של המודלים, הטבלאות, ולשוניות. אורכים: home.html כ-730 שורות (כולל 11 מודלים), profile.html כ-115 שורות, admin.html כ-200 שורות.
— סוף תיק הפרויקט —
תיק זה הוכן על-ידי מאי, סמל מקצוע 883589, חלופת הגנת סייבר ומערכות הפעלה.