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). השרטוט הבא מציג את הקישורים:
תיאור הרכיבים:
clientstorage-6978a.firebasestorage.app.| שכבה | טכנולוגיה | גרסה | תפקיד |
|---|---|---|---|
| שפת תכנות שרת | 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) |
| בסיס נתונים | Google Firestore | (via firebase-admin 6.5.0) | NoSQL document store בענן |
| אחסון קבצים | Google Cloud Storage | (via google-cloud-storage 2.14.0) | אחסון אובייקטים (blobs) |
| שפת לקוח | HTML5 / CSS3 / ES6 JavaScript | — | ממשק משתמש (ללא framework) |
| תקשורת | HTTP/1.1 + Fetch API | — | פרוטוקול REST על-גבי TCP |
| איחסון לקוחי | localStorage | — | שמירת authToken, username, cloudStorageTheme |
| בקרת גרסאות | Git | — | ניהול קוד והיסטוריה |
תחומי עניין מרכזיים: רשתות (HTTP/REST/TCP), מערכות הפעלה (קבצים, תהליכים, ניהול זיכרון בעת ZIP), הגנת סייבר (אימות, הצפנה, בקרת גישה, JWT), בסיסי נתונים (NoSQL Document Model), ארכיטקטורת שרתים מבוססת בלוקים (Blueprints).
ניסוח הבעיה: יש לאחסן סיסמאות באופן שלא יאפשר לאף אחד (כולל מנהל המערכת) לשחזר אותן, אך עם זאת לאפשר אימות שהמשתמש הקליד את הסיסמה הנכונה בעת התחברות.
סקירת פתרונות קיימים:
הפתרון הנבחר: 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).
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.
תפקיד: נקודת כניסה — מציג כותרת ומפנה אוטומטית ל-login.html.
תפקיד: איסוף 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.
תפקיד: המרכז העיקרי של היישום. מציג טבלת קבצים ותיקיות של המשתמש, עם breadcrumbs לניווט בעץ, תיבת חיפוש, כפתורי Upload / Create Directory, מיון עמודות, checkboxes ל-Bulk Actions, וסרגל פעולות Bulk שמופיע מתחת לטבלה כשנבחרו פריטים.
פעולות: העלאה (Drag&Drop או Browse), יצירת תיקייה, כניסה לתיקייה (double-click), הורדה, מחיקה, שיתוף, סימון מועדף, חיפוש, מיון, Bulk Download / Move / Copy / Delete.
תפקיד: רשימת קבצים ותיקיות שהמשתמש קיבל שיתוף אליהם, עם הצגת בעלים (Owner) לכל פריט.
תפקיד: רשימת פריטים שהמשתמש סימן כמועדפים. תומך בחיפוש ומיון.
תפקיד: רשימת פריטים שנמחקו, עם עמודת Days Remaining וכפתורי Restore / Empty Recycle Bin.
תפקיד: אזור Drag&Drop גדול, כפתור "Browse File", spinner העלאה, success icon, ושדה בחירת תיקיית יעד.
תפקיד: חיפוש משתמשים בזמן אמת, רשימת משותפים נוכחית, יכולת לבטל שיתוף.
תפקיד: Modals לאישור פעולות ובחירת יעדים.
תפקיד: עריכת firstName / lastName, העלאה והסרת תמונת פרופיל, שם משתמש לקריאה בלבד.
תפקיד: זמין רק ל-admin. ארבעה כרטיסי סטטיסטיקה (Total Users, Total Files, Total Storage, Active Shares), טבלת משתמשים עם File Count + Storage Used + כפתור מחיקה, וטבלת All Files.
בסיס הנתונים הוא 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_..." | לא |
מפתח ראשי: Document ID (אוטומטי). מפתח עסקי: username.
| שדה | טיפוס | דוגמה | חובה |
|---|---|---|---|
| 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 | לא |
הערה: קולקציה זו מאוחזרת כ-db.collection('files') בכל מודול,
ואינה מיוצאת מ-config.py.
| שדה | טיפוס | דוגמה | חובה |
|---|---|---|---|
| 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" |
| שדה | טיפוס | דוגמה |
|---|---|---|
| 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}| מפתח | תוכן | שימוש |
|---|---|---|
| authToken | JWT string | נשלח ככותרת Authorization בכל בקשה מוגנת |
| username | string | תצוגת שם משתמש בכותרת home page |
| cloudStorageTheme | "dark" | "light" | שמירת בחירת ערכת הנושא בין רענונים |
בנוסף: sessionStorage.cameFromHome כדגל זמני בעת חזרה ל-login אחרי 401.
הסעיף הזה מתאר באופן כן ומפורש את הפערים האבטחתיים הקיימים בקוד הנוכחי (סביבת פיתוח / למידה), ולכל פער – מה היה ראוי לבצע בסביבת ייצור. אסטרטגיה זו נבחרה במכוון: דיווח אמיתי הוא בעל ערך לימודי גבוה הרבה יותר מהצגת מימוש מושלם.
מה הקוד עושה היום: אין שכבת SQL בכלל. ה-DB הוא Firestore (NoSQL). שאילתות מבוצעות
באמצעות builder מתודות (.where('field', '==', value)) שאינן נוצרות מ-concatenation
של מחרוזות. ההגנה היא מבנית.
מקבילה מסוכנת (NoSQL injection): ב-MongoDB עלולה להיות בעיה אם בונים שאילתה מ-dict שמגיע ישירות מהמשתמש. ב-Firestore Python SDK כל ערך עובר type-conversion ל-Firestore native types ולא נוצרת בעיה דומה.
בייצור: שמירה על המודל הקיים; הימנעות מבניית query string דינמיים.
מה הקוד עושה היום: ב-home.js מסוימים נעשה שימוש ב-innerHTML
לחיבור HTML דינמי (לדוגמה, יצירת שורות טבלת קבצים). שמות קבצים שמוצגים בטבלאות מגיעים מ-Firestore — היכן שהמקור הוא
שם הקובץ שהמשתמש העלה. שם קובץ ש-secure_filename כבר ניקה אותו לא יכיל אופייני HTML מסוכנים, אך אם בשם הקובץ
נשאר תו XSS-relevant ייתכן וקטור.
בייצור: מעבר עקבי ל-textContent, או הוספת ספריית escape, או שימוש
ב-template engine. בנוסף: הגדרת Content-Security-Policy ב-Headers.
מה הקוד עושה היום: 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 נקרא מ-Environment Variable, אך
במקרה היעדרו יש fallback קבוע 'your-secret-key-change-in-production' ב-
config.py. בסביבת פיתוח זה ה-fallback. אם סוד זה ידוע לתוקף, ניתן לזייף JWT.
בייצור: הגדרה מחייבת של SECRET_KEY דרך Secret Manager (Google Secret Manager / AWS Secrets Manager), ביטול ה-fallback, שימוש ב-Asymmetric (RS256), רוטציה תקופתית.
מה הקוד עושה היום: השרת רץ ב-debug=True על HTTP פשוט (ללא TLS).
בקשות מהדפדפן מגיעות בטקסט גלוי – כולל ה-JWT. ה-CORS פתוח לחלוטין (origins="*") — ראוי
לסביבת פיתוח, מסוכן בייצור.
בייצור: חובה לאכוף HTTPS דרך reverse proxy (nginx + Let's Encrypt), HSTS header, CORS מצומצם ל-domain ספציפי, הצפנה נוספת ברמת השדה לנתונים רגישים (Field-Level Encryption).
מה הקוד עושה היום: אין rate-limiting בכלל. שום endpoint לא מוגן מפני flood של בקשות.
MAX_CONTENT_LENGTH=16MB מגביל גודל קובץ בודד, אבל לא מספר ההעלאות הכולל.
פעולות Bulk לא מוגבלות בכמות הפריטים — תוקף יכול לבקש Bulk Download של 100,000 פריטים ולגרום ל-OOM.
בייצור: Flask-Limiter, WAF מול השרת (Cloudflare / GCP Armor), Per-Endpoint Quotas, Streaming ZIP במקום ZIP-in-memory להגבלת זיכרון.
מה הקוד עושה היום: אסימון 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 ב-app.py מחזיר את
str(e) ל-client רק כאשר app.debug=True. השרת רץ עם
debug=True תמיד, ולכן בפועל stack traces ופרטי שגיאה אחרים נחשפים.
בייצור: debug=False, ניתוב הודעות שגיאה אמיתיות ללוג בלבד,
החזרת hash/error-id ללקוח.
מה הקוד עושה היום: GCS מצפין כל אובייקט אוטומטית ב-AES-256 (Google Default Encryption). Firestore מצפין נתונים אוטומטית כברירת מחדל. הגנה זו מקבלת ע"י תשתית הענן ללא קונפיגורציה.
בייצור: שימוש ב-Customer-Managed Encryption Keys (CMEK) דרך KMS לבקרה מלאה.
מה הקוד עושה היום: הקובץ cloudstorageproject-privatekey.json
קיים פיזית ברפו של הפרויקט (משמש לפיתוח מקומי). מצב זה אינו מקובל בייצור.
בייצור: מחיקה מההיסטוריה של git, רוטציה של המפתח, אחסון ב-Secret Manager, גישה ל-GCS דרך Workload Identity (Cloud Run / GKE).
הלקוח (דפדפן) פותח חיבור 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.
בסביבת הפיתוח אין TLS — תקשורת על HTTP פשוט (פורט 5000). בייצור: HTTPS חובה. החיבור בין שרת Flask ל-Firestore ול-GCS עובר על HTTPS/TLS אוטומטית דרך firebase-admin ו-google-cloud-storage. כלומר, ההפסד הוא רק במקטע Browser ↔ Flask.
| שם המודול | למה משמש |
|---|---|
| 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 |
תפקיד: אתחול 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.
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
| 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; } });
הבדיקות נערכו ידנית בדפדפן 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 │ ├── config.py # Singletons (Flask app, Firestore, GCS) │ ├── seed_db.py # סקריפט אתחול חד-פעמי של משתמש admin │ ├── cloudstorageproject-privatekey.json # Service Account Key │ ├── utils/ │ │ ├── __init__.py │ │ └── auth.py # @token_required / @admin_required │ └── routes/ # 11 Blueprints │ ├── __init__.py # all_blueprints list │ ├── auth.py # /api/auth/* │ ├── user.py # /api/user/*, /api/users/* │ ├── files.py # /api/upload, /api/files, /api/files/search, ... │ ├── directories.py # /api/directories/* │ ├── sharing.py # /api/share, /api/shared-with-me, ... │ ├── favorites.py # /api/favorites/* │ ├── recycle_bin.py # /api/recycle-bin/* │ ├── bulk.py # /api/bulk/* │ ├── admin.py # /api/admin/* (admin-only) │ ├── static_files.py # שירות HTML/CSS/JS │ └── items.py # /api/items (legacy) │ ├── requirements.txt # Python dependencies ├── start_all.bat # הפעלת השרת + פתיחת דפדפן ├── start_server.bat # רק הפעלת שרת ├── start_server.ps1 # גרסת PowerShell ├── check_setup.bat # אבחון התקנה ├── README.md # תיעוד בסיסי (מיושן — ראו CLAUDE.md) └── PROJECT_BOOK.html # תיק הפרויקט הזה
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 (רק שרת).http://localhost:5000/seed_db.py:adminadmin
משתמשים נוספים נוצרים דרך עמוד ההרשמה ב-/signup.html.
5000 חופשי עבור שרת Flask.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.פרויקט ה-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.
"""
from flask import jsonify
from config import app
from routes import all_blueprints
# Global error handler - Flask-CORS will add headers automatically
@app.errorhandler(Exception)
def handle_exception(e):
"""Handle all uncaught exceptions"""
error_message = str(e)
# Log the error
app.logger.error(f"Unhandled exception: {error_message}")
# Return a generic error response
return jsonify({
"error": "An internal error occurred",
"details": error_message if app.debug else None
}), 500
# Register all blueprints
for blueprint in all_blueprints:
app.register_blueprint(blueprint)
if __name__ == '__main__':
print("Starting Flask server...")
print("Server running at http://localhost:5000")
app.run(debug=True, host='0.0.0.0', port=5000)
"""
Flask application configuration and initialization.
Contains Flask app, Firebase Admin SDK, and Google Cloud Storage setup.
"""
from flask import Flask
from flask_cors import CORS
import firebase_admin
from firebase_admin import credentials, firestore
from google.cloud import storage
import os
# Create Flask app
app = Flask(__name__, static_folder='../client', static_url_path='')
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your-secret-key-change-in-production')
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), 'uploads')
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size
# Create uploads directory if it doesn't exist
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# Configure CORS - allow all origins for development
CORS(app,
origins="*",
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
supports_credentials=True)
# Initialize Firebase Admin SDK
cred_path = os.path.join(os.path.dirname(__file__), 'cloudstorageproject-privatekey.json')
cred = credentials.Certificate(cred_path)
firebase_admin.initialize_app(cred)
# Get Firestore database
db = firestore.client()
users_collection = db.collection('users')
items_collection = db.collection('items')
directories_collection = db.collection('directories')
shares_collection = db.collection('shares')
favorites_collection = db.collection('favorites')
recycle_bin_collection = db.collection('recycle_bin')
# Initialize Google Cloud Storage
GCS_BUCKET_NAME = 'clientstorage-6978a.firebasestorage.app'
storage_client = storage.Client.from_service_account_json(cred_path)
gcs_bucket = storage_client.bucket(GCS_BUCKET_NAME)
"""
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 = []; }
});
}
קבצי ה-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, חלופת הגנת סייבר ומערכות הפעלה.