initial commit
This commit is contained in:
commit
5449e361d5
46 changed files with 8309 additions and 0 deletions
124
src/accounts/MastodonOAuth2Callback.tsx
Normal file
124
src/accounts/MastodonOAuth2Callback.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import { useNavigate, useSearchParams } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
Show,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import { acceptAccountViaAuthCode } from "./stores";
|
||||
import { $settings } from "../settings/stores";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import cards from "../material/cards.module.css";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import Img from "../material/Img";
|
||||
import { createRestAPIClient } from "masto";
|
||||
import { Title } from "../material/typography";
|
||||
|
||||
type OAuth2CallbackParams = {
|
||||
code?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
|
||||
const MastodonOAuth2Callback: Component = () => {
|
||||
const progressId = createUniqueId();
|
||||
const titleId = createUniqueId();
|
||||
const [params] = useSearchParams<OAuth2CallbackParams>();
|
||||
const navigate = useNavigate();
|
||||
const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
|
||||
const [siteImg, setSiteImg] = createSignal<{
|
||||
src: string;
|
||||
srcset?: string;
|
||||
blurhash: string;
|
||||
}>();
|
||||
const [siteTitle, setSiteTitle] = createSignal("the Mastodon server");
|
||||
|
||||
onMount(async () => {
|
||||
const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
|
||||
if (!onGoingOAuth2Process) return;
|
||||
const client = createRestAPIClient({
|
||||
url: onGoingOAuth2Process,
|
||||
});
|
||||
const ins = await client.v2.instance.fetch();
|
||||
setDocumentTitle(`Back from ${ins.title}...`);
|
||||
setSiteTitle(ins.title);
|
||||
|
||||
const srcset = []
|
||||
if (ins.thumbnail.versions["@1x"]) {
|
||||
srcset.push(`${ins.thumbnail.versions["@1x"]} 1x`)
|
||||
}
|
||||
if (ins.thumbnail.versions["@2x"]) {
|
||||
srcset.push(`${ins.thumbnail.versions["@2x"]} 2x`)
|
||||
}
|
||||
|
||||
setSiteImg({
|
||||
src: ins.thumbnail.url,
|
||||
blurhash: ins.thumbnail.blurhash,
|
||||
srcset: srcset ? srcset.join(",") : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
|
||||
if (onGoingOAuth2Process && params.code) {
|
||||
const acct = await acceptAccountViaAuthCode(
|
||||
onGoingOAuth2Process,
|
||||
params.code,
|
||||
);
|
||||
$settings.setKey('onGoingOAuth2Process', undefined)
|
||||
navigate('/', {replace: true})
|
||||
return;
|
||||
}
|
||||
|
||||
const error =
|
||||
params.error ||
|
||||
(onGoingOAuth2Process ? "unknown" : "oauth2_unknown_target");
|
||||
const errorDescription =
|
||||
params.error_description ||
|
||||
(error === "unknown"
|
||||
? "Remote server sends nothing"
|
||||
: error === "oauth2_unknown_target"
|
||||
? "Unknown OAuth2 target. This is an internal error. Please contract the application maintainer."
|
||||
: undefined);
|
||||
const urlParams = new URLSearchParams({
|
||||
error,
|
||||
});
|
||||
if (errorDescription) {
|
||||
urlParams.set("errorDescription", errorDescription);
|
||||
}
|
||||
navigate("/accounts/sign-in?" + urlParams.toString(), {
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
return (
|
||||
<div class={cards.layoutCentered}>
|
||||
<div class={cards.card} aria-busy="true" aria-describedby={progressId}>
|
||||
<LinearProgress
|
||||
class={[cards.cardNoPad, cards.cardGutSkip].join(' ')}
|
||||
id={progressId}
|
||||
aria-labelledby={titleId}
|
||||
/>
|
||||
<Show when={siteImg()} fallback={<i aria-busy="true" aria-label="Preparing image..." style={{"height": "235px", display: "block"}}></i>}>
|
||||
<Img
|
||||
src={siteImg()?.src}
|
||||
srcset={siteImg()?.srcset}
|
||||
blurhash={siteImg()?.blurhash}
|
||||
class={[cards.cardNoPad, cards.cardGutSkip].join(' ')}
|
||||
alt={`Banner image for ${siteTitle()}`}
|
||||
style={{"height": "235px", "display": "block"}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Title component="h6" id={titleId}>
|
||||
Contracting {siteTitle}...
|
||||
</Title>
|
||||
<p>
|
||||
If this page stays too long, you can close this page and sign in again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MastodonOAuth2Callback;
|
173
src/accounts/SignIn.tsx
Normal file
173
src/accounts/SignIn.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
import {
|
||||
Component,
|
||||
Show,
|
||||
createEffect,
|
||||
createSelector,
|
||||
createSignal,
|
||||
createUniqueId,
|
||||
onMount,
|
||||
} from "solid-js";
|
||||
import cards from "../material/cards.module.css";
|
||||
import TextField from "../material/TextField.js";
|
||||
import Button from "../material/Button.js";
|
||||
import { useDocumentTitle } from "../utils";
|
||||
import { Title } from "../material/typography";
|
||||
import { css } from "solid-styled";
|
||||
import { LinearProgress } from "@suid/material";
|
||||
import { createRestAPIClient } from "masto";
|
||||
import { getOrRegisterApp } from "./stores";
|
||||
import { useSearchParams } from "@solidjs/router";
|
||||
import { $settings } from "../settings/stores";
|
||||
|
||||
type ErrorParams = {
|
||||
error: string;
|
||||
errorDescription: string;
|
||||
};
|
||||
|
||||
const SignIn: Component = () => {
|
||||
const progressId = createUniqueId();
|
||||
const [params] = useSearchParams<ErrorParams>();
|
||||
const [rawServerUrl, setRawServerUrl] = createSignal("");
|
||||
const [currentState, setCurrentState] = createSignal<
|
||||
"inactive" | "contracting" | "navigating"
|
||||
>("inactive");
|
||||
const [serverUrlHelperText, setServerUrlHelperText] = createSignal("");
|
||||
const [serverUrlError, setServerUrlError] = createSignal(false);
|
||||
const [targetSiteTitle, setTargetSiteTitle] = createSignal("");
|
||||
|
||||
useDocumentTitle("Sign In");
|
||||
css`
|
||||
form {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const serverUrl = () => {
|
||||
const url = rawServerUrl();
|
||||
if (url.length === 0 || /^%w:/.test(url)) {
|
||||
return url;
|
||||
} else {
|
||||
return `https://${url}`;
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const url = serverUrl();
|
||||
setServerUrlError(false);
|
||||
if (url.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
setServerUrlHelperText("Domain is required.");
|
||||
return;
|
||||
}
|
||||
setServerUrlHelperText("");
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
$settings.setKey('onGoingOAuth2Process', undefined)
|
||||
})
|
||||
|
||||
const onStartOAuth2 = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
setCurrentState("contracting");
|
||||
const url = serverUrl();
|
||||
try {
|
||||
setServerUrlError(!!serverUrlHelperText());
|
||||
|
||||
const client = createRestAPIClient({
|
||||
url,
|
||||
});
|
||||
const ins = await client.v2.instance.fetch();
|
||||
setTargetSiteTitle(ins.title);
|
||||
|
||||
const redirectURL = new URL(
|
||||
"./oauth2/mastodon",
|
||||
window.location.href,
|
||||
).toString();
|
||||
|
||||
const app = await getOrRegisterApp(url, redirectURL);
|
||||
if (app === null) {
|
||||
alert("The mastodon server could not be used with tutu.");
|
||||
return;
|
||||
}
|
||||
|
||||
const authStart = new URL("./oauth/authorize", url);
|
||||
const searches = authStart.searchParams;
|
||||
const args = {
|
||||
response_type: "code",
|
||||
client_id: app.clientId,
|
||||
redirect_uri: redirectURL,
|
||||
scope: "read write push",
|
||||
};
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
searches.set(k, v);
|
||||
}
|
||||
$settings.setKey("onGoingOAuth2Process", url)
|
||||
window.location.href = authStart.toString();
|
||||
} catch (e) {
|
||||
setServerUrlHelperText(
|
||||
`Could not contract with the server: "${String(e)}". Please check and try again.`,
|
||||
);
|
||||
setServerUrlError(true);
|
||||
console.error(`Failed to contract ${url}.`, e);
|
||||
} finally {
|
||||
setCurrentState("inactive");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class={cards.layoutCentered}>
|
||||
<Show when={params.error || params.errorDescription}>
|
||||
<div class={cards.card} style={{ "margin-bottom": "20px" }}>
|
||||
<p>Authorization is failed.</p>
|
||||
<p>{params.errorDescription}</p>
|
||||
<p>
|
||||
Please try again later. If the problem persist, you can seek for
|
||||
help from the server administrator.
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class={/*once*/ cards.card}
|
||||
aria-busy={currentState() !== "inactive" ? "true" : "false"}
|
||||
aria-describedby={
|
||||
currentState() !== "inactive" ? progressId : undefined
|
||||
}
|
||||
>
|
||||
<LinearProgress
|
||||
class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
|
||||
id={progressId}
|
||||
sx={currentState() === "inactive" ? { display: "none" } : undefined}
|
||||
/>
|
||||
<form onSubmit={onStartOAuth2}>
|
||||
<Title component="h6">Sign in with Your Mastodon Account</Title>
|
||||
<TextField
|
||||
label="Mastodon Server"
|
||||
name="serverUrl"
|
||||
onInput={setRawServerUrl}
|
||||
required
|
||||
helperText={serverUrlHelperText()}
|
||||
error={!!serverUrlError()}
|
||||
/>
|
||||
|
||||
<div style={{ display: "flex", "justify-content": "end" }}>
|
||||
<Button type="submit" disabled={currentState() !== "inactive"}>
|
||||
{currentState() == "inactive"
|
||||
? "Continue"
|
||||
: currentState() == "contracting"
|
||||
? `Contracting ${new URL(serverUrl()).host}...`
|
||||
: `Moving to ${targetSiteTitle}`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignIn;
|
174
src/accounts/stores.ts
Normal file
174
src/accounts/stores.ts
Normal file
|
@ -0,0 +1,174 @@
|
|||
import { persistentAtom } from "@nanostores/persistent";
|
||||
import { createOAuthAPIClient, createRestAPIClient } from "masto";
|
||||
import { action } from "nanostores";
|
||||
|
||||
export type Account = {
|
||||
site: string;
|
||||
accessToken: string;
|
||||
|
||||
tokenType: string;
|
||||
scope: string;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export const $accounts = persistentAtom<Account[]>("accounts", [], {
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
});
|
||||
|
||||
interface OAuth2AccessToken {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
scope: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
async function oauth2TokenViaAuthCode(app: RegisteredApp, authCode: string) {
|
||||
const resp = await fetch(new URL("./oauth/token", app.site), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: authCode,
|
||||
client_id: app.clientId,
|
||||
client_secret: app.clientSecret,
|
||||
redirect_uri: app.redirectUrl,
|
||||
scope: "read write push",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
switch (resp.status) {
|
||||
case 200:
|
||||
return (await resp.json()) as OAuth2AccessToken;
|
||||
default: {
|
||||
const dict = await resp.json();
|
||||
const explain = dict.error_desciption ?? "Unknown OAuth2 Error";
|
||||
throw new TypeError(explain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const acceptAccountViaAuthCode = action(
|
||||
$accounts,
|
||||
"acceptAccount",
|
||||
async ($store, site: string, authCode: string) => {
|
||||
const app = $registeredApps.get()[site];
|
||||
if (!app) {
|
||||
throw TypeError("application not found");
|
||||
}
|
||||
const token = await oauth2TokenViaAuthCode(app, authCode);
|
||||
|
||||
const acct = {
|
||||
site: app.site,
|
||||
accessToken: token.access_token,
|
||||
tokenType: token.token_type,
|
||||
scope: token.scope,
|
||||
createdAt: token.created_at * 1000,
|
||||
};
|
||||
|
||||
const all = [...$store.get(), acct];
|
||||
$store.set(all);
|
||||
|
||||
return acct;
|
||||
},
|
||||
);
|
||||
|
||||
export type RegisteredApp = {
|
||||
site: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
vapidKey?: string;
|
||||
redirectUrl: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export const $registeredApps = persistentAtom<{
|
||||
[site: string]: RegisteredApp;
|
||||
}>(
|
||||
"registeredApps",
|
||||
{},
|
||||
{
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse,
|
||||
},
|
||||
);
|
||||
|
||||
async function getAppAccessToken(app: RegisteredApp) {
|
||||
const resp = await fetch(new URL("./oauth/token", app.site), {
|
||||
method: 'post',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: app.clientId,
|
||||
client_secret: app.clientSecret,
|
||||
redirect_uri: app.redirectUrl,
|
||||
grant_type: "client_credentials",
|
||||
}),
|
||||
});
|
||||
const dict = await resp.json();
|
||||
return dict.access_token;
|
||||
}
|
||||
|
||||
export const getOrRegisterApp = action(
|
||||
$registeredApps,
|
||||
"getOrRegisterApp",
|
||||
async ($store, site: string, redirectUrl: string) => {
|
||||
const all = $store.get();
|
||||
const savedApp = all[site];
|
||||
if (savedApp && savedApp.redirectUrl === redirectUrl) {
|
||||
const appAccessToken = await getAppAccessToken(savedApp);
|
||||
if (appAccessToken) {
|
||||
const client = createRestAPIClient({
|
||||
url: site,
|
||||
accessToken: appAccessToken,
|
||||
});
|
||||
try {
|
||||
const verify = await client.v1.apps.verifyCredentials();
|
||||
Object.assign(savedApp, {
|
||||
vapidKey: verify.vapidKey,
|
||||
});
|
||||
const oauthClient = createOAuthAPIClient({
|
||||
url: site,
|
||||
accessToken: appAccessToken,
|
||||
});
|
||||
try {
|
||||
await oauthClient.revoke({
|
||||
clientId: savedApp.clientId,
|
||||
clientSecret: savedApp.clientSecret,
|
||||
token: appAccessToken,
|
||||
});
|
||||
} catch {}
|
||||
return savedApp;
|
||||
} finally {
|
||||
$store.set(all);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = createRestAPIClient({
|
||||
url: site,
|
||||
});
|
||||
const app = await client.v1.apps.create({
|
||||
clientName: "TuTu",
|
||||
website: "https://github.com/thislight/tutu",
|
||||
redirectUris: redirectUrl,
|
||||
scopes: "read write push",
|
||||
});
|
||||
if (!app.clientId || !app.clientSecret) {
|
||||
return null;
|
||||
}
|
||||
all[site] = {
|
||||
site,
|
||||
clientId: app.clientId,
|
||||
clientSecret: app.clientSecret,
|
||||
vapidKey: app.vapidKey ?? undefined,
|
||||
redirectUrl: redirectUrl,
|
||||
scope: "read write push",
|
||||
};
|
||||
$store.set(all);
|
||||
return all[site];
|
||||
},
|
||||
);
|
Loading…
Add table
Add a link
Reference in a new issue