diff --git a/sticker/server/api/setup.py b/sticker/server/api/setup.py index 7a9dc30..3a60001 100644 --- a/sticker/server/api/setup.py +++ b/sticker/server/api/setup.py @@ -13,7 +13,20 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . - from aiohttp import web +from ..database import User, AccessToken + routes = web.RouteTableDef() + + +@routes.get("/whoami") +async def whoami(req: web.Request) -> web.Response: + user: User = req["user"] + token: AccessToken = req["token"] + return web.json_response({ + "id": user.id, + "widget_secret": user.widget_secret, + "homeserver_url": user.homeserver_url, + "last_seen": int(token.last_seen_date.timestamp() / 60) * 60, + }) diff --git a/sticker/server/database/access_token.py b/sticker/server/database/access_token.py index 1771328..a5d04ce 100644 --- a/sticker/server/database/access_token.py +++ b/sticker/server/database/access_token.py @@ -37,7 +37,8 @@ class AccessToken(Base): @classmethod async def get(cls, token_id: int) -> Optional['AccessToken']: - q = "SELECT user_id, token_hash, last_seen_ip, last_seen_date FROM pack WHERE token_id=$1" + q = ("SELECT user_id, token_hash, last_seen_ip, last_seen_date " + "FROM access_token WHERE token_id=$1") row: asyncpg.Record = await cls.db.fetchrow(q, token_id) if row is None: return None @@ -48,7 +49,7 @@ class AccessToken(Base): == datetime.now().replace(second=0, microsecond=0)): # Same IP and last seen on this minute, skip update return - q = ("UPDATE access_token SET last_seen_ip=$3, last_seen_date=current_timestamp " + q = ("UPDATE access_token SET last_seen_ip=$2, last_seen_date=current_timestamp " "WHERE token_id=$1 RETURNING last_seen_date") self.last_seen_date = await self.db.fetchval(q, self.token_id, ip) self.last_seen_ip = ip diff --git a/web/src/setup/App.js b/web/src/setup/App.js new file mode 100644 index 0000000..7029b02 --- /dev/null +++ b/web/src/setup/App.js @@ -0,0 +1,73 @@ +// 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 . +import { useEffect, useState } from "../../lib/preact/hooks.js" +import { html } from "../../lib/htm/preact.js" + +import LoginView from "./LoginView.js" +import Spinner from "../Spinner.js" +import * as matrix from "./matrix-api.js" +import * as sticker from "./sticker-api.js" + +const App = () => { + const [loggedIn, setLoggedIn] = useState(Boolean(localStorage.mxAccessToken)) + const [widgetSecret, setWidgetSecret] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + if (!loggedIn) { + return html` + <${LoginView} + onLoggedIn=${() => setLoggedIn(Boolean(localStorage.mxAccessToken))} + />` + } + + useEffect(() => { + if (widgetSecret === null) { + setLoading(true) + const whoamiReceived = data => { + setLoading(false) + setWidgetSecret(data.widget_secret) + } + const reauth = async () => { + const openIDToken = await matrix.requestOpenIDToken( + localStorage.mxHomeserver, localStorage.mxUserID, localStorage.mxAccessToken) + const integrationData = await matrix.requestIntegrationToken(openIDToken) + localStorage.stickerSetupAccessToken = integrationData.token + return await sticker.whoami() + } + const whoamiErrored = err => { + console.error("Setup API whoami returned", err) + if (err.code === "NET.MAUNIUM_TOKEN_EXPIRED" || err.code === "M_UNKNOWN_TOKEN") { + return reauth().then(whoamiReceived) + } else { + throw err + } + } + sticker.whoami().then(whoamiReceived, whoamiErrored).catch(err => { + setLoading(false) + setError(err.message) + }) + } + }, []) + + if (loading) { + return html`<${Spinner} size=80 green />` + } + + return html`${widgetSecret}` +} + +export default App diff --git a/web/src/setup/LoginView.js b/web/src/setup/LoginView.js index ce5abe7..4563234 100644 --- a/web/src/setup/LoginView.js +++ b/web/src/setup/LoginView.js @@ -16,13 +16,7 @@ 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 * as matrix from "./matrix-api.js" import Button from "../Button.js" import Spinner from "../Spinner.js" @@ -87,11 +81,11 @@ const LoginView = ({ onLoggedIn }) => { previousServerValue.current = server setSupportedFlows(null) setError(null) - resolveWellKnown(server).then(url => { + matrix.resolveWellKnown(server).then(url => { setServerURL(url) localStorage.mxServerName = server localStorage.mxHomeserver = url - return getLoginFlows(url) + return matrix.getLoginFlows(url) }).then(flows => { setSupportedFlows(flows) }).catch(err => { @@ -135,15 +129,13 @@ const LoginView = ({ onLoggedIn }) => { } 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) + const [accessToken, userID, realURL] = await matrix.login(actualServerURL, authInfo) + const openIDToken = await matrix.requestOpenIDToken(realURL, userID, accessToken) + const integrationData = await matrix.requestIntegrationToken(openIDToken) + localStorage.mxHomeserver = realURL localStorage.mxAccessToken = accessToken localStorage.mxUserID = userID - localStorage.accessToken = integrationData.token + localStorage.stickerSetupAccessToken = integrationData.token onLoggedIn() } catch (err) { setError(err.message) diff --git a/web/src/setup/index.js b/web/src/setup/index.js index efa122e..11b0140 100644 --- a/web/src/setup/index.js +++ b/web/src/setup/index.js @@ -15,6 +15,6 @@ // along with this program. If not, see . import { html, render } from "../../lib/htm/preact.js" -import LoginView from "./LoginView.js" +import App from "./App.js" -render(html`<${LoginView} onLoggedIn=${() => console.log("Logged in")}/>`, document.body) +render(html`<${App} />`, document.body) diff --git a/web/src/setup/matrix-api.js b/web/src/setup/matrix-api.js index 09ef99b..ca36c20 100644 --- a/web/src/setup/matrix-api.js +++ b/web/src/setup/matrix-api.js @@ -13,7 +13,6 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - import { tryFetch, integrationPrefix } from "./tryGet.js" export const resolveWellKnown = async (server) => { @@ -41,7 +40,7 @@ export const getLoginFlows = async (address) => { return flows } -export const loginMatrix = async (address, authInfo) => { +export const login = async (address, authInfo) => { const data = await tryFetch(`${address}/_matrix/client/r0/login`, { method: "POST", body: JSON.stringify({ @@ -67,6 +66,17 @@ export const loginMatrix = async (address, authInfo) => { return [data.access_token, data.user_id, address] } +export const whoami = (address, accessToken) => tryFetch( + `${address}/_matrix/client/r0/account/whoami`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + { + service: address, + requestType: "whoami", + }, +) + export const requestOpenIDToken = (address, userID, accessToken) => tryFetch( `${address}/_matrix/client/r0/user/${userID}/openid/request_token`, { diff --git a/web/src/setup/sticker-api.js b/web/src/setup/sticker-api.js new file mode 100644 index 0000000..ca5ba3e --- /dev/null +++ b/web/src/setup/sticker-api.js @@ -0,0 +1,34 @@ +// 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 . + +import { tryFetch as tryFetchDefault, setupPrefix } from "./tryGet.js" + +const service = "setup API" + +const tryFetch = (url, options, reqInfo) => { + if (!options.headers?.Authorization) { + if (!options.headers) { + options.headers = {} + } + options.headers.Authorization = `Bearer ${localStorage.stickerSetupAccessToken}` + } + return tryFetchDefault(url, options, reqInfo) +} + +export const whoami = () => tryFetch( + `${setupPrefix}/whoami`, + {}, { service, requestType: "whoami" }, +) diff --git a/web/src/setup/tryGet.js b/web/src/setup/tryGet.js index 39d3680..a671524 100644 --- a/web/src/setup/tryGet.js +++ b/web/src/setup/tryGet.js @@ -13,8 +13,8 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . - export const integrationPrefix = "../_matrix/integrations/v1" +export const setupPrefix = "api" export const queryToURL = (url, query) => { if (!Array.isArray(query)) { @@ -26,15 +26,19 @@ export const queryToURL = (url, query) => { return url } +class MatrixError extends Error { + constructor(data, status) { + super(data.error) + this.code = data.errcode + this.httpStatus = status + } +} + 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 { @@ -59,7 +63,9 @@ export const tryFetch = async (url, options, reqInfo) => { console.error(reqName, "request JSON parse failed:", err) throw new Error(`Invalid response from ${reqInfo.service}`) } - if (resp.status >= 400) { + if (data.error && data.errcode) { + throw new MatrixError(data, resp.status) + } else 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}`) }