פלטפורמה לעמותה לנוער בסיכון
פלטפורמת למידה מקצה לקצה: מערכת וידאו מאובטחת עם הפעלה מבוססת טוקן חתום וסימן מים נע לכל משתמש, מעקב התקדמות, תרומות, תעודות, תמיכה מלאה בעברית ובאנגלית עם RTL ועמידה בתקן נגישות.
מאגר פרטי — הקוד אינו פומבי
עמותה שעובדת עם נוער בסיכון הייתה צריכה להעביר את התוכנית שלה לאונליין: קורסי וידאו, מעקב התקדמות, תעודות בסיום, ותרומות — בעברית ובאנגלית, נגיש, ורץ בתקציב שהניח שהתשובה היא "מסלול חינמי או כלום".
שתי דרישות משכו לכיוונים מנוגדים. תוכן הווידאו הופק עם אנשי מקצוע מזוהים בשמם ולא היה יכול להיות חופשי להפצה — קישור שאפשר להדביק בקבוצת ווטסאפ היה בעיה אמיתית, לא תיאורטית. ומצד שני הפלטפורמה הייתה צריכה לעלות כמעט אפס בהרצה, מה שפסל את מוצרי ה-DRM שפותרים בדיוק את הבעיה הזאת.
צד לקוח ב-React 19, Vite ו-Tailwind מול API ב-Node, TypeScript ו-Express מעל MongoDB.
שום דבר לא מוגש כקישור לקובץ סטטי. כל הפעלה מבקשת הרשאה, וה-API מנפיק טוקן חתום קצר-מועד שקשור לאותו משתמש ולאותו שיעור.
תרשים רצף: הלומד פותח שיעור, השרת בודק רישום לקורס ומנפיק טוקן חתום קצר-מועד הנושא זרע לסימן מים, הלקוח מושך את מקטעי המדיה עם הטוקן, מציג מעליהם סימן מים נע, ומדווח נקודות התקדמות בחזרה לשרת שמנפיק תעודה בסיום.
const grantPlayback = async (user: IUser, lessonId: string): Promise<IPlaybackGrant> => {
const enrolment = await enrolments.find(user.id, lessonId);
if (!enrolment) {
throw new ForbiddenError('not enrolled in this lesson');
}
const expiresAt = new Date(Date.now() + PLAYBACK_TTL_MS);
return {
url: await provider.signedUrl(lessonId, expiresAt),
watermarkSeed: createHmac('sha256', env.WATERMARK_SECRET).update(`${user.id}:${lessonId}`).digest('hex').slice(0, 16),
expiresAt,
};
};זרע סימן המים נגזר ולא נשמר — אותו משתמש ואותו שיעור מייצרים תמיד את אותו זרע, ואי אפשר לחשב אותו בלי סוד השרת. הלקוח הופך את הזרע לשכבת סימון נעה: המיקום והתזמון נגזרים מהזרע, ולכן הוא שונה בין משתמשים ואי אפשר לחתוך אותו בניחוש של המקום שבו הוא יושב.
זה במכוון לא DRM. אדם נחוש עם מקליט מסך עדיין מנצח. מה שזה עוצר הוא האיום האמיתי: כתובת ששותפה בקבוצה, שפגה, והקלטת מסך שנושאת סימן מים הקשור לחשבון שממנו היא באה.
אילוץ העלות הוא שהוליד את החלק המעניין. במקום ספק אחסון אחד, ההעלאות מסווגות ומנותבות, עם רישום שעוקב אחרי צריכת המסלולים החינמיים ואחרי תקינות.
תרשים זרימה: העלאה מסווגת כווידאו ארוך או כמסמכים ותמונות, וידאו נבדק מול המכסה החינמית ומנותב לספק המנוהל או לספק משני, כל הנכסים מקבלים טוקן חתום להפעלה, ורישום ספקים עם בדיקת תקינות מעביר לספק המשני בעת הצורך.
כל ספק יושב מאחורי ממשק אחד, כך שהוספת ספק היא קובץ אחד ורשומת הגדרה:
export interface IMediaProvider {
readonly name: string;
supports: (asset: IAssetDescriptor) => boolean;
remainingQuota: () => Promise<number>;
upload: (asset: IAssetDescriptor, stream: Readable) => Promise<IStoredAsset>;
signedUrl: (assetId: string, expiresAt: Date) => Promise<string>;
}הבחירה שואלת כל ספק אם הוא תומך בנכס וכמה מכסה נשארה לו, ואז בוחרת את הזול ביותר שיכול לקבל אותו. כשספק מדווח על אי-תקינות, הרישום כבר יודע היכן הנכס קיים גם, כך שהמעבר אינו נוגע בנתוני השיעורים.
עברית ואנגלית לכל אורך המערכת, עם תכונות CSS לוגיות במקום כיווניות, כך שהפריסה עוקבת אחרי כיוון הכתיבה במקום להתהפך בדיעבד. הנגישות נעשתה תוך כדי הבנייה: אזורי ציון סמנטיים, הפעלה מלאה במקלדת כולל בנגן ובסרגל ההתקדמות, מיקוד גלוי, פקדים מתויגים, וניגודיות שנבדקה בשני המצבים.
DRM אמיתי עולה לפי הזרמה ומוסיף שרת רישיונות לנתיב הבקשה. עבור מודל האיום הזה — הפצה מזדמנת ולא פיראטיות מסחרית — כתובת חתומה קצרת-מועד יחד עם סימן מים שניתן לשיוך משיגה את רוב ההגנה באפס עלות. הפשרה מוצהרת: זו הרתעה ושיוך, לא מניעה.
ספק אחד היה פשוט בהרבה, ובאופן רגיל הייתי טוען בעדו. כאן הדרישה הייתה עלות הרצה אפסית כמעט, ואף מסלול חינמי בודד לא כיסה גם וידאו ארוך וגם אחסון אובייקטים כללי. הרישום וממשק הספקים הם המחיר של זה; התועלת היא שהפלטפורמה נשארה בתוך המסלולים החינמיים וקיבלה מעבר בין ספקים כתופעת לוואי.
שמירת זרע לכל משתמש ולכל שיעור הייתה חלק נע אחד פחות. גזירה באמצעות HMAC אומרת שאין טבלה לשמור מסונכרנת, אין הגירה כשהשיעורים משתנים, ואין זרע שיכול לדלוף — אבל היא גם אומרת שהחלפת WATERMARK_SECRET מבטלת את הרציפות של כל סימן מים קיים. מקובל, מפני ששום דבר לא תלוי ביציבות של זרע לאורך החלפה.
מבנה הקורס הוא מסמך מקונן ונקרא כיחידה אחת. המחיר זהה לזה של Signet: עדכוני התקדמות שהיו פקודה טרנזקציונית אחת ב-SQL נכתבים כעדכונים מותנים במקום.
נמסר ורץ.
מה שהייתי בודק מחדש: סימן המים מצויר בצד הלקוח, ולכן לקוח שעבר שינוי יכול להסיר אותו. העברת השכבה לצד השרת הייתה סוגרת את זה במחיר של קידוד מחדש — הפשרה הנכונה רק אם הפצה הייתה הופכת לבעיה בפועל.