Node.js自學筆記 (12/12)

接續 Node.js自學筆記 (10/12):完善Address Book

現在要把通訊錄加上權限控制,讓有管理權限的人才可以對通訊錄進行「修改」、「刪除」。若沒有登入管理權限則無法修改通訊錄內容。

首先開啟phpmyadmin,加入新的資料表,名為 admin ,因為只是測試用途,所以這個資料表先只設定三個欄位:id, name, password,其中 passwordSHA1 編碼。

navbar.ejs 加上登入的按鈕,預計登入的網址是 /address-book/login

<ul class="navbar-nav">
    <li class="nav-item active">
        <a class="nav-link" href="/address-book/login">登入</a>
    </li>
</ul>

因為之前已經有做過登入功能了,所以把 views\login.ejs 複製到 views\address-book\login.ejs

src\address_book.js 加上以下路由:

router.get('/login', (req, res) => {
    res.render('address-book/login');
});
router.post('/login', upload.none(), (req, res) => {
    // res.rener('address-book/login');
    res.json(req.body)
});

將資料對比資料庫做驗證

src\address_book.js 的路由加上資料庫資料比對功能:

router.get('/login', (req, res) => {
    if (req.session.admin){
        res.redirect('/address-book/list')
    }else{
        res.render('address-book/login');
    }
});
router.post('/login', upload.none(), (req, res) => {
    const output = {
        body: req.body,
        success: false
    };
    const sql = "SELECT `id`, `name` FROM admins WHERE name=? AND password=SHA1(?)";
    db.query(sql, [req.body.account, req.body.password])
        .then(([r]) => {
            if (r && r.length) {
                // 帳號密碼比對正確
                req.session.admin = r[0];
                output.success = true;
            }
            res.json(output)
        }); 
});

views\address-book\login.ejs 修改為:

<%- include ('../parts/html-head') %>
<%- include ('../parts/navbar') %>
<div class="container">
    <div id="infobar" class="alert" role="alert" style="display: none;">
    </div>
    <form name="form1" method="post" onsubmit="return formCheck();">
        <!-- onsubmit為事件處理器 -->
        <div class="form-group">
            <label for="account">Account</label>
            <input type="text" class="form-control" id="account" name="account">
        </div>
        <div class="form-group">
            <label for="password">Password</label>
            <input type="password" class="form-control" id="password" name="password">
        </div>
        <button type="submit" class="btn btn-primary">Submit</button>
    </form>
</div>
<%- include('../parts/scripts') %>
<script>
    const infobar = $('#infobar');

    function formCheck() {
        infobar.hide();
        const fd = new FormData(document.form1);
        fetch('/address-book/login', {
            method: 'POST',
            body: fd
        })
            .then(r => r.json()) // 後端傳JSON到前端
            .then(obj => {
                console.log(obj)
                if (obj.success) {
                    infobar
                        .removeClass('alert-danger')
                        .removeClass('alert-success')
                        .addClass('alert-success')
                        .text('登入成功');
                    setTimeout(() => {
                        location.reload();
                    }, 1000);
                } else {
                    infobar
                        .removeClass('alert-danger')
                        .removeClass('alert-success')
                        .addClass('alert-danger')
                        .text('登入失敗,帳號或密碼錯誤');
                    setTimeout(() => {
                        infobar.slideUp();
                    }, 1000);
                }
            });
        infobar.show();
        return false; // 目的為不讓表單送出
    };
</script>
<%- include('../parts/html-foot') %>

因為在 index.js 有這樣個一段語法:

app.use((req, res, next) => {
    res.locals.sess = req.session || {};
    next();
});

所以可以在 views\parts\nvbar.ejs 做這樣的判斷:

<ul class="navbar-nav">
    <% if (sess.admin) {%>
    <li class="nav-item active">
        <a class="nav-link"><%= sess.admin.name%></a>
    </li>
    <% } else {%>
    <li class="nav-item active">
        <a class="nav-link" href="/address-book/login">登入</a>
    </li>
    <% }%>
</ul>

製作登出功能

src\address_book.js 加上以下路由:

router.get('/logout', (req, res) => {
    delete req.session.admin;
    res.redirect('/address-book/list')
});

views\parts\nvbar.ejs 的部分內容修改為:

<ul class="navbar-nav">
    <% if (sess.admin) {%>
    <li class="nav-item active">
        <a class="nav-link"><%= sess.admin.name%></a>
    </li>
    <li class="nav-item active">
        <a class="nav-link" href="/address-book/logout">登出</a>
    </li>
    <% } else {%>
    <li class="nav-item active">
        <a class="nav-link" href="/address-book/login">登入</a>
    </li>
    <% }%>
</ul>

在路由層級做 deledit 的權限控管

HTTP狀態碼可以參考這裡:https://zh.wikipedia.org/wiki/HTTP状态码

404:Not Found
403:Forbidden

當使用者不具有權限並嘗試操作 deledit 時,可以在路由層級阻擋,並回傳 403 狀態碼,代表禁止操作。

參考資料:https://expressjs.com/zh-tw/4x/api.html#res.send

res.send([body])

發送HTTP響應。

所述body 參數可以是一個Buffer 對象,一個String ,對象,BooleanArray 。例如:

res.send(Buffer.from('whoop'))
res.send({ some: 'json' })
res.send('<p>some html</p>')
res.status(404).send('Sorry, we cannot find that!')
res.status(500).send({ error: 'something blew up' })

此方法對簡單的非流式響應執行許多有用的任務:例如,它自動分配Content-Length HTTP響應標頭字段(除非先前定義),並提供自動的HEAD和HTTP緩存新鮮度支持。

本來是要把判斷規則放在每一個要判斷權限的路由上,但是路由太多,會造成管理困難。

比較好的作法是把判斷規則放在 middleware 上。

src\address-book.js 加上以下程式碼:

router.use((req, res, next) => { // 所有進入 /address-book 的流量不論協定網址,通通會進入
    const whitelist = ['list', 'login'];
    let u = req.url.split('/')[1]; // 注意這邊拿到的是不包含baseURL的內層URL
    u = u.split('?')[0];
    if (whitelist.indexOf(u) !== -1) {
        next();
    } else {
        if (req.session.admin) {
            next();
        } else {
            res.status(403).send('OUT!')
        }
    }
});

通訊錄資料改為向自家API索取資料

address-book.js 新增以下路由:

router.get('/list2/:page?', (req, res) => {
    res.render('address-book/list2')
});

資料不再是由路由餵進去,而是由 list2.ejs 裡面去 http://localhost:3000/address-book/api/list 抓取資料。這邊其實是模擬SPA的做法,但是沒有做到那麼複雜。

views\address_book\list2.ejs 改為

<style>
    .table .fa-trash-alt {
        color: crimson;
    }
</style>
<%- include ('../parts/html-head') %>
<%- include ('../parts/navbar') %>
<div class="row">
    <div class="col">
        <div class="container">
            <nav aria-label="Page navigation example">
                <ul class="pagination">
                </ul>
            </nav>
            <table class="table table-striped">
                <thead>
                    <tr>
                        <th scope="col">sid</th>
                        <th scope="col">Name</th>
                        <th scope="col">Email</th>
                        <th scope="col">Cell Phone</th>
                        <th scope="col">Birthday</th>
                        <th scope="col">Address</th>
                    </tr>
                </thead>
                <tbody id="dataBody">

                </tbody>
            </table>
        </div>
    </div>

</div>
<%- include('../parts/scripts') %>
<script>
    const dataBody = $('#dataBody');
    const pagination = $('.pagination');
    const paginationTpl = (obj)=>{
        const active = obj.active?'active':'';
        return `<li class="page-item ${active}"><a class="page-link" href="#${obj.page}">${obj.page}</a></li>`;
    };
    const dataRawTpl = (obj) => {
        return `<tr>
                    <td>${obj.sid}</td>
                    <td>${obj.name}</td>
                    <td>${obj.email}</td>
                    <td>${obj.mobile}</td>
                    <td>${obj.birthday}</td>
                    <td>${obj.address}</td>
                </tr>`
    };

    const getDataFromHash = () => {
        let h = location.hash.slice(1) || 1;
        fetch('/address-book/api/list/' + h)
            .then(r => r.json())
            .then(obj => {
                console.log(obj);
                // pagitation
                pagination.empty();
                let str='';
                for (let i=1; i<=obj.totalPages; i++){
                    str += paginationTpl({
                        page: i,
                        active: h==i
                    })
                }
                pagination.append(str);

                // table
                dataBody.empty();
                str='';
                for(let i of obj.rows){
                    str += dataRawTpl(i)
                }
                dataBody.append(str)
            });
    };

    window.addEventListener('hashchange', (event) => {
        getDataFromHash();
    });
    getDataFromHash();
</script>
<%- include('../parts/html-foot') %>

完成!