sources/tech/20180716 Building a Messenger App- Access Page.md
14 KiB
Building a Messenger App: Access Page
This post is the 7th on a series:
- Part 1: Schema
- Part 2: OAuth
- Part 3: Conversations
- Part 4: Messages
- Part 5: Realtime Messages
- Part 6: Development Login
Now that we’re done with the backend, lets move to the frontend. I will go with a single-page application.
Lets start by creating a file static/index.html
with the following content.
<!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>
This HTML file must be server for every URL and JavaScript will take care of rendering the correct page.
So lets go the the main.go
for a moment and in the main()
function add the following route:
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
}
We use a custom file system so instead of returning 404 Not Found
for unknown URLs, it serves the index.html
.
Router
In the index.html
we loaded two files: styles.css
and main.js
. I leave styling to your taste.
Lets move to main.js
. Create a static/main.js
file with the following content:
import { guard } from './auth.js'
import Router from './router.js'
let currentPage
const disconnect = new CustomEvent('disconnect')
const router = new Router()
router.handle('/', guard(view('home'), view('access')))
router.handle('/callback', view('callback'))
router.handle(/^\/conversations\/([^\/]+)$/, guard(view('conversation'), view('access')))
router.handle(/^\//, view('not-found'))
router.install(async result => {
document.body.innerHTML = ''
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))
}
If you are follower of this blog, you already know how this works. That router is the one showed here. Just download it from @nicolasparada/router and save it to static/router.js
.
We registered four routes. At the root /
we show the home or access page whether the user is authenticated. At /callback
we show the callback page. On /conversations/{conversationID}
we show the conversation or access page whether the user is authenticated and for every other URL, we show a not found page.
We tell the router to render the result to the document body and dispatch a disconnect
event to each page before leaving.
We have each page in a different file and we import them with the new dynamic import()
.
Auth
guard()
is a function that given two functions, executes the first one if the user is authenticated, or the sencond one if not. It comes from auth.js
so lets create a static/auth.js
file with the following content:
export function isAuthenticated() {
const token = localStorage.getItem('token')
const expiresAtItem = localStorage.getItem('expires_at')
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('auth_user')
if (authUser === null) {
return null
}
try {
return JSON.parse(authUser)
} catch (_) {
return null
}
}
isAuthenticated()
checks for token
and expires_at
from localStorage to tell if the user is authenticated. getAuthUser()
gets the authenticated user from localStorage.
When we login, we’ll save all the data to localStorage so it will make sense.
Access Page
Lets start with the access page. Create a file static/pages/access-page.js
with the following content:
const template = document.createElement('template')
template.innerHTML = `
<h1>Messenger</h1>
<a href="/api/oauth/github" onclick="event.stopPropagation()">Access with GitHub</a>
`
export default function accessPage() {
return template.content
}
Because the router intercepts all the link clicks to do its navigation, we must prevent the event propagation for this link in particular.
Clicking on that link will redirect us to the backend, then to GitHub, then to the backend and then to the frontend again; to the callback page.
Callback Page
Create the file static/pages/callback-page.js
with the following content:
import http from '../http.js'
import { navigate } from '../router.js'
export default async function callbackPage() {
const url = new URL(location.toString())
const token = url.searchParams.get('token')
const expiresAt = url.searchParams.get('expires_at')
try {
if (token === null || expiresAt === null) {
throw new Error('Invalid URL')
}
const authUser = await getAuthUser(token)
localStorage.setItem('auth_user', JSON.stringify(authUser))
localStorage.setItem('token', token)
localStorage.setItem('expires_at', expiresAt)
} catch (err) {
alert(err.message)
} finally {
navigate('/', true)
}
}
function getAuthUser(token) {
return http.get('/api/auth_user', { authorization: `Bearer ${token}` })
}
The callback page doesn’t render anything. It’s an async function that does a GET request to /api/auth_user
using the token from the URL query string and saves all the data to localStorage. Then it redirects to /
.
HTTP
There is an HTTP module. Create a static/http.js
file with the following content:
import { isAuthenticated } from './auth.js'
async function handleResponse(res) {
const body = await res.clone().json().catch(() => res.text())
if (res.status === 401) {
localStorage.removeItem('auth_user')
localStorage.removeItem('token')
localStorage.removeItem('expires_at')
}
if (!res.ok) {
const message = typeof body === 'object' && body !== null && 'message' in body
? body.message
: typeof body === 'string' && body !== ''
? 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('token')}` }
: {}
}
export default {
get(url, headers) {
return fetch(url, {
headers: Object.assign(getAuthHeader(), headers),
}).then(handleResponse)
},
post(url, body, headers) {
const init = {
method: 'POST',
headers: getAuthHeader(),
}
if (typeof body === 'object' && body !== null) {
init.body = JSON.stringify(body)
init.headers['content-type'] = 'application/json; charset=utf-8'
}
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('token', localStorage.getItem('token'))
}
const eventSource = new EventSource(urlWithToken.toString())
eventSource.onmessage = ev => {
let data
try {
data = JSON.parse(ev.data)
} catch (err) {
console.error('could not parse message data as JSON:', err)
return
}
callback(data)
}
const unsubscribe = () => {
eventSource.close()
}
return unsubscribe
},
}
This module is a wrapper around the fetch and EventSource APIs. The most important part is that it adds the JSON web token to the requests.
Home Page
So, when the user login, the home page will be shown. Create a static/pages/home-page.js
file with the following content:
import { getAuthUser } from '../auth.js'
import { avatar } from '../shared.js'
export default function homePage() {
const authUser = getAuthUser()
const template = document.createElement('template')
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('logout-button').onclick = onLogoutClick
return page
}
function onLogoutClick() {
localStorage.clear()
location.reload()
}
For this post, this is the only content we render on the home page. We show the current authenticated user and a logout button.
When the user clicks to logout, we clear all inside localStorage and do a reload of the page.
Avatar
That avatar()
function is to show the user’s avatar. Because it’s used in more than one place, I moved it to a shared.js
file. Create the file static/shared.js
with the following content:
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}'s avatar">`
}
We use a small figure with the user’s initial in case the avatar URL is null.
You can show the initial with a little of CSS using the attr()
function.
.avatar[data-initial]::after {
content: attr(data-initial);
}
Development Login
In the previous post we coded a login for development. Lets add a form for that in the access page. Go to static/pages/access-page.js
and modify it a little.
import http from '../http.js'
const template = document.createElement('template')
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('login-form').onsubmit = onLoginSubmit
return page
}
async function onLoginSubmit(ev) {
ev.preventDefault()
const form = ev.currentTarget
const input = form.querySelector('input')
const submitButton = form.querySelector('button')
input.disabled = true
submitButton.disabled = true
try {
const payload = await login(input.value)
input.value = ''
localStorage.setItem('auth_user', JSON.stringify(payload.authUser))
localStorage.setItem('token', payload.token)
localStorage.setItem('expires_at', 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('/api/login', { username })
}
I added a login form. When the user submits the form. It does a POST requets to /api/login
with the username. Saves all the data to localStorage and reloads the page.
Remember to remove this form once you are done with the frontend.
That’s all for this post. In the next one, we’ll continue with the home page to add a form to start conversations and display a list with the latest ones.
via: https://nicolasparada.netlify.com/posts/go-messenger-access-page/
作者:Nicolás Parada 选题:lujun9972 译者:译者ID 校对:校对者ID