Linux中國

構建一個即時消息應用(七):Access 頁面

本文是該系列的第七篇。

現在我們已經完成了後端,讓我們轉到前端。 我將採用單頁應用程序方案。

首先,我們創建一個 static/index.html 文件,內容如下。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Messenger</title>
    <link rel="shortcut icon" href="data:,">
    <link rel="stylesheet" href="/styles.css">
    <script src="/main.js" type="module"></script>
</head>
<body></body>
</html>

這個 HTML 文件必須為每個 URL 提供服務,並且使用 JavaScript 負責呈現正確的頁面。

因此,讓我們將注意力轉到 main.go 片刻,然後在 main() 函數中添加以下路由:

router.Handle("GET", "/...", http.FileServer(SPAFileSystem{http.Dir("static")}))

type SPAFileSystem struct {
    fs http.FileSystem
}

func (spa SPAFileSystem) Open(name string) (http.File, error) {
    f, err := spa.fs.Open(name)
    if err != nil {
        return spa.fs.Open("index.html")
    }
    return f, nil
}

我們使用一個自定義的文件系統,因此它不是為未知的 URL 返回 404 Not Found,而是轉到 index.html

路由器

index.html 中我們載入了兩個文件:styles.cssmain.js。我把樣式留給你自由發揮。

讓我們移動到 main.js。 創建一個包含以下內容的 static/main.js 文件:

import { guard } from &apos;./auth.js&apos;
import Router from &apos;./router.js&apos;

let currentPage
const disconnect = new CustomEvent(&apos;disconnect&apos;)
const router = new Router()

router.handle(&apos;/&apos;, guard(view(&apos;home&apos;), view(&apos;access&apos;)))
router.handle(&apos;/callback&apos;, view(&apos;callback&apos;))
router.handle(/^/conversations/([^/]+)$/, guard(view(&apos;conversation&apos;), view(&apos;access&apos;)))
router.handle(/^//, view(&apos;not-found&apos;))

router.install(async result => {
    document.body.innerHTML = &apos;&apos;
    if (currentPage instanceof Node) {
        currentPage.dispatchEvent(disconnect)
    }
    currentPage = await result
    if (currentPage instanceof Node) {
        document.body.appendChild(currentPage)
    }
})

function view(pageName) {
    return (...args) => import(`/pages/${pageName}-page.js`)
        .then(m => m.default(...args))
}

如果你是這個博客的關注者,你已經知道它是如何工作的了。 該路由器就是在 這裡 顯示的那個。 只需從 @nicolasparada/router 下載並保存到 static/router.js 即可。

我們註冊了四條路由。 在根路由 / 處,我們展示 homeaccess 頁面,無論用戶是否通過身份驗證。 在 /callback 中,我們展示 callback 頁面。 在 /conversations/{conversationID} 上,我們展示對話或 access 頁面,無論用戶是否通過驗證,對於其他 URL,我們展示一個 not-found 頁面。

我們告訴路由器將結果渲染為文檔主體,並在離開之前向每個頁面調度一個 disconnect 事件。

我們將每個頁面放在不同的文件中,並使用新的動態 import() 函數導入它們。

身份驗證

guard() 是一個函數,給它兩個函數作為參數,如果用戶通過了身份驗證,則執行第一個函數,否則執行第二個。它來自 auth.js,所以我們創建一個包含以下內容的 static/auth.js 文件:

export function isAuthenticated() {
    const token = localStorage.getItem(&apos;token&apos;)
    const expiresAtItem = localStorage.getItem(&apos;expires_at&apos;)
    if (token === null || expiresAtItem === null) {
        return false
    }

    const expiresAt = new Date(expiresAtItem)
    if (isNaN(expiresAt.valueOf()) || expiresAt <= new Date()) {
        return false
    }

    return true
}

export function guard(fn1, fn2) {
    return (...args) => isAuthenticated()
        ? fn1(...args)
        : fn2(...args)
}

export function getAuthUser() {
    if (!isAuthenticated()) {
        return null
    }

    const authUser = localStorage.getItem(&apos;auth_user&apos;)
    if (authUser === null) {
        return null
    }

    try {
        return JSON.parse(authUser)
    } catch (_) {
        return null
    }
}

isAuthenticated() 檢查 localStorage 中的 tokenexpires_at,以判斷用戶是否已通過身份驗證。getAuthUser()localStorage 中獲取經過身份驗證的用戶。

當我們登錄時,我們會將所有的數據保存到 localStorage,這樣才有意義。

Access 頁面

access page screenshot

讓我們從 access 頁面開始。 創建一個包含以下內容的文件 static/pages/access-page.js

const template = document.createElement(&apos;template&apos;)
template.innerHTML = `
 <h1>Messenger</h1>
 <a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`

export default function accessPage() {
    return template.content
}

因為路由器會攔截所有鏈接點擊來進行導航,所以我們必須特別阻止此鏈接的事件傳播。

單擊該鏈接會將我們重定向到後端,然後重定向到 GitHub,再重定向到後端,然後再次重定向到前端; 到 callback 頁面。

Callback 頁面

創建包括以下內容的 static/pages/callback-page.js 文件:

import http from &apos;../http.js&apos;
import { navigate } from &apos;../router.js&apos;

export default async function callbackPage() {
    const url = new URL(location.toString())
    const token = url.searchParams.get(&apos;token&apos;)
    const expiresAt = url.searchParams.get(&apos;expires_at&apos;)

    try {
        if (token === null || expiresAt === null) {
            throw new Error(&apos;Invalid URL&apos;)
        }

        const authUser = await getAuthUser(token)

        localStorage.setItem(&apos;auth_user&apos;, JSON.stringify(authUser))
        localStorage.setItem(&apos;token&apos;, token)
        localStorage.setItem(&apos;expires_at&apos;, expiresAt)
    } catch (err) {
        alert(err.message)
    } finally {
        navigate(&apos;/&apos;, true)
    }
}

function getAuthUser(token) {
    return http.get(&apos;/api/auth_user&apos;, { authorization: `Bearer ${token}` })
}

callback 頁面不呈現任何內容。這是一個非同步函數,它使用 URL 查詢字元串中的 token 向 /api/auth_user 發出 GET 請求,並將所有數據保存到 localStorage。 然後重定向到 /

HTTP

這裡是一個 HTTP 模塊。 創建一個包含以下內容的 static/http.js 文件:

import { isAuthenticated } from &apos;./auth.js&apos;

async function handleResponse(res) {
    const body = await res.clone().json().catch(() => res.text())

    if (res.status === 401) {
        localStorage.removeItem(&apos;auth_user&apos;)
        localStorage.removeItem(&apos;token&apos;)
        localStorage.removeItem(&apos;expires_at&apos;)
    }

    if (!res.ok) {
        const message = typeof body === &apos;object&apos; && body !== null && &apos;message&apos; in body
            ? body.message
            : typeof body === &apos;string&apos; && body !== &apos;&apos;
                ? body
                : res.statusText
        throw Object.assign(new Error(message), {
            url: res.url,
            statusCode: res.status,
            statusText: res.statusText,
            headers: res.headers,
            body,
        })
    }

    return body
}

function getAuthHeader() {
    return isAuthenticated()
        ? { authorization: `Bearer ${localStorage.getItem(&apos;token&apos;)}` }
        : {}
}

export default {
    get(url, headers) {
        return fetch(url, {
            headers: Object.assign(getAuthHeader(), headers),
        }).then(handleResponse)
    },

    post(url, body, headers) {
        const init = {
            method: &apos;POST&apos;,
            headers: getAuthHeader(),
        }
        if (typeof body === &apos;object&apos; && body !== null) {
            init.body = JSON.stringify(body)
            init.headers[&apos;content-type&apos;] = &apos;application/json; charset=utf-8&apos;
        }
        Object.assign(init.headers, headers)
        return fetch(url, init).then(handleResponse)
    },

    subscribe(url, callback) {
        const urlWithToken = new URL(url, location.origin)
        if (isAuthenticated()) {
            urlWithToken.searchParams.set(&apos;token&apos;, localStorage.getItem(&apos;token&apos;))
        }
        const eventSource = new EventSource(urlWithToken.toString())
        eventSource.onmessage = ev => {
            let data
            try {
                data = JSON.parse(ev.data)
            } catch (err) {
                console.error(&apos;could not parse message data as JSON:&apos;, err)
                return
            }
            callback(data)
        }
        const unsubscribe = () => {
            eventSource.close()
        }
        return unsubscribe
    },
}

這個模塊是 fetchEventSource API 的包裝器。最重要的部分是它將 JSON web 令牌添加到請求中。

Home 頁面

home page screenshot

因此,當用戶登錄時,將顯示 home 頁。 創建一個具有以下內容的 static/pages/home-page.js 文件:

import { getAuthUser } from &apos;../auth.js&apos;
import { avatar } from &apos;../shared.js&apos;

export default function homePage() {
    const authUser = getAuthUser()
    const template = document.createElement(&apos;template&apos;)
    template.innerHTML = `
 <div>
 <div>
 ${avatar(authUser)}
 <span>${authUser.username}</span>
 </div>
 <button id="logout-button">Logout</button>
 </div>
 <!-- conversation form here -->
 <!-- conversation list here -->
 `
    const page = template.content
    page.getElementById(&apos;logout-button&apos;).onclick = onLogoutClick
    return page
}

function onLogoutClick() {
    localStorage.clear()
    location.reload()
}

對於這篇文章,這是我們在 home 頁上呈現的唯一內容。我們顯示當前經過身份驗證的用戶和註銷按鈕。

當用戶單擊註銷時,我們清除 localStorage 中的所有內容並重新載入頁面。

Avatar

那個 avatar() 函數用於顯示用戶的頭像。 由於已在多個地方使用,因此我將它移到 shared.js 文件中。 創建具有以下內容的文件 static/shared.js

export function avatar(user) {
    return user.avatarUrl === null
        ? `<figure class="avatar" data-initial="${user.username[0]}"></figure>`
        : `<img class="avatar" src="${user.avatarUrl}" alt="${user.username}&apos;s avatar">`
}

如果頭像網址為 null,我們將使用用戶的姓名首字母作為初始頭像。

你可以使用 attr() 函數顯示帶有少量 CSS 樣式的首字母。

.avatar[data-initial]::after {
    content: attr(data-initial);
}

僅開發使用的登錄

access page with login form screenshot

在上一篇文章中,我們為編寫了一個登錄代碼。讓我們在 access 頁面中為此添加一個表單。 進入 static/ages/access-page.js,稍微修改一下。

import http from &apos;../http.js&apos;

const template = document.createElement(&apos;template&apos;)
template.innerHTML = `
 <h1>Messenger</h1>
 <form id="login-form">
 <input type="text" placeholder="Username" required>
 <button>Login</button>
 </form>
 <a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`

export default function accessPage() {
    const page = template.content.cloneNode(true)
    page.getElementById(&apos;login-form&apos;).onsubmit = onLoginSubmit
    return page
}

async function onLoginSubmit(ev) {
    ev.preventDefault()

    const form = ev.currentTarget
    const input = form.querySelector(&apos;input&apos;)
    const submitButton = form.querySelector(&apos;button&apos;)

    input.disabled = true
    submitButton.disabled = true

    try {
        const payload = await login(input.value)
        input.value = &apos;&apos;

        localStorage.setItem(&apos;auth_user&apos;, JSON.stringify(payload.authUser))
        localStorage.setItem(&apos;token&apos;, payload.token)
        localStorage.setItem(&apos;expires_at&apos;, payload.expiresAt)

        location.reload()
    } catch (err) {
        alert(err.message)
        setTimeout(() => {
            input.focus()
        }, 0)
    } finally {
        input.disabled = false
        submitButton.disabled = false
    }
}

function login(username) {
    return http.post(&apos;/api/login&apos;, { username })
}

我添加了一個登錄表單。當用戶提交表單時。它使用用戶名對 /api/login 進行 POST 請求。將所有數據保存到 localStorage 並重新載入頁面。

記住在前端完成後刪除此表單。

這就是這篇文章的全部內容。在下一篇文章中,我們將繼續使用主頁添加一個表單來開始對話,並顯示包含最新對話的列表。

via: https://nicolasparada.netlify.com/posts/go-messenger-access-page/

作者:Nicolás Parada 選題:lujun9972 譯者:gxlct008 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國