המדריך בנוי בדיוק לפי הסדר של המחוון של משרד החינוך:
קופסה אדומה = משהו שחסר בפרויקט. יש תשובת גיבוי בעברית להגיד אם הבוחן שואל. קופסה ירוקה = מימוש חזק. קופסה כתומה = מימוש חלקי / מקום לשיפור.
CloudStorage היא מערכת אחסון קבצים בענן עם שיתוף מוצפן בין משתמשים. למערכת שני לקוחות שונים:
השרת מורכב משתי שכבות בתוך אותו תהליך:
אחסון: מטא-דאטה ב-Firestore (NoSQL של Google), קבצים ב-Google Cloud Storage, מוצפנים בהצפנת מעטפה (Envelope Encryption) של Fernet + RSA-2048.
Browser ──HTTPS─► Flask :5000 ──┐
│ אותו תהליך, אותו זיכרון
Desktop ──TCP─────► NotificationHub :5001
Desktop ◄──push──── (events: shared / file_changed)
Desktop ──HTTPS───► Flask :5000 (להורדת קבצים)
│
▼
Firestore (מטא-דאטה)
Google Cloud Storage (בתים מוצפנים)
שתי תשתיות תקשורת באותו שרת: HTTP/REST שמשרת קבצים, וסוקט TCP גולמי שמשרת התראות. שני הלקוחות (Web ו-Desktop) מדברים עם אותם משאבים, אבל כל אחד דרך הערוץ המתאים לו. זו דוגמה מצוינת ל-"מימוש פרוטוקול העברת הודעות מצד לצד הגיוני ע"י התלמיד" — דרישה 2 במחוון.
הדרישה היא לפחות 4 מחלקות שהתלמיד יצר, עם שימוש נכון ב-OOP: חלוקה למחלקות, הפרדה לוגית, קשרים בין מחלקות, הכלה והורשה.
| מחלקה | קובץ:שורה | תפקיד | קשר OOP |
|---|---|---|---|
| StorageItem | server/models/storage_item.py:20 | מחלקת-על מופשטת (Abstract Base Class) לכל פריט אחסון | Abstraction, Encapsulation |
| FileItem | server/models/file_item.py:17 | קובץ — יורש מ-StorageItem | Inheritance + Polymorphism |
| DirectoryItem | server/models/directory_item.py:24 | תיקייה — יורש מ-StorageItem | Inheritance + Polymorphism |
| User | server/models/user.py:15 | משתמש רגיל — encapsulation לכל שדה | Encapsulation |
| AdminUser | server/models/user.py:207 | מנהל מערכת — יורש מ-User | Inheritance + Polymorphism |
| NotificationHub | server/socket_server.py:31 | מרכזת חיבורי סוקט וניהול threads | Encapsulation |
| NotificationClient | desktop/socket_client.py:19 | לקוח התראות בצד Desktop | Encapsulation |
| ApiClient | desktop/api_client.py:19 | עטיפת REST לצד Desktop | Encapsulation |
| DesktopApp | desktop/app.py:24 | ה-UI של Tkinter | Encapsulation, Composition (מכיל ApiClient + NotificationClient) |
from abc import ABC, abstractmethod
class StorageItem(ABC):
"""Common contract for File and Directory items."""
def __init__(self, doc_id, owner, parent_directory_id=None, created_at=None):
self._id = doc_id
self._owner = owner
self._parent_directory_id = parent_directory_id
self._created_at = created_at or datetime.utcnow().isoformat()
@abstractmethod
def item_type(self):
"""Return 'file' or 'directory'."""
@abstractmethod
def display_name(self):
"""Human-readable name."""
@abstractmethod
def permanently_delete(self):
"""Hard-delete from GCS + Firestore."""
StorageItem היא מחלקה מופשטת — היא יורשת מ-ABC ומגדירה מתודות עם @abstractmethod.
זה אומר שלא ניתן ליצור ממנה אובייקט ישירות; חייבים לרשת ממנה ולממש את כל המתודות המופשטות.
זו הפשטה (Abstraction): היא חוזה — אומרת "כל פריט אחסון חייב לדעת להגיד את הסוג שלו, להציג שם, ולמחוק את עצמו".
הקובץ (FileItem) והתיקייה (DirectoryItem) הם מימושים קונקרטיים של החוזה הזה.
class FileItem(StorageItem):
"""Represents an uploaded file."""
def __init__(self, doc_id, owner, filename, stored_filename, gcs_path,
size, content_type, directory_id=None, uploaded_at=None,
wrapped_dek=None):
# Inheritance — pass shared fields up to StorageItem.__init__
super().__init__(doc_id, owner,
parent_directory_id=directory_id,
created_at=uploaded_at)
self._filename = filename
self._stored_filename = stored_filename
self._gcs_path = gcs_path
self._size = size
self._content_type = content_type
self._wrapped_dek = wrapped_dek
הקריאה super().__init__(...) מעבירה את השדות המשותפים למחלקת-העל. FileItem מקבל "בחינם" את _id, _owner, _parent_directory_id ו-_created_at + את המתודה check_ownership וה-soft_delete.
class AdminUser(User):
"""The single admin account."""
def __init__(self, username, password_hash, first_name='', last_name='',
profile_picture=None, doc_id=None,
public_key=None, wrapped_private_key=None):
super().__init__(username, password_hash, first_name, last_name,
profile_picture, doc_id, public_key, wrapped_private_key)
# Polymorphism — override of User.is_admin()
def is_admin(self):
return True
AdminUser יורש מ-User. הוא דורס רק מתודה אחת: is_admin(). אצל User הרגיל המתודה הזו בודקת username == 'admin', אצל AdminUser היא תמיד מחזירה True. הקוד שמשתמש במשתמש (ה-decorator admin_required) קורא user.is_admin() בלי לדעת אם זה User או AdminUser — וזה בדיוק פולימורפיזם.
def soft_delete(self, parent_directory_id_override=None, path=''):
"""Move this item into the recycle bin (30-day retention)."""
expiration_date = datetime.utcnow() + timedelta(days=30)
recycle_bin_data = {
'item_id': self._id,
'item_type': self.item_type(), # Polymorphism — subclass-specific
'item_name': self.display_name(), # Polymorphism — subclass-specific
'original_data': self.to_dict(), # Polymorphism — subclass-specific
'deleted_by': self._owner,
'deleted_at': datetime.utcnow().isoformat(),
'expires_at': expiration_date.isoformat(),
...
}
ref = recycle_bin_collection.add(recycle_bin_data)
self._collection_ref().document(self._id).delete() # Polymorphism
return ref[1].id
שימי לב: soft_delete נכתבת פעם אחת במחלקת-העל. היא קוראת ל-self.item_type(), self.display_name(), self._collection_ref(). בזמן ריצה Python בודק מהו האובייקט בפועל ומפעיל את המתודה הנכונה — אם זה FileItem היא מחזירה 'file' ומחקת מ-files; אם זה DirectoryItem היא מחזירה 'directory' ומחקת מ-directories. אותו קוד, התנהגות שונה. זה הלב של פולימורפיזם.
class User:
def __init__(self, username, password_hash, ...):
self._username = username
self._password_hash = password_hash # פרטי — קידומת underscore
@property
def username(self):
return self._username # קריאה בלבד, אי-אפשר לדרוס מבחוץ
def set_password(self, plain_password):
# Encapsulation — הדרך היחידה להגדיר סיסמה היא דרך המתודה הזו,
# שמבטיחה שהסיסמה תמיד נשמרת מוצפנת (hashed), אף פעם לא בטקסט גלוי.
self._password_hash = generate_password_hash(plain_password, method='scrypt')
def verify_password(self, plain_password):
if not self._password_hash:
return False
return check_password_hash(self._password_hash, plain_password)
למה זה חשוב? כי אם הסיסמה הייתה public, מתכנת אחר בקוד היה יכול בטעות לכתוב user.password = "1234" ולשמור סיסמה בטקסט גלוי במסד הנתונים. הכימוס מאלץ אותו ללכת דרך set_password — שמכריחה hashing. הקופסה הסגורה הזו שומרת על אינווריאנט (חוק שאסור להפר): "סיסמאות תמיד מוצפנות".
class DesktopApp:
def __init__(self, root):
self.root = root
self.api = None # ייווצר ApiClient כשנתחבר
self.nc = None # ייווצר NotificationClient כשנתחבר
self._shared_files = []
...
def _connect_worker(self, server, username, password):
api = ApiClient(server) # אובייקט מוכל
api.login(username, password)
nc = NotificationClient() # אובייקט מוכל
nc.connect(host, SOCKET_PORT, api.token)
זו הכלה: DesktopApp לא יורש מ-ApiClient או מ-NotificationClient, אלא מחזיק בהם בתוכו. זה ההפך מהורשה — במקום "DesktopApp הוא ApiClient", זה "DesktopApp יש לו ApiClient". זה גם מאפשר לבדוק כל מחלקה בנפרד.
class FileItem(StorageItem): ב-file_item.py שורה 17.soft_delete במחלקת-העל. אני קוראת self.item_type() ולא יודעת אם זה FileItem או DirectoryItem — Python מחליט בזמן ריצה. דוגמה נוספת: user.is_admin() ב-auth.py שורה 108.@abstractmethod — אי אפשר ליצור ממנה אובייקט. היא מגדירה את החוזה ש-FileItem ו-DirectoryItem חייבים לקיים.@property, סיסמה דרך set_password בלבד.הדרישה היא שלושה דברים: (1) שרת ולקוח מבוססי סוקטים, (2) שרת מרובה לקוחות, (3) פרוטוקול הודעות הגיוני שאני פיתחתי.
בנוסף ל-Flask REST API שעובד מעל HTTP, יש בפרויקט שרת סוקטים גולמי בפורט 5001. הוא לא משתמש ב-HTTP, ב-WebSocket, או בכל ספרייה חיצונית — רק socket מהספרייה התקנית של Python. הסיבה: HTTP הוא תקשורת בקשה-תשובה — הלקוח שואל, השרת עונה. אני רוצה שהשרת ייזום שליחה ללקוח כש"מישהו שיתף איתך קובץ" — וזה אפשרי רק על סוקט פתוח מתמשך.
"""
Raw TCP socket server for real-time push notifications.
This is intentionally separate from Flask: it listens on its own port (default 5001)
and speaks a tiny hand-rolled application protocol -- newline-delimited JSON, one
object per line -- over a plain TCP socket (Python's `socket` module).
Protocol (each message is one UTF-8 JSON object terminated by '\n'):
Client -> Server : {"type": "auth", "token": ""} (must be first message)
Client -> Server : {"type": "ping"}
Server -> Client : {"type": "auth_ok", "username": "bob"}
Server -> Client : {"type": "auth_error", "error": "Invalid token"} then close
Server -> Client : {"type": "pong"}
Server -> Client : {"type": "shared", ...} (someone shared an item with you)
Server -> Client : {"type": "file_changed", ...} (one of your files changed)
"""
זה הפרוטוקול שלי. כל הודעה היא אובייקט JSON אחד, מסתיימת ב-\n. המסר הראשון מהלקוח חייב להיות אימות עם ה-JWT. אם האימות נכשל, השרת שולח auth_error וסוגר את הסוקט. בזמן ריצה אמצעי: ping/pong כדי שאדע אם הצד השני עוד שם.
def start(self, host="127.0.0.1", port=5001):
"""Bind, listen, and spawn the accept loop on a daemon thread."""
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(16)
self._server_socket = srv
thread = threading.Thread(
target=self._accept_loop, name="socket-accept-loop", daemon=True
)
thread.start()
def _accept_loop(self):
while True:
try:
conn, addr = self._server_socket.accept()
except OSError:
break
threading.Thread(
target=self._handle_client,
args=(conn, addr),
name=f"socket-client-{addr}",
daemon=True,
).start()
4 שלבים קלאסיים של שרת TCP:
socket(AF_INET, SOCK_STREAM) — AF_INET = IPv4, SOCK_STREAM = TCP (אם הייתי רוצה UDP זה היה SOCK_DGRAM).bind — קושר את הסוקט לכתובת IP ופורט.listen(16) — מתחיל להאזין, מכין תור של עד 16 חיבורים שמחכים.accept() — חוסם עד שלקוח מתחבר; מחזיר סוקט חדש לתקשורת עם אותו לקוח ספציפי.SO_REUSEADDR מאפשר להפעיל את השרת מחדש מיד אחרי שעצרתי אותו, בלי לחכות שמערכת ההפעלה תשחרר את הפורט.
כל פעם ש-accept() מחזיר חיבור חדש — אני יוצרת thread חדש בדאמון (daemon=True) שמטפל באותו לקוח. בזמן שהthread הזה רץ, accept() חוזר לחכות ל-הלקוח הבא. כך מספר לקוחות יכולים להיות מחוברים בבו-זמנית.
def connect(self, host, port, token, timeout=10):
"""Open the socket, authenticate, and start the receive loop."""
self._stop.clear()
sock = socket.create_connection((host, port), timeout=timeout)
sock.settimeout(None)
self._sock = sock
self._reader = _LineReader(sock)
self._send({"type": "auth", "token": token})
response = self._read_message()
if not response or response.get("type") != "auth_ok":
err = (response or {}).get("error", "authentication failed")
self.close()
raise ConnectionError(err)
self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
self._recv_thread.start()
self._ping_thread = threading.Thread(target=self._ping_loop, daemon=True)
self._ping_thread.start()
return response
הלקוח: יוצרת חיבור (socket.create_connection), שולחת מסר אימות עם ה-JWT, מחכה ל-auth_ok. אם הכל בסדר — מפעילה שני threads ברקע: אחד מקבל הודעות מהשרת, אחד שולח ping כל 20 שניות.
class _LineReader:
"""Buffers bytes off a socket and yields one '\n'-terminated line at a time."""
def __init__(self, conn):
self._conn = conn
self._buf = b""
def readline(self):
while b"\n" not in self._buf:
chunk = self._conn.recv(4096)
if not chunk:
line, self._buf = self._buf, b""
return line.decode("utf-8", "replace") if line else ""
self._buf += chunk
line, _, self._buf = self._buf.partition(b"\n")
return line.decode("utf-8", "replace")
בעיה ב-TCP: TCP הוא זרם בייטים, לא הודעות. אם השרת שלח שתי הודעות, אני יכולה לקבל אותן ב-recv אחד; אם השרת שלח הודעה אחת ארוכה, אני יכולה לקבל אותה בשני recv-ים. אז אני צריכה שכבת פריימינג שתחלק את הזרם להודעות. הפתרון שלי: כל הודעה מסתיימת ב-\n, ויש לי באפר שאוסף בייטים ומחפש \n. כשיש — חותך את ההודעה הזו ושומר את השאר לבאפר לפעם הבאה. זו שכבת הפרוטוקול שאני יצרתי.
def push_to_user(self, username, event):
"""Send one JSON event to every live socket for `username`."""
line = (json.dumps(event) + "\n").encode("utf-8")
with self._lock:
conns = list(self._connections.get(username, ()))
dead = []
for conn in conns:
try:
conn.sendall(line)
except OSError:
dead.append(conn)
for conn in dead:
self.unregister(username, conn)
try:
conn.close()
except OSError:
pass
הפונקציה הזו נקראת מתוך נתיבי Flask — למשל ב-server/routes/sharing.py:130:
notification_hub.push_to_user(share_with_username, {...}).
כלומר: כשמשתמש עושה Share דרך ה-REST API, השרת מסתכל בטבלת החיבורים הפעילים, ואם המקבל מחובר ב-Desktop — שולח לו דחיפה דרך הסוקט הפתוח. זה לא היה אפשרי על HTTP טהור.
הסוקט בפורט 5001 הוא TCP גלוי — אין TLS עליו. ה-JWT עובר בהודעת ה-auth הראשונה בטקסט. איך אגיד את זה לבוחן?
"הסוקט עצמו הוא TCP גלוי כי הוא רץ על localhost בלבד — host='127.0.0.1' — וכל התקשורת בין שני התהליכים על אותו מחשב. שיפור עתידי שאני מתכננת זה לעטוף את הסוקט ב-ssl.SSLContext.wrap_socket בדומה לאיך שה-HTTP העתידי יעבור HTTPS. עדיין, ה-JWT עצמו חתום ב-HS256 ומכיל timestamps של תפוגה ופקיעה, כך שגם אם מישהו יחטוף אותו, יש לו תוקף מוגבל."
SOCK_STREAM ב-socket_server.py:84. בחרתי TCP כי אני שולחת הודעות JSON שלמות שחייבות להגיע מסודר ובלי איבוד — אם הודעה הולכת לאיבוד, לא אדע על שיתוף קובץ.recv מחזיר אפס בייטים, אז ה-thread יוצא מלולאת ה-handle_client. ב-finally אני מסירה אותו מטבלת החיבורים וסוגרת את הסוקט. השרת לא קורס.socket.create_connection אצלי זה קורה מאחורי הקלעים.הדרישה: שימוש ב-Thread, וגישה למערכת קבצים או API או COM או רכיבים אחרים.
| איפה | קובץ:שורה | למה |
|---|---|---|
| Accept loop של הסוקט | socket_server.py:89 | השרת לא יכול לחסום את התהליך הראשי. accept חוסם, אז הוא צריך thread משלו. |
| Handler לכל לקוח | socket_server.py:100 | כדי לתמוך במרובה לקוחות. |
| Receive loop של הלקוח | desktop/socket_client.py:46 | קולט הודעות מהשרת באופן רציף בלי לחסום את ה-UI. |
| Ping loop של הלקוח | desktop/socket_client.py:48 | שולח ping כל 20 שניות. |
| Connect worker ב-Tkinter | desktop/app.py:102 | פעולות רשת לא יכולות להתבצע ב-UI thread של Tkinter — הוא יקפא. |
| ThreadPoolExecutor — מחיקת תיקייה | directory_item.py:78 | מחיקה מקבילה של עד 8 קבצים בו-זמנית מ-GCS. |
| ThreadPoolExecutor — Zip של תיקייה | directory_item.py:280 | הורדה מקבילה של בתים מ-GCS לארגון לקובץ Zip. |
| ThreadPoolExecutor — העתקת תיקייה | directory_item.py:213 | copy_blob מקבילי לכל קובץ. |
| ThreadPoolExecutor — מחיקת משתמש | user.py:302 | אדמין מוחק משתמש עם הרבה קבצים — לא רוצים שזה ייתקע. |
def _accept_loop(self):
while True:
try:
conn, addr = self._server_socket.accept()
except OSError:
break # listening socket closed -> shut down the loop
threading.Thread(
target=self._handle_client,
args=(conn, addr),
name=f"socket-client-{addr}",
daemon=True,
).start()
daemon=True אומר שאם התהליך הראשי מסתיים, גם ה-threads האלה ייסגרו מיד. בלי daemon=True התהליך לא יוכל להיסגר עד שכל ה-threads ייסגרו לבד.
def fetch_blob(file_info):
if not file_info['gcs_path']:
return None
blob = gcs_bucket.blob(file_info['gcs_path'])
if not blob.exists():
return None
return (file_info['relative'],
decrypt_stored_file(blob.download_as_bytes(), file_info.get('wrapped_dek')))
zip_buffer = io.BytesIO()
files_added = 0
total_bytes = 0
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(fetch_blob, f) for f in all_files]
for future in as_completed(futures):
result = future.result()
if result is None:
continue
relative, data = result
total_bytes += len(data)
if total_bytes > MAX_ZIP_BYTES:
raise ZipLimitExceeded(...)
zf.writestr(relative, data)
files_added += 1
למה Thread Pool ולא threads רגילים? כי יצירת thread חדש לכל קובץ עולה לי הרבה במשאבים, וזה לא נכון להפעיל 200 threads בו-זמנית כש-GCS יכול לחנוק את ההורדות שלי. ThreadPoolExecutor(max_workers=8) מבטיח שמקסימום 8 הורדות יקרו במקביל, בעוד הקבצים האחרים מחכים בתור.
למה לא לעשות את הכתיבה ל-Zip במקביל? כי zipfile.ZipFile לא thread-safe. רק ההורדות (I/O ברשת) רצות במקביל; הכתיבה לזיפ נשארת ב-thread הראשי.
def __init__(self):
self._connections = {}
self._lock = threading.Lock()
self._server_socket = None
def register(self, username, conn):
with self._lock:
self._connections.setdefault(username, set()).add(conn)
def unregister(self, username, conn):
with self._lock:
conns = self._connections.get(username)
if conns:
conns.discard(conn)
if not conns:
del self._connections[username]
טבלת ה-_connections משותפת בין כל ה-threads — accept loop רושם, handler מוסיף ומסיר, push_to_user קורא. אם שני threads ישנו אותה בו-זמנית — Race Condition. threading.Lock מבטיח שרק thread אחד יכול להיות בתוך with self._lock: בכל רגע נתון. זה סעיף קריטי (critical section).
def _download_selected(self):
sel = self.shared_list.curselection()
if not sel:
messagebox.showinfo("Download", "Select a file first.")
return
index = sel[0]
f = self._shared_files[index]
dest = filedialog.asksaveasfilename(initialfile=f.get("filename", "download"))
if not dest:
return
try:
self.api.download_shared_file(f["id"], dest)
except ApiError as e:
messagebox.showerror("Download failed", str(e))
return
self._log(f"Downloaded \"{f.get('filename', '')}\" to {dest}")
filedialog.asksaveasfilename פותח את החלון של מערכת ההפעלה לבחירת מיקום שמירה. אחר כך אני קוראת ל-self.api.download_shared_file שכותבת את הבתים ל-open(dest, "wb"). זו גישה ישירה למערכת הקבצים של המחשב — בדיוק מה שהמחוון דורש.
הפרויקט משתמש בשני APIs חיצוניים גדולים של Google: Firebase Admin SDK (לגישה ל-Firestore) ו-Google Cloud Storage SDK. שניהם מאומתים עם קובץ private key בפורמט JSON.
# 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)
זה שימוש ב-API: אני שולחת בקשות מאומתות לשירותים חיצוניים של Google ומקבלת תוצאות. בשונה ממסד נתונים מקומי שאני מנהלת בעצמי, ה-APIs האלה מטפלים בהפצה גלובלית, בעמידה בעומס, וב-replication.
אם הבוחן שואל ב-Windows API ספציפי (ctypes/win32com/COM/מיקרופון/מצלמה):
"המחוון מציע רשימת חלופות: 'גישה למערכת הקבצים ו/או שימוש ב-API ו/או רכיבי COM ו/או רכיבים אחרים'. אני מקיימת שתי חלופות: גישה למערכת הקבצים (ב-desktop/app.py דרך filedialog) ושימוש ב-APIs של Google. בנוסף אני משתמשת ב-Tkinter שהוא wrapper פייתוני ל-Tcl/Tk שמתממש דרך מנגנוני ה-Window Manager של מערכת ההפעלה (ב-Windows זה GDI/User32). הטרייד-אוף לבחירה הזו: API של Google נותן לי גלובליות ועמידה בעומס אמיתיים, מה שאני לא הייתי משיגה עם COM מקומי."
הדרישה: (1) הצפנה למידע רגיש העובר בתקשורת, (2) טיפול בפרצות אבטחה בפרויקט.
גוגל מצפינים את ה-GCS בעצמם, אבל המפתחות שלהם. אם תוקף יגנוב את ה-bucket (ע"י דליפה במפתחות שירות), הוא יוכל לקרוא את הקבצים. הפתרון שלי: שכבת הצפנה נוספת שאני שולטת בה. גם אם מישהו יגנוב את ה-bucket — הוא יראה Ciphertext בלי המפתחות שלי, ויהיה לו רק "בייטים אקראיים".
בפרויקט יש שלושה סוגי מפתחות:
def generate_dek() -> bytes:
"""Generate a fresh per-file Data Encryption Key (a Fernet key)."""
return Fernet.generate_key()
def encrypt_with_dek(plaintext: bytes, dek: bytes) -> bytes:
"""Encrypt file bytes under a per-file DEK."""
return Fernet(dek).encrypt(plaintext)
def decrypt_with_dek(ciphertext: bytes, dek: bytes) -> bytes:
"""Decrypt file bytes that were encrypted under the given DEK."""
return Fernet(dek).decrypt(ciphertext)
Fernet זה AES-128 ב-CBC mode + HMAC-SHA256 — הצפנה סימטרית מאומתת (Authenticated Encryption). זה אומר שלא רק הקובץ מוצפן, אלא יש חתימה שמוודאת שהוא לא שונה. אם מישהו מנסה לשנות בייט אחד ב-ciphertext, ה-HMAC ייכשל ו-decrypt יזרוק שגיאה.
def rsa_wrap_dek(dek: bytes, public_pem: bytes) -> str:
"""RSA-OAEP-encrypt a DEK under a recipient's public key."""
if isinstance(public_pem, str):
public_pem = public_pem.encode('utf-8')
public_key = serialization.load_pem_public_key(public_pem)
ciphertext = public_key.encrypt(
dek,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
return base64.b64encode(ciphertext).decode('utf-8')
def rsa_unwrap_dek(wrapped_b64: str, private_pem: bytes) -> bytes:
"""Reverse rsa_wrap_dek using the recipient's private-key PEM."""
if isinstance(private_pem, str):
private_pem = private_pem.encode('utf-8')
private_key = serialization.load_pem_private_key(private_pem, password=None)
return private_key.decrypt(
base64.b64decode(wrapped_b64),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
איך עובד שיתוף קובץ?
def set_password(self, plain_password):
# Pin scrypt explicitly so a future Werkzeug default change cannot
# silently weaken hashing. check_password_hash still auto-detects the
# algorithm prefix on verify, so old pbkdf2 hashes keep working.
self._password_hash = generate_password_hash(plain_password, method='scrypt')
def verify_password(self, plain_password):
if not self._password_hash:
return False
return check_password_hash(self._password_hash, plain_password)
סיסמה אף פעם לא מוצפנת — היא מ-Hash. ההבדל: הצפנה היא דו-כיוונית (אפשר להפך), Hash הוא חד-כיווני. מנסחר אקראי (salt) מתווסף לפני ה-Hash כדי שאם יש שני משתמשים עם אותה סיסמה, ה-Hash יוצא שונה.
למה scrypt ולא MD5 או SHA-256? כי MD5 ו-SHA הם מהירים מדי — תוקף שגונב את מסד הנתונים יכול לנסות מיליארדי סיסמאות בשנייה. scrypt תוכנן להיות איטי בכוונה ולדרוש זיכרון רב — מה שמקשה על מתקפות brute-force.
def _resolve_user(token):
from routes.auth import JWT_ISSUER, JWT_AUDIENCE
try:
data = jwt.decode(
token,
app.config['SECRET_KEY'],
algorithms=['HS256'],
issuer=JWT_ISSUER,
audience=JWT_AUDIENCE,
)
except jwt.ExpiredSignatureError:
return None, ({"error": "Token has expired"}, 401)
except jwt.InvalidTokenError:
return None, ({"error": "Invalid token"}, 401)
...
revoke_after = user_data.get('token_revoked_after')
iat = data.get('iat')
if revoke_after and iat is not None:
try:
iat_dt = datetime.utcfromtimestamp(int(iat))
revoke_dt = datetime.fromisoformat(revoke_after)
if iat_dt <= revoke_dt:
return None, ({"error": "Token has been revoked"}, 401)
except (ValueError, TypeError):
return None, ({"error": "Token has been revoked"}, 401)
JWT (JSON Web Token) = "תעודה" שהשרת חתם דיגיטלית, ומכילה את שם המשתמש + זמן הוצאה + זמן תפוגה. הלקוח שולח את התעודה בכל בקשה ב-Header של Authorization. השרת מפענח, מוודא את החתימה (HS256 = HMAC-SHA256 עם SECRET_KEY), בודק שלא פג תוקף, ושלא שוללה. אם הכל בסדר — יודע מי המשתמש.
הסוד שעל פיו חותמים נטען מ-config.py:14 שמייצר אותו אקראית בפעם הראשונה ושומר ב-server/.secret_key (לא ב-git!).
| פרצה | מה זה | הפתרון בפרויקט |
|---|---|---|
| סוד קשיח בקוד | אם SECRET_KEY קשיח בקוד, כל אחד שרואה את הקוד יכול לזייף JWT. | config.py:14–33 — הסוד נטען מ-env var או נוצר אקראית ונשמר בקובץ מחוץ ל-git. |
| CORS פתוח | אם השרת מקבל בקשות מכל מקור, אתר זדוני יכול לדבר עם ה-API בשם המשתמש. | config.py:48–63 — רשימת מקורות מוגדרת, ברירת מחדל localhost בלבד. |
| חוסר Security Headers | בלי CSP, X-Frame-Options — האתר חשוף ל-Clickjacking ו-XSS. | app.py:22–49 — CSP מלא, X-Frame-Options=DENY, nosniff, Referrer-Policy, HSTS על HTTPS. |
| IDOR (Insecure Direct Object Reference) | אם אני יכולה לגשת ל-/api/files/123 של מישהו אחר רק כי אני יודעת את ה-ID. | כל ניתוב בודק check_ownership(username) או uploaded_by == current_user['username']. |
| Zip Bomb / DoS | תיקייה ענקית = הורדה אינסופית = השרת קורס מ-OOM. | directory_item.py:14–15 — MAX_ZIP_FILES=2000, MAX_ZIP_BYTES=512MB. נזרק ZipLimitExceeded אם חורגים. |
| Stored XSS | משתמש מזין שם משתמש <script>alert(1)</script> ואני שם אותו ב-DOM עם innerHTML. | בלקוח אנחנו משתמשים ב-textContent כמעט בכל מקום (123 שימושים) ולא ב-innerHTML לתוכן משתמש. |
| Brute-force סיסמאות | תוקף מנסה אינסוף סיסמאות. | סיסמאות עם scrypt — איטי בכוונה. ניתן להוסיף Rate Limiting בעתיד. |
| הצפנה במנוחה | אם מישהו גונב את ה-GCS bucket, הוא יכול לקרוא קבצים. | Envelope Encryption — שכבה נוספת מעל ההצפנה של גוגל. |
def check_ownership(self, username):
"""True iff this item belongs to the given user."""
return self._owner == username
זה לב הלב של מודל ההרשאות: לכל קובץ ולכל תיקייה יש שדה owner, וכל פעולה (הורדה, מחיקה, שיתוף) קוראת ל-check_ownership(current_user['username']). הנתיבים מחזירים 403 או 404 אם הבדיקה נכשלת. כך לא ניתן לגשת לקובץ של משתמש אחר רק כי יודעים את ה-ID שלו.
הפרויקט מקבל קבצים מהמשתמש ושומר אותם ב-GCS. אנחנו לא בודקים את הבייטים הראשונים (magic number) כדי לוודא שמדובר באמת בקובץ מהסוג שהמשתמש הצהיר עליו. אם משתמש מעלה image.png שהוא בעצם image.png.exe — אנחנו לא נדע.
איך מקלים על הסיכון? אנחנו שומרים את הקובץ עם Content-Type: application/octet-stream ב-GCS, כך שכשמורידים — הדפדפן מוריד כקובץ ולא מריץ. וכן אנחנו עוטפים את השם ב-secure_filename() של Werkzeug שמסיר תווים מסוכנים.
תשובת גיבוי אם הבוחן שואל: "זה שיפור עתידי שאני מתכננת. כיום אני מסתמכת על שני עקיפי הקטנה: secure_filename מסיר תווים בעייתיים מהשם, ובאחסון אני כותבת את כל הקבצים עם content-type=application/octet-stream כדי שגם אם מישהו מצליח להעלות קובץ exe מחופש לתמונה, הדפדפן לא יריץ אותו אלא יוריד אותו. בעתיד הייתי מוסיפה ספרייה כמו python-magic שמזהה לפי 4-8 הבייטים הראשונים את הסוג האמיתי."
בפרויקט יש שני ממשקים:
| דף | תיקייה | פיצ'רים אינטראקטיביים |
|---|---|---|
| בית | client/home/ | גרירה ושחרור (drag-drop) של קבצים להעלאה, חיפוש בזמן אמת, פירורי לחם, מודלים לפעולות, סטטוס העלאה, רשימת מועדפים, שיתוף, מעבר לסל מיחזור, יצירת תיקיות. |
| פרופיל | client/profile/ | עריכת פרטים, החלפת תמונה, שינוי סיסמה, ניתוק מכל המכשירים. |
| אדמין | client/admin/ | סטטיסטיקות מערכת, רשימת משתמשים עם נפח אחסון, מחיקת משתמש. |
| התחברות | client/login/ | ולידציה בצד לקוח, הודעות שגיאה אינטראקטיביות. |
| הרשמה | client/signup/ | אישור סיסמה, ולידציה של אורך/תוים. |
| בכל הדפים | client/theme.js | מתג כהה/בהיר, נשמר ב-localStorage. |
class DesktopApp:
def __init__(self, root):
self.root = root
self.root.title("CloudStorage - Notifications")
self.root.geometry("560x520")
self.api = None
self.nc = None
self._shared_files = []
self._build_login_frame()
self._build_main_frame()
self._show_login()
def _build_login_frame(self):
self.login_frame = ttk.Frame(self.root, padding=20)
ttk.Label(self.login_frame, text="Connect to CloudStorage",
font=("Segoe UI", 14, "bold")).grid(...)
...
def _build_main_frame(self):
self.main_frame = ttk.Frame(self.root, padding=12)
self.status_label = ttk.Label(self.main_frame, ...)
self.notif_text = scrolledtext.ScrolledText(self.main_frame, height=10, ...)
...
self.shared_list = tk.Listbox(self.main_frame, height=8)
...
שני מצבים: דף התחברות ודף ראשי. אחרי התחברות מוצלחת — עוברים לדף הראשי, רואים בלוק "Live notifications" שמתעדכן בזמן אמת מהסוקט, ורשימת קבצים ששותפו איתי.
def _poll_events(self):
"""Drain the socket client's event queue on the UI thread."""
if self.nc is None:
return
try:
while True:
event = self.nc.events.get_nowait()
self._handle_event(event)
except Exception:
pass # queue empty
self.root.after(150, self._poll_events)
def _handle_event(self, event):
etype = event.get("type")
if etype == "shared":
owner = event.get("owner", "someone")
name = event.get("item_name", "an item")
self._log(f"{owner} shared \"{name}\" with you.")
self._refresh_shared()
messagebox.showinfo("New share", f'{owner} shared "{name}" with you.')
בעיה: ה-thread שמקבל הודעות מהסוקט הוא לא ה-UI thread. Tkinter לא thread-safe — אם הייתי קוראת ל-messagebox.showinfo מתוך ה-thread של הסוקט, ה-UI היה קורס. פתרון: ה-thread של הסוקט שם הודעות בתור (queue.Queue), ו-root.after(150, ...) רץ ב-UI thread כל 150 מילי-שניות ושולף הודעות מהתור.
client/ ו-Desktop ב-desktop/app.py. ה-Web אינטראקטיבי במלואו (drag-drop, חיפוש בזמן אמת, מודלים). ה-Desktop ב-Tkinter עם התראות בזמן אמת.queue.Queue). ה-thread של הסוקט שם בו הודעות, ה-UI מאוסס דרך root.after כל 150ms.| שכבה | שם | דוגמה | איפה בפרויקט? |
|---|---|---|---|
| 7 | Application | HTTP, SMTP, DNS, FTP, הפרוטוקול שלי | הפרוטוקול JSON שלי על הסוקט; HTTP של Flask |
| 6 | Presentation | הצפנה (TLS), קידוד | Fernet, RSA, JSON encoding |
| 5 | Session | ניהול חיבור | JWT, חיבור הסוקט הפתוח |
| 4 | Transport | TCP, UDP | TCP על פורט 5000 (HTTP) ו-5001 (סוקט שלי) |
| 3 | Network | IP, ICMP | IPv4 (AF_INET ב-socket.socket) |
| 2 | Data Link | Ethernet, MAC, ARP | מנוהל ע"י מערכת ההפעלה |
| 1 | Physical | חוטים, גלי רדיו | מתחת לראדאר שלנו |
| היבט | TCP | UDP |
|---|---|---|
| קישור | מבוסס חיבור (3-way handshake) | חסר חיבור (connectionless) |
| אמינות | מבטיח הגעה וסדר | לא מבטיח כלום — אפשר לאבד, לקבל לא בסדר, לקבל פעמיים |
| מהירות | איטי יותר (overhead) | מהיר ורזה |
| שימושים | HTTP, SSH, האפליקציה שלי | DNS, VoIP, משחקים |
Client Server │ │ │ ──── SYN (seq=x) ──────────► │ "אני רוצה להתחבר" │ │ │ ◄──── SYN+ACK (seq=y, ack=x+1)──│ "מסכים, גם אני" │ │ │ ──── ACK (ack=y+1) ─────────► │ "סבבה, התחלנו" │ │ │ חיבור פתוח │
שלוש הודעות = "לחיצה משולשת". זו הסיבה ש-TCP יודע שהחיבור באמת קם — שני הצדדים אישרו שהם רואים זה את זה.
192.168.1.7. IPv6: 16 בייטים. הפרויקט שלי משתמש ב-127.0.0.1 (localhost) ב-development.| פרוטוקול | תפקיד | פורט סטנדרטי |
|---|---|---|
| HTTP | תקשורת Web | 80 (HTTPS = 443) |
| SMTP | שליחת אימייל | 25 / 587 |
| DNS | תרגום שם דומיין ל-IP | 53 (UDP בעיקר) |
| ARP | תרגום IP ל-MAC ברשת מקומית (שכבה 2) | — |
| FTP | העברת קבצים | 21 |
| SSH | קונסולת רחוקה מאובטחת | 22 |
במערכת א-סימטרית, יש זוג מפתחות שמתואמים מתמטית. כל מה שהוצפן עם אחד ניתן לפענח רק עם השני.
שלוש פונקציות:
ב-JWT שלי החתימה היא HMAC-SHA256 (חתימה סימטרית) — לא חתימה דיגיטלית אמיתית, אבל מספיק בשביל המקרה שלי, כי השרת שלי גם חותם וגם מאמת.
| היבט | Process | Thread |
|---|---|---|
| זיכרון | זיכרון נפרד לחלוטין | זיכרון משותף עם שאר ה-threads באותו process |
| תקשורת | IPC: pipes, sockets, shared memory | משתנים גלובליים, חפצים משותפים (צריך Lock!) |
| יצירה | יקרה (fork/CreateProcess) | זולה |
| קריסה | process אחד נופל — לא משפיע על אחרים | thread שמקריס סופר exception עלול להפיל את כל ה-process |
| אצלי בפרויקט | Flask + Socket הם תהליך אחד | הרבה threads בתוכו |
WinAPI הוא ספריית הפונקציות של Windows שאפליקציות קוראות אליה בשביל לפנות למערכת ההפעלה: לפתוח קבצים, ליצור threads, לצייר חלונות וכו'. ב-Python יש לי ctypes או pywin32 לקרוא ישירות ל-WinAPI, אבל בפרויקט שלי אני לא קוראת ישירות — אני משתמשת ב-threading ו-socket מהספרייה התקנית של Python, שמתחתם משתמשים ב-WinAPI עבורי. למשל threading.Thread.start() בסופו של דבר קורא ל-CreateThread של Windows.
ה-magic number הם הבייטים הראשונים של קובץ שמזהים את הסוג שלו, ללא תלות בסיומת השם. דוגמאות:
| סוג קובץ | בייטים ראשונים (Hex) |
|---|---|
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| JPEG | FF D8 FF |
| 25 50 44 46 (%PDF) | |
| ZIP / docx / xlsx / jar | 50 4B 03 04 (PK..) |
| PE (Windows EXE/DLL) | 4D 5A (MZ) |
| ELF (Linux) | 7F 45 4C 46 (.ELF) |
למה זה רלוונטי לפרויקט? כי משתמש יכול לשנות שם קובץ מ-evil.exe ל-evil.png, והשרת לא יבדל בלי בדיקת ה-magic. אצלי: אני לא בודקת magic, רק מגנה דרך content-type=octet-stream באחסון (ראה הקופסה הכתומה בסעיף 4).
מערכת קבצים ישנה של Windows ומכשירי USB:
פורמט קבצי הרצה של Windows: .exe, .dll, .sys.
.text (קוד), .data (משתנים), .rdata (קונסטנטות), .rsrc (אייקונים, מחרוזות).תוקף נכנס בין הלקוח לשרת ומקריא/משנה את התקשורת. אצלי:
ssl.wrap_socket.תוקף מזריק קוד SQL לתוך input של משתמש. למשל:
SELECT * FROM users WHERE username = '' OR '1'='1';
אצלי: הסיכון לא רלוונטי — אני משתמשת ב-Firestore (NoSQL) ולא ב-SQL. בנוסף, ספריית Firebase Admin מקבלת ערכים כפרמטרים (לא קונקטנציה של מחרוזות) — מה שמקביל ל-prepared statements.
תשובה לבוחן: "הפרויקט שלי לא חשוף ל-SQL Injection כי הוא לא משתמש ב-SQL. הוא משתמש ב-Firestore — NoSQL. הסיכון המקביל ב-NoSQL הוא NoSQL Injection, אבל ה-SDK של Firebase Admin מעביר ערכים כפרמטרים, לא בונה queries ע"י קונקטנציה של מחרוזות, אז גם הוא לא חשוף."
תוקף שולח קלט גדול יותר מהבאפר שמוקצה לו, וכותב על אזורים אחרים בזיכרון. בעיקרון מאפשר הרצת קוד שרירותי.
אצלי: לא חשוף — Python מנהל זיכרון בעצמו (managed memory). list ו-str ב-Python גדלים דינמית — אין גלישה. ה-buffer overflow רלוונטי לשפות עם ניהול זיכרון ידני: C, C++, Assembly.
תשובה לבוחן: "הפרויקט שלי לא חשוף ל-buffer overflow כי Python היא שפה עם ניהול זיכרון אוטומטי. הסיכון רלוונטי ל-C/C++ עם strcpy, gets ופונקציות שלא בודקות את גודל הקלט."
תוקף מזריק קוד JavaScript לדף שלי. למשל אם משתמש מזין כשם משתמש <script>alert(document.cookie)</script> ואני מציגה את זה ב-DOM עם innerHTML — הסקריפט ירוץ אצל כל מי שצופה.
אצלי: כמעט בכל מקום אני משתמשת ב-textContent ולא ב-innerHTML — וזה מחליף את התווים < ו-> בייצוג טקסטואלי. דוגמה: client/profile/profile.js:13 משתמש ב-textContent להצגת שם משתמש. בנוסף, ה-CSP header חוסם הרצת scripts inline ממקורות זרים.
MAX_ZIP_FILES, MAX_ZIP_BYTES.check_ownership בכל פעולה.תשובות קצרות בעברית, מסודרות מקל לקשה. שאלות 1–10 בסיסיות, 11–20 בינוניות, 21–35 קשות. כשמופיע 📍 פתחי... זה רמז לקובץ שאת צריכה לפתוח אם הבוחן ידרוש "תראי לי בקוד".
file_item.py:17 כתוב class FileItem(StorageItem):. הוא מקבל בחינם את check_ownership, soft_delete, ועוד.soft_delete ב-StorageItem קוראת ל-self.item_type(). אצל FileItem היא מחזירה 'file', אצל DirectoryItem היא מחזירה 'directory'. אותו קוד, התנהגות שונה — לפי הסוג בזמן ריצה._username, _password_hash), וגישה מבחוץ דרך @property. הסיסמה רק דרך set_password כדי להבטיח hashing.{"type":"auth","token":...}. אחר כך ping/pong. השרת דוחף הודעות כמו {"type":"shared",...} כשמישהו משתף איתי קובץ.\n. יש לי באפר שמצטבר; כשמופיע \n אני חותכת את ההודעה. _LineReader ב-socket_server.py:146.recv מחזיר 0 בייטים. ה-thread יוצא מהלולאה ומבצע cleanup ב-finally: מסיר את הלקוח מטבלת החיבורים וסוגר את הסוקט. השרת ממשיך לעבוד._connections נכתבת ונקראת מהרבה threads בו-זמנית. בלי Lock אפשר לקבל race condition — שני threads ישנו את אותו dict באותו זמן והנתונים יהיו שבורים.multiprocessing ל-processes, threading ל-threads.ThreadPoolExecutor(max_workers=8) מבטיח 8 במקביל בלבד.ssl.wrap_socket.textContent ולא ב-innerHTML להצגת תוכן משתמש (123 מופעים על פני 6 קבצים). בנוסף יש CSP header שחוסם scripts inline ממקורות זרים.owner ב-DB. כל פעולה בודקת check_ownership(current_user['username']) או משווה uploaded_by. גם אם משתמש יודע ID של קובץ של מישהו אחר — הוא יקבל 403/404.MAX_ZIP_FILES=2000 ו-MAX_ZIP_BYTES=512MB ב-directory_item.py:14. ברגע שעוברים — חריגה ZipLimitExceeded.users, files, directories, shares, favorites, recycle_bin. הבייטים של הקבצים ב-Google Cloud Storage (אובייקטים בענן). המסלול: users/{username}/files/{timestamp}_{filename}. בהורדה — שולפים מטא-דאטה מ-Firestore, מורידים בייטים מ-GCS, מפענחים, שולחים ללקוח.{"type":"auth","token":...}. השרת בודק, מאשר, רושם בטבלת החיבורים.sharing.py: לוקח את ה-DEK של הקובץ, פותח עם Master KEK, עוטף עם המפתח הציבורי של בוב ב-RSA-OAEP, שומר ב-Firestore. אז קורא ל-notification_hub.push_to_user("bob", {"type":"shared",...}).filedialog.asksaveasfilename.הבוחן יבקש "תראי לי בקוד איפה...". זה תרגיל חזרה: אם את מצליחה לפתוח את הקובץ ולהצביע על השורה תוך 10 שניות, את מוכנה.
| השאלה | פתחי |
|---|---|
| "תראי לי דוגמה למחלקה מופשטת" | server/models/storage_item.py:20 |
| "תראי לי הורשה" | server/models/file_item.py:17 או user.py:207 |
| "תראי לי פולימורפיזם" | server/models/storage_item.py:87 (soft_delete) |
| "תראי לי איפה את יוצרת socket" | server/socket_server.py:84 |
| "תראי לי איפה את עושה bind/listen/accept" | server/socket_server.py:86–97 |
| "תראי לי איפה את יוצרת thread לכל לקוח" | server/socket_server.py:100 |
| "תראי לי את הפרוטוקול שלך" | server/socket_server.py:1–23 (docstring) |
| "תראי לי הצפנה סימטרית" | server/utils/crypto.py:101–113 |
| "תראי לי הצפנה א-סימטרית" | server/utils/crypto.py:182–212 |
| "תראי לי hash של סיסמה" | server/models/user.py:80 |
| "תראי לי איפה את בודקת JWT" | server/utils/auth.py:31 |
| "תראי לי security headers" | server/app.py:22–49 |
| "תראי לי בדיקת בעלות (IDOR)" | server/models/storage_item.py:78 |
| "תראי לי הגנה מ-Zip bomb" | server/models/directory_item.py:14, 259 |
| "תראי לי ThreadPoolExecutor" | server/models/directory_item.py:280 |
| "תראי לי איפה ה-UI מקבל הודעות מהסוקט" | desktop/app.py:132 (_poll_events) |
| "תראי לי איפה את כותבת קובץ ל-disk" | desktop/api_client.py:82 |
| "תראי לי שימוש ב-Lock" | server/socket_server.py:49 |
| "תראי לי איפה הסוקט שולח הודעה ללקוח" | server/socket_server.py:60 (push_to_user) |
| "תראי לי איפה route של Flask קורא לסוקט" | server/routes/sharing.py:130 |
threading.Thread(target=self._handle_client, ...).socket_server.py.filedialog.asksaveasfilename + open(dest, "wb").הפרויקט שלך עומד במלוא הדרישות — יותר מהרגיל. אם הבוחן ישאל משהו שאת לא יודעת, אל תמציאי: "זה נושא שאני פחות חזקה בו" עדיף בהרבה מתשובה לא נכונה. הבוחן אוהב כנות וביטחון.
זכרי: תהליך שלם מקצה לקצה שווה 30%. שליטה בקוד שווה 30%. אם את יודעת לפתוח את המערכת, להעלות קובץ, לשתף, לראות התראה ב-Desktop ולהוריד — את כבר ב-30% הראשונים. אם את יודעת לנווט לקוד בלי לחפש — עוד 30%. השאר זה תיאוריה — והמסמך הזה הוא בדיוק זה.