פיתוח Web Application מלא באמצעות Firebase (חלק שני)


קוד התוכנית שהוצג בפרק זמין אונליין:
https://github.com/ynonp/firebase-hello-world/tree/part-2

הסבר מלא על כל מה שנעשה בגירסת טקסט זמינה בפוסט המלווה:
https://www.tocode.co.il/blog/2018-07-firebase-part2

ביום חמישי אעביר את החלק השני של וובינר פיתוח יישומי Web עם פיירבייס. אם אתם עדיין מתלבטים אם לבוא (מה מתלבטים?! תבואו) או מתכננים אבל אוהבים לדעת מראש מה הולך להיות - זה הקוד שננסה להספיק לכתוב יחד.

אגב1 החלק הראשון של הוובינר הוקלט וכבר באוויר בדף הקלטות מוובינרים. אתם צריכים מנוי לאתר כדי לצפות בו.

אגב2 להרשמה לוובינר של יום חמישי בקרו בקישור:

https://www.tocode.co.il/workshops/39

1. כללי אבטחה לבסיס הנתונים

את החלק הראשון סיימנו עם בסיס נתונים בפיירבייס ואפליקציה שמחוברת אליו ויודעת לקרוא ולכתוב טקסטים ממנו. נקודה אחת שלא התיחסנו אליה היא כללי אבטחת בסיס הנתונים.

להזכירכם, זה הקוד שטען את שורת הטקסט שלנו מבסיס הנתונים:

$('#btn-load').on('click', function() {
    const userid = firebase.auth().currentUser.uid;
    firebase.firestore().collection('taglines').doc(userid).get().then((doc) => {
        $('#tagline').val(doc.data().text);
    });
});

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

בשביל זה יש לפיירבייס מנגנון של כללי אבטחה. הכללים שמורים בקובץ firestore.rules ואפשר לראות ולערוך אותם גם אונליין. כלל האבטחה הראשון שנכתוב יגביל את הגישה לטקסטים כך שכל אחד יוכל לגשת רק לטקסטים שלו:

service cloud.firestore {
  match /databases/{database}/documents {
  match /taglines/{userid} {
    allow read, write: if userid == request.auth.uid;
  }

    match /{document=**} {
      allow read, write: if false;
    }
  }
}

כללי האבטחה מסתיימים בכלל הגנרי שאומר שכל מה שלא צוין במפורש כ"מותר" נחשב "אסור". השורה המעניינת יותר היא הכלל הראשון. הכלל מתיחס לטבלא taglines ולאוביקט כלשהו מתוכה ואומר שבשביל לכתוב או לקרוא מידע מאוביקט זה אתה צריך להגיע עם uid שזהה למזהה האוביקט.

ניזכר בשורה ששמרה את הטקסט כדי לראות איך הדברים מתחברים יחד:

firebase.firestore().collection('taglines').doc(userid).set({text});

מאחר והשתמשתי במזהה המשתמש הנוכחי כדי ליצור את האוביקט, אך ורק אותו משתמש יכול גם לקרוא משדה זה.

לאחר כל שינוי בקובץ firestore.rules יש להעלות את הגירסא החדשה לשרת באמצעות:

$ firebase deploy

2. עדכון הקוד ובניית תוכנית ציור

בפרק של היום נרצה להפוך את הקוד שלנו לתוכנית ציור המציגה פיד של ציורים ואפשרות לכמה אנשים לצייר יחד. בשביל שיהיה נוח לכתוב נעבור תחילה לגירסת Object Oriented של הקוד שכתבנו.

השינוי הראשון יהיה בקובץ index.html שם אעדכן את הטעינה של main.js ואוסיף לו type=module. שינוי זה יאפשר לקובץ main.js לטעון בעצמו את שאר הקבצים של התוכנית. בנוסף אמחק מהקובץ את כל המסכים והכפתורים ואעבור לבנות אותם מתוך קוד JavaScript.

הקובץ index.html לאחר השינוי נראה כך:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome to Firebase Hosting</title>
    <link rel="stylesheet" href="style/style.css" />
    <script src="https://www.gstatic.com/firebasejs/5.2.0/firebase.js"></script>
    <script>
        // Initialize Firebase
        var config = {
            apiKey: "AIzaSyAVsjc5Qh_4WuhUnAIbbNFpmHYTpRzNAqs",
            authDomain: "fir-webinar-2.firebaseapp.com",
            databaseURL: "https://fir-webinar-2.firebaseio.com",
            projectId: "fir-webinar-2",
            storageBucket: "fir-webinar-2.appspot.com",
            messagingSenderId: "401090230941"
        };
        firebase.initializeApp(config);
    </script>

  </head>
  <body>

  <div class="container">

  </div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  <script src="scripts/main.js" type="module"></script>
  </body>
</html>

הקובץ הבא שארצה לערוך הוא main.js. קובץ זה יעבור להשתמש בשני קבצי עזר המייצגים מסכים: login.js ו painter.js. הקובץ יטען את קבצי המסכים ולפי מצב ההתחברות יחליט איזה מסך להציג. כך נראה הקוד:

import Painter from './components/painter.js';
import Login from './components/login.js';

const db = firebase.firestore();
const settings = {/* your settings... */ timestampsInSnapshots: true};
db.settings(settings);

firebase.auth().onAuthStateChanged(function(user) {
    if (user) {
        const painter = new Painter();
        painter.init(document.querySelector('.container'));
    } else {
        const login = new Login();
        login.init(document.querySelector('.container'));
    }
});

הקובץ הבא login.js מגדיר מחלקה עבור מסך ה Login. הפונקציה init של המחלקה מקבלת את אלמנט ה container ופשוט מעדכנת את ה HTML הפנימי שלו כדי להכיל את האלמנטים שנרצה במסך הלוגין, ומוסיפה קוד לטיפול בלחיצה על כפתור ההתחברות:

export default class Login {
    init(el) {
        el.innerHTML = `
<p>Please Sign In To Continue</p>
<button id="btn-signin">Sign In</button>
        `;

        $('#btn-signin').on('click', function() {
            var provider = new firebase.auth.GoogleAuthProvider();
            firebase.auth().signInWithPopup(provider);
        });
    }
}

הרכיב השני הוא הקובץ painter.js והוא מציג כפתור לשמירת תמונה, כפתור לניקוי המסך ולוח ציור:

export default class Painter {
    init(el) {
        el.innerHTML = `
<div id="painter">
    <aside class="toolbar">
        <button id="btn-save">Save</button>
        <button id="btn-clear">Clear</button>
        <p class="status"></p>
    </aside>
    <main>
        <canvas width="800" height="600" />
    </main>
</div>
        `;
    }
}

לסיום כהכנה להמשך וכדי שנוכל לראות משהו בזמן הבדיקה נוסיף קובץ עיצוב style.css:

canvas {
    border: 1px solid purple;
}

אם הכל עבד כמו שצריך תהיה לנו עכשיו אפליקציה הכוללת שני מסכים: מסך Login ומסך ציור כאשר מסך הלוגין כבר מתפקד אך מסך הציור מכיל בסך הכל לוח ציור ריק שאינו מגיב ללחיצות.

3. עדכון קוד תוכנית הציור כך שגם תוכל לצייר

מאחר והפוקוס שלנו הוא לא פיתוח תוכניות ציור אלא תקשורת עם שרת פיירבייס אבנה קוד יחסית רזה לתוכנית הציור: התוכנית בסך הכל תטפל בלחיצות והזזות עכבר כך שאפשר יהיה לצייר קווים אבל לא לשנות צבע או לצייר תבניות מתוחכמות.

שלושת האירועים שמעניינים אותנו הם mousedown, mousemove ו mouseup. בנוסף אליהם יש לנו שני כפתורים שצריך לטפל בלחיצות עליהם: כפתור ניקוי המסך וכפתור השמירה.

ניקוי המסך ינקה מקומית את לוח הציור, וכפתור שמירה יעלה את הציור ל Firebase כאוביקט בתוך האוסף paintings. בדוגמא הקודמת שמרנו טקסט בודד ולכן אפשר היה להשתמש במזהה המשתמש בתור מזהה האוביקט. בדוגמא הפעם למשתמש יכולים להיות מספר ציורים ולכן הוספתי שדה בתוך כל ציור בשם owner שמציין מי המשתמש שזה הציור שלו. הקוד עבור הקובץ painter.js:

export default class Painter {
    constructor() {
        for (let key of Object.getOwnPropertyNames(Painter.prototype)) {
            if (key.startsWith('handle_')) {
                this[key] = this[key].bind(this);
            }
        }
    }

    init(el) {
        el.innerHTML = `
<div id="painter">
    <aside class="toolbar">
        <button id="btn-save">Save</button>
        <button id="btn-clear">Clear</button>
        <p class="status"></p>
    </aside>
    <main>
        <canvas width="800" height="600" />
    </main>
</div>            
        `;

        this.can = el.querySelector('canvas');
        this.ctx = this.can.getContext('2d');

        this.can.addEventListener('mousemove', this.handle_MouseMove);
        this.can.addEventListener('mousedown', this.handle_MouseDown);
        this.can.addEventListener('mouseup', this.handle_MouseUp);
        el.querySelector('#btn-clear').addEventListener('click', this.handle_clear);
        el.querySelector('#btn-save').addEventListener('click', this.handle_save);
    }

    handle_MouseMove(e) {
        if (!this.isDrawing) return;

        this.ctx.beginPath();
        this.ctx.moveTo(...this.lastPosition);
        this.ctx.lineTo(e.offsetX, e.offsetY);
        this.ctx.stroke();
        this.lastPosition = [e.offsetX, e.offsetY];
    }

    handle_MouseDown(e) {
        this.ctx.fillRect(e.offsetX - 1, e.offsetY - 1, 2, 2);
        this.lastPosition = [e.offsetX, e.offsetY];
        this.isDrawing = true;
    }

    handle_MouseUp() {
        this.isDrawing = false;
    }

    handle_clear() {
        this.can.width = this.can.width;
    }

    handle_save() {
        const uid = firebase.auth().currentUser.uid;

        firebase.firestore().collection('paintings').add({
           owner: uid,
           image: this.can.toDataURL(),
        });
    }
}

גם את קובץ כללי האבטחה נרצה לעדכן. אין לי בעיה שכל המשתמשים יוכלו לראות את הציורים של כולם, אבל כן חשוב שמשתמשים לא יוכלו למחוק או לשנות ציורים של אחרים. קובץ הכללים לכן יראה עכשיו כך:

service cloud.firestore {
  match /databases/{database}/documents {
  match /taglines/{userid} {
    allow read, write: if userid == request.auth.uid;
  }

    match /paintings/{id} {
        allow create: if request.auth.uid != null;
        allow read: if true;
        allow write: if resource.owner == request.auth.uid;
    }

    match /{document=**} {
      allow read, write: if false;
    }
  }
}

השורה הראשונה בתוך הכלל שמתיחס ל paintings אומרת שרק משתמשים המחוברים למערכת יכולים ליצור ציורים, השורה השניה אומרת שכולם יכולים לראות את הציורים של כולם והשלישית אומרת שכל משתמש יכול לעדכן ולמחוק את הציורים שלו בלבד.

4. הצגת פיד ציורים בזמן אמת

ליד אזור הציור המרכזי ארצה להציג פיד של כל הציורים שכולם מציירים. כל פעם שמשתמש כלשהו (כולל אני) לוחץ Save הציור החדש יתווסף לפיד.

בשביל זה נתחיל בבניית הקובץ feed.js שיציג את הרשימה:

export default class Feed {
    constructor() {
        this.handle_newData = this.handle_newData.bind(this);
    }

    init(el) {
        el.innerHTML = `
<div id='feed'>
    <ul class="thumbnails">

    </ul>
</div>
`;

        firebase.firestore().collection('paintings').onSnapshot(this.handle_newData);
        this.list = el.querySelector('.thumbnails');
    }

    handle_newData(snapshot) {
        snapshot.docChanges().forEach((change) => {
            if (change.type === "added") {
                const data = change.doc.data();
                $(this.list).prepend(`
                    <li>
                        <img src="${data.image}" />
                    </li>
                `);
            }
        });
    }
}

החלק המעניין בקובץ הוא הפונקציה handle_newData. פונקציה זו נקראת בכל פעם שיש שינוי באוסף paintings כלומר כל פעם שמסמך חדש מצטרף לאוסף או שמסמך נמחק ממנו. אין לנו תמיכה במחיקת ציורים בינתיים לכן האירוע היחיד שנצטרך להתמודד איתו הוא הוספה.

מאחר והפונקציה נקראת גם בעליית העמוד מספיק לרשום אותה כדי למלא את הפיד בציורים שנשמרו אפילו לפני שהפעלנו את המערכת.

נוסיף את השורות הבאות לפונקציה init של painter.js כדי ליצור את הפיד:

const feed = new Feed();
feed.init(el.querySelector('#feed-container'));

ונסיים בתוספת קטנה לעיצוב כדי שהפיד והצייר יופיעו אחד ליד השני:

canvas {
    border: 1px solid purple;
}

.col {
    display: inline-block;
    height: 80vh;
    vertical-align: top;
}

.thumbnails {
    list-style: none;
}

.thumbnails li {
    height: 100px;
    max-width: 100%;
    border-bottom: 1px solid black;
}

.thumbnails img {
    max-width: 100%;
    max-height: 100%;
}

.col.aside {
    width: 300px;
    overflow: auto;
    background-color: #fff;
    background-image:
            linear-gradient(90deg, transparent 79px, #abced4 79px, #abced4 81px, transparent 81px),
            linear-gradient(#eee .1em, transparent .1em);
    background-size: 100% 1.2em;
}

ובנינו תוכנית ציור בה אתם יכולים לצייר, לשמור את הציורים וכל האחרים רואים את מה שציירתם.

5. ציור משותף

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

בשביל זה נוסיף לאוביקט painting שדה חדש בשם events. שדה זה יכיל את כל פעולות הציור של תמונה זו. בכל פעם שנזיז את העכבר נעדכן גם את פיירבייס בפעולת הציור עם הקוד הבא:

    handle_MouseMove(e) {
        if (!this.isDrawing) return;
        this.drawLine(this.lastPosition, [e.offsetX, e.offsetY]);

        if (this.paintingId) {
            const id = this.paintingId;
            firebase.firestore().collection(`/paintings/${id}/events`).add({
               painter: this.myPainterId,
               from: this.lastPosition,
               to: [e.offsetX, e.offsetY],
            });
        }

        this.lastPosition = [e.offsetX, e.offsetY];
    }

לפני שממשיכים נרצה לעדכן את קובץ כללי האבטחה כך שכל משתמש יוכל לצייר על כל ציור. אלה הכללים המעודכנים:

service cloud.firestore {
  match /databases/{database}/documents {
  match /taglines/{userid} {
    allow read, write: if userid == request.auth.uid;
  }

    match /paintings/{id} {
        allow create: if request.auth.uid != null;
        allow read: if true;
        allow write: if resource.owner == request.auth.uid;
        match /events {
            allow read, write: if true;
        }
    }

    match /{document=**} {
      allow read, write: if false;
    }
  }
}

הערת אגב- אפשר לדמיין מצב בו נרצה שכל משתמש יוכל לשנות את הציורים שלו וכל האחרים יוכלו "להקשיב" לשינויים אבל לא לצייר בעצמם על ציורים של אחרים. במצב כזה נחליף את הבלוק שמטפל ב events לקוד הבא:

        match /events {
            allow read, write: if true;
            allow write: if get(/databases/$(database)/documents/paintings/{id}).data.owner == request.auth.uid;
        }

עכשיו אפשר להוסיף לקובץ painter.js את הפונקציה handle_joinPainting שגורמת למשתמש "להצטרף" לציור מסוים. לאחר ההצטרפות כל אירוע ציור על אותו לוח יגרום לציור גם על המסך שלי:

    handle_joinPainting(id) {
        this.paintingId = id;

        if (this.leavePainting) {
            this.leavePainting();
            this.paintingId = null;
        }

        this.leavePainting = firebase.
        firestore().
        collection(`/paintings/${id}/events`).
        onSnapshot((snapshot) => {
            snapshot.docChanges().forEach((change) => {
                if (change.type === "added") {
                    const data = change.doc.data();
                    if (data.painter !== this.myPainterId) {
                        this.drawLine(data.from, data.to);
                    }
                }
            });
        });
    }

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

ליצור ערך ייחודי זה לא דבר פשוט באופן כללי ובפרט גם לא כאן. אי אפשר לקחת את מזהה המשתמש כי יכול להיות שלאותו משתמש יש כמה חלונות פתוחים. אני הפעלתי את Math.random רק בשביל הדוגמא ושמרתי את הערך, אבל לאפליקציות אמיתיות תרצו לשמור את כל המזהים שכבר יצרתם כדי לא לחזור על עצמכם.

מקום טוב להפעיל את פונקציית ההצטרפות הוא מתוך אוביקט feed בכל פעם שמשתמש לוחץ על תמונה.

לסיכום הפלטפורמה של פיירבייס מספקת לכם דרך טובה לבנות יישומים גדולים גם בלי שתצטרכו לכתוב קוד צד שרת. כל הפוסט הזה יופיע גם כוידאו אחרי יום חמישי למי שמעדיפים את הפורמט הויזואלי וכמובן ביום חמישי מוזמנים לבוא לראות אותי כותב את זה בשידור חי ולעזור למצוא את הבאגים.

נתראה, ינון