initial commit

This commit is contained in:
thislight 2024-07-14 20:28:44 +08:00
commit 5449e361d5
46 changed files with 8309 additions and 0 deletions

View 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
View 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
View 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];
},
);