Add server with basic auth stuff

This commit is contained in:
Tulir Asokan
2020-10-31 21:53:46 +02:00
parent d3adedf3df
commit 9151f4cb6d
54 changed files with 3415 additions and 419 deletions

30
web/src/Button.js Normal file
View File

@ -0,0 +1,30 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html } from "../lib/htm/preact.js"
const Button = ({
type = "button", class: className = "", children,
variant = "filled", size = "normal",
...customProps
}) => {
const props = {
class: `mau-button variant-${variant} size-${size} ${className}`,
type, ...customProps,
}
return html`<button ...${props}>${children}</button>`
}
export default Button

View File

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html } from "../lib/htm/preact.js"
export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
const Spinner = ({ size = 40, noCenter = false, noMargin = false, green = false }) => {
let margin = 0
if (!isNaN(+size)) {
size = +size
@ -39,3 +39,5 @@ export const Spinner = ({ size = 40, noCenter = false, noMargin = false, green =
}
return comp
}
export default Spinner

215
web/src/setup/LoginView.js Normal file
View File

@ -0,0 +1,215 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { useEffect, useLayoutEffect, useRef, useState } from "../../lib/preact/hooks.js"
import { html } from "../../lib/htm/preact.js"
import {
getLoginFlows,
loginMatrix,
requestIntegrationToken,
requestOpenIDToken,
resolveWellKnown,
} from "./matrix-api.js"
import Button from "../Button.js"
import Spinner from "../Spinner.js"
const query = Object.fromEntries(location.search
.substr(1).split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const LoginView = ({ onLoggedIn }) => {
const usernameWrapperRef = useRef()
const usernameRef = useRef()
const serverRef = useRef()
const passwordRef = useRef()
const previousServerValue = useRef()
const [loading, setLoading] = useState(false)
const [userIDFocused, setUserIDFocused] = useState(false)
const [supportedFlows, setSupportedFlows] = useState(["m.login.password"])
const [username, setUsername] = useState("")
const [server, setServer] = useState("")
const [serverURL, setServerURL] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState(null)
const keyDown = evt => {
if ((evt.target.name === "username" && evt.key === ":") || evt.key === "Enter") {
if (evt.target.name === "username") {
serverRef.current.focus()
} else if (evt.target.name === "server") {
passwordRef.current.focus()
}
evt.preventDefault()
} else if (evt.target.name === "server" && !evt.target.value && evt.key === "Backspace") {
usernameRef.current.focus()
}
}
const paste = evt => {
if (usernameRef.current.value !== "" || serverRef.current.value !== "") {
return
}
let data = evt.clipboardData.getData("text")
if (data.startsWith("@")) {
data = data.substr(1)
}
const separator = data.indexOf(":")
if (separator === -1) {
setUsername(data)
} else {
setUsername(data.substr(0, separator))
setServer(data.substr(separator + 1))
serverRef.current.focus()
}
evt.preventDefault()
}
useLayoutEffect(() => usernameRef.current.focus(), [])
const onFocus = () => setUserIDFocused(true)
const onBlur = () => {
setUserIDFocused(false)
if (previousServerValue.current !== server && server) {
previousServerValue.current = server
setSupportedFlows(null)
setError(null)
resolveWellKnown(server).then(url => {
setServerURL(url)
localStorage.mxServerName = server
localStorage.mxHomeserver = url
return getLoginFlows(url)
}).then(flows => {
setSupportedFlows(flows)
}).catch(err => {
setError(err.message)
setSupportedFlows(["m.login.password"])
})
}
}
useEffect(() => {
if (localStorage.mxHomeserver && query.loginToken) {
console.log("Found homeserver in localstorage and loginToken in query, " +
"attempting SSO token login")
setError(null)
setLoading(true)
submit(query.loginToken, localStorage.mxHomeserver)
.catch(err => console.error("Fatal error:", err))
.finally(() => setLoading(false))
const url = new URL(location.href)
url.searchParams.delete("loginToken")
history.replaceState({}, document.title, url.toString())
}
}, [])
const submit = async (token, serverURLOverride) => {
let authInfo
if (token) {
authInfo = {
type: "m.login.token",
token,
}
} else {
authInfo = {
type: "m.login.password",
identifier: {
type: "m.id.user",
user: username,
},
password,
}
}
try {
const actualServerURL = serverURLOverride || serverURL
const [accessToken, userID, realURL] = await loginMatrix(actualServerURL, authInfo)
console.log(userID, realURL)
const openIDToken = await requestOpenIDToken(realURL, userID, accessToken)
console.log(openIDToken)
const integrationData = await requestIntegrationToken(openIDToken)
console.log(integrationData)
localStorage.mxAccessToken = accessToken
localStorage.mxUserID = userID
localStorage.accessToken = integrationData.token
onLoggedIn()
} catch (err) {
setError(err.message)
}
}
const onSubmit = evt => {
evt.preventDefault()
setError(null)
setLoading(true)
submit()
.catch(err => console.error("Fatal error:", err))
.finally(() => setLoading(false))
}
const startSSOLogin = () => {
const redir = encodeURIComponent(location.href)
location.href = `${serverURL}/_matrix/client/r0/login/sso/redirect?redirectUrl=${redir}`
}
const usernameWrapperClick = evt => evt.target === usernameWrapperRef.current
&& usernameRef.current.focus()
const ssoButton = html`
<${Button} type="button" disabled=${!serverURL} onClick=${startSSOLogin}
title=${!serverURL ? "Enter your server name before using SSO" : undefined}>
${loading ? html`<${Spinner} size=30/>` : "Log in with SSO"}
</Button>
`
const disablePwLogin = !username || !server || !serverURL
const loginButton = html`
<${Button} type="submit" disabled=${disablePwLogin}
title=${disablePwLogin ? "Fill out the form before submitting" : undefined}>
${loading ? html`<${Spinner} size=30/>` : "Log in"}
</Button>
`
return html`
<main class="login-view">
<form class="login-box ${error ? "has-error" : ""}" onSubmit=${onSubmit}>
<h1>Stickerpicker setup</h1>
<div class="username input ${userIDFocused ? "focus" : ""}"
ref=${usernameWrapperRef} onClick=${usernameWrapperClick}>
<span onClick=${() => usernameRef.current.focus()}>@</span>
<input type="text" placeholder="username" name="username" value=${username}
onChange=${evt => setUsername(evt.target.value)} ref=${usernameRef}
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}
onPaste=${paste}/>
<span onClick=${() => serverRef.current.focus()}>:</span>
<input type="text" placeholder="example.com" name="server" value=${server}
onChange=${evt => setServer(evt.target.value)} ref=${serverRef}
onKeyDown=${keyDown} onFocus=${onFocus} onBlur=${onBlur}/>
</div>
<input type="password" placeholder="password" name="password" value=${password}
class="password input" ref=${passwordRef}
disabled=${supportedFlows && !supportedFlows.includes("m.login.password")}
onChange=${evt => setPassword(evt.target.value)}/>
<div class="button-group">
${supportedFlows === null && html`<${Spinner} green size=30 />`}
${supportedFlows?.includes("m.login.sso") && ssoButton}
${supportedFlows?.includes("m.login.password") && loginButton}
</div>
${error && html`<div class="error">${error}</div>`}
</form>
</main>`
}
export default LoginView

20
web/src/setup/index.js Normal file
View File

@ -0,0 +1,20 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html, render } from "../../lib/htm/preact.js"
import LoginView from "./LoginView.js"
render(html`<${LoginView} onLoggedIn=${() => console.log("Logged in")}/>`, document.body)

102
web/src/setup/matrix-api.js Normal file
View File

@ -0,0 +1,102 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { tryFetch, integrationPrefix } from "./tryGet.js"
export const resolveWellKnown = async (server) => {
try {
const resp = await fetch(`https://${server}/.well-known/matrix/client`)
const data = await resp.json()
let url = data["m.homeserver"].base_url
if (url.endsWith("/")) {
url = url.slice(0, -1)
}
return url
} catch (err) {
console.error("Resolution failed:", err)
throw new Error(`Failed to resolve Matrix URL for ${server}`)
}
}
export const getLoginFlows = async (address) => {
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {},
{ service: address, requestType: "get login flows" })
const flows = []
for (const flow of data.flows) {
flows.push(flow.type)
}
return flows
}
export const loginMatrix = async (address, authInfo) => {
const data = await tryFetch(`${address}/_matrix/client/r0/login`, {
method: "POST",
body: JSON.stringify({
...authInfo,
/* eslint-disable camelcase */
device_id: "maunium-stickerpicker",
initial_device_display_name: "maunium-stickerpicker",
/* eslint-enable camelcase */
}),
headers: {
"Content-Type": "application/json",
},
}, {
service: address,
requestType: "login",
})
if (data.well_known && data.well_known["m.homeserver"]) {
address = data.well_known["m.homeserver"].base_url || address
if (address.endsWith("/")) {
address = address.slice(0, -1)
}
}
return [data.access_token, data.user_id, address]
}
export const requestOpenIDToken = (address, userID, accessToken) => tryFetch(
`${address}/_matrix/client/r0/user/${userID}/openid/request_token`,
{
method: "POST",
body: "{}",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
},
{
service: "OpenID",
requestType: "token",
},
)
export const requestIntegrationToken = tokenData => tryFetch(
`${integrationPrefix}/account/register`, {
method: "POST",
body: JSON.stringify(tokenData),
headers: {
"Content-Type": "application/json",
},
},
{
service: "sticker server",
requestType: "register",
})
export const logout = () => tryFetch(`${integrationPrefix}/account/logout`, { method: "POST" }, {
service: "sticker server",
requestType: "logout",
})

67
web/src/setup/tryGet.js Normal file
View File

@ -0,0 +1,67 @@
// maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
// Copyright (C) 2020 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
export const integrationPrefix = "../_matrix/integrations/v1"
export const queryToURL = (url, query) => {
if (!Array.isArray(query)) {
query = Object.entries(query)
}
query = query.map(([key, value]) =>
[key, typeof value === "string" ? value : JSON.stringify(value)])
url = `${url}?${new URLSearchParams(query)}`
return url
}
export const tryFetch = async (url, options, reqInfo) => {
if (options.query) {
url = queryToURL(url, options.query)
delete options.query
}
options.headers = {
Authorization: `Bearer ${localStorage.accessToken}`,
...options.headers,
}
const reqName = `${reqInfo.service} ${reqInfo.requestType}`
let resp
try {
resp = await fetch(url, options)
} catch (err) {
console.error(reqName, "request failed:", err)
throw new Error(`Failed to contact ${reqInfo.service}`)
}
if (resp.status === 502) {
console.error("Unexpected", reqName, "request bad gateway:", await resp.text())
throw new Error(`Failed to contact ${reqInfo.service}`)
}
if (reqInfo.raw) {
return resp
} else if (resp.status === 204) {
return
}
let data
try {
data = await resp.json()
} catch (err) {
console.error(reqName, "request JSON parse failed:", err)
throw new Error(`Invalid response from ${reqInfo.service}`)
}
if (resp.status >= 400) {
console.error("Unexpected", reqName, "request status:", resp.status, data)
throw new Error(data.error || data.message || `Invalid response from ${reqInfo.service}`)
}
return data
}

View File

@ -13,8 +13,9 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { html, render, Component } from "../lib/htm/preact.js"
import { Spinner } from "./spinner.js"
import { html, render, Component } from "../../lib/htm/preact.js"
import Spinner from "../Spinner.js"
import * as widgetAPI from "./widget-api.js"
import * as frequent from "./frequently-used.js"
@ -24,23 +25,25 @@ const PACKS_BASE_URL = "packs"
// This is updated from packs/index.json
let HOMESERVER_URL = "https://matrix-client.matrix.org"
// eslint-disable-next-line max-len
const makeThumbnailURL = mxc => `${HOMESERVER_URL}/_matrix/media/r0/thumbnail/${mxc.substr(6)}?height=128&width=128&method=scale`
// We need to detect iOS webkit because it has a bug related to scrolling non-fixed divs
// This is also used to fix scrolling to sections on Element iOS
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/) && navigator.userAgent.match(/AppleWebKit/)
const isMobileSafari = navigator.userAgent.match(/(iPod|iPhone|iPad)/)
&& navigator.userAgent.match(/AppleWebKit/)
export const parseQuery = str => Object.fromEntries(
str.split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const query = Object.fromEntries(location.search
.substr(1).split("&")
.map(part => part.split("="))
.map(([key, value = ""]) => [key, value]))
const supportedThemes = ["light", "dark", "black"]
class App extends Component {
constructor(props) {
super(props)
this.defaultTheme = parseQuery(location.search.substr(1)).theme
this.defaultTheme = query.theme
this.state = {
packs: [],
loading: true,
@ -61,7 +64,8 @@ class App extends Component {
this.defaultTheme = "light"
}
this.stickersByID = new Map(JSON.parse(localStorage.mauFrequentlyUsedStickerCache || "[]"))
this.state.frequentlyUsed.stickers = this._getStickersByID(this.state.frequentlyUsed.stickerIDs)
this.state.frequentlyUsed.stickers = this._getStickersByID(
this.state.frequentlyUsed.stickerIDs)
this.imageObserver = null
this.packListRef = null
this.navRef = null
@ -86,12 +90,14 @@ class App extends Component {
stickers,
},
})
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(stickers.map(sticker => [sticker.id, sticker]))
localStorage.mauFrequentlyUsedStickerCache = JSON.stringify(
stickers.map(sticker => [sticker.id, sticker]))
}
setStickersPerRow(val) {
localStorage.mauStickersPerRow = val
document.documentElement.style.setProperty("--stickers-per-row", localStorage.mauStickersPerRow)
document.documentElement.style.setProperty("--stickers-per-row",
localStorage.mauStickersPerRow)
this.setState({
stickersPerRow: val,
})
@ -116,20 +122,34 @@ class App extends Component {
}
_loadPacks(disableCache = false) {
const cache = disableCache ? "no-cache" : undefined
fetch(`${PACKS_BASE_URL}/index.json`, { cache }).then(async indexRes => {
const args = {
cache: disableCache ? "no-cache" : undefined,
headers: query.user_id && query.token ? {
Authorization: `Bearer ${query.token}`,
"X-Matrix-User-ID": query.user_id,
} : {},
}
fetch(`${PACKS_BASE_URL}/index.json`, args).then(async indexRes => {
if (indexRes.status >= 400) {
this.setState({
loading: false,
error: indexRes.status !== 404 ? indexRes.statusText : null,
})
try {
const errData = await indexRes.json()
this.setState({
loading: false,
error: errData.error,
})
} catch (err) {
this.setState({
loading: false,
error: indexRes.status !== 404 ? indexRes.statusText : null,
})
}
return
}
const indexData = await indexRes.json()
HOMESERVER_URL = indexData.homeserver_url || HOMESERVER_URL
// TODO only load pack metadata when scrolled into view?
for (const packFile of indexData.packs) {
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, { cache })
const packRes = await fetch(`${PACKS_BASE_URL}/${packFile}`, args)
const packData = await packRes.json()
for (const sticker of packData.stickers) {
this.stickersByID.set(sticker.id, sticker)
@ -140,11 +160,15 @@ class App extends Component {
})
}
this.updateFrequentlyUsed()
}, error => this.setState({ loading: false, error }))
}, error => this.setState({
loading: false,
error,
}))
}
componentDidMount() {
document.documentElement.style.setProperty("--stickers-per-row", this.state.stickersPerRow.toString())
document.documentElement.style.setProperty("--stickers-per-row",
this.state.stickersPerRow.toString())
this._loadPacks()
this.imageObserver = new IntersectionObserver(this.observeImageIntersections, {
rootMargin: "100px",
@ -226,27 +250,37 @@ class App extends Component {
render() {
const theme = `theme-${this.state.theme}`
if (this.state.loading) {
return html`<main class="spinner ${theme}"><${Spinner} size=${80} green /></main>`
return html`
<main class="spinner ${theme}">
<${Spinner} size=${80} green />
</main>`
} else if (this.state.error) {
return html`<main class="error ${theme}">
<h1>Failed to load packs</h1>
<p>${this.state.error}</p>
</main>`
return html`
<main class="error ${theme}">
<h1>Failed to load packs</h1>
<p>${this.state.error}</p>
</main>`
} else if (this.state.packs.length === 0) {
return html`<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
return html`
<main class="empty ${theme}"><h1>No packs found 😿</h1></main>`
}
return html`<main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
${this.state.packs.map(pack => html`<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }} iconOverride="settings" />
</nav>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}" ref=${elem => this.packListRef = elem}>
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker} />
${this.state.packs.map(pack => html`<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker} />`)}
<${Settings} app=${this}/>
</div>
</main>`
return html`
<main class="has-content ${theme}">
<nav onWheel=${this.navScroll} ref=${elem => this.navRef = elem}>
<${NavBarItem} pack=${this.state.frequentlyUsed} iconOverride="recent" />
${this.state.packs.map(pack => html`
<${NavBarItem} id=${pack.id} pack=${pack}/>`)}
<${NavBarItem} pack=${{ id: "settings", title: "Settings" }}
iconOverride="settings" />
</nav>
<div class="pack-list ${isMobileSafari ? "ios-safari-hack" : ""}"
ref=${elem => this.packListRef = elem}>
<${Pack} pack=${this.state.frequentlyUsed} send=${this.sendSticker}/>
${this.state.packs.map(pack => html`
<${Pack} id=${pack.id} pack=${pack} send=${this.sendSticker}/>`)}
<${Settings} app=${this}/>
</div>
</main>`
}
}
@ -257,9 +291,9 @@ const Settings = ({ app }) => html`
<button onClick=${app.reloadPacks}>Reload</button>
<div>
<label for="stickers-per-row">Stickers per row: ${app.state.stickersPerRow}</label>
<input type="range" min=2 max=10 id="stickers-per-row" id="stickers-per-row"
value=${app.state.stickersPerRow}
onInput=${evt => app.setStickersPerRow(evt.target.value)} />
<input type="range" min=2 max=10 id="stickers-per-row"
value=${app.state.stickersPerRow}
onInput=${evt => app.setStickersPerRow(evt.target.value)}/>
</div>
<div>
<label for="theme">Theme: </label>
@ -278,25 +312,34 @@ const Settings = ({ app }) => html`
// open the link in the browser instead of just scrolling there, so we need to scroll manually:
const scrollToSection = (evt, id) => {
const pack = document.getElementById(`pack-${id}`)
pack.scrollIntoView({ block: "start", behavior: "instant" })
pack.scrollIntoView({
block: "start",
behavior: "instant",
})
evt.preventDefault()
}
const NavBarItem = ({ pack, iconOverride = null }) => html`
const NavBarItem = ({
pack,
iconOverride = null,
}) => html`
<a href="#pack-${pack.id}" id="nav-${pack.id}" data-pack-id=${pack.id} title=${pack.title}
onClick=${isMobileSafari ? (evt => scrollToSection(evt, pack.id)) : undefined}>
onClick=${isMobileSafari ? evt => scrollToSection(evt, pack.id) : undefined}>
<div class="sticker">
${iconOverride ? html`
<span class="icon icon-${iconOverride}"/>
<span class="icon icon-${iconOverride}" />
` : html`
<img src=${makeThumbnailURL(pack.stickers[0].url)}
alt=${pack.stickers[0].body} class="visible" />
alt=${pack.stickers[0].body} class="visible" />
`}
</div>
</a>
`
const Pack = ({ pack, send }) => html`
const Pack = ({
pack,
send,
}) => html`
<section class="stickerpack" id="pack-${pack.id}" data-pack-id=${pack.id}>
<h1>${pack.title}</h1>
<div class="sticker-list">
@ -307,9 +350,12 @@ const Pack = ({ pack, send }) => html`
</section>
`
const Sticker = ({ content, send }) => html`
const Sticker = ({
content,
send,
}) => html`
<div class="sticker" onClick=${send} data-sticker-id=${content.id}>
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body} />
<img data-src=${makeThumbnailURL(content.url)} alt=${content.body}/>
</div>
`