diff --git a/package.json b/package.json index e775a18..08a08f9 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@atcute/client": "^2.0.4", + "@atcute/oauth-browser-client": "^1.0.5", "@solidjs/router": "^0.15.1", "hls.js": "^1.5.17", "public-transport": "file:pkg/pt.tgz", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70909eb..a1c2ab4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@atcute/client': specifier: ^2.0.4 version: 2.0.4 + '@atcute/oauth-browser-client': + specifier: ^1.0.5 + version: 1.0.5 '@solidjs/router': specifier: ^0.15.1 version: 0.15.1(solid-js@1.9.3) @@ -61,6 +64,9 @@ packages: '@atcute/client@2.0.4': resolution: {integrity: sha512-bKA6KEOmrdhU2CDRNp13M4WyKN0EdrVLKJffzPo62ANSTMacz5hRJhmvQYwuo7BZSGIoDql4sH+QR6Xbk3DERg==} + '@atcute/oauth-browser-client@1.0.5': + resolution: {integrity: sha512-UUs2WFMh22rXOapRM848WfWtvgaxV/ji0tEupFrrBYe2i+/UlwhXcphlqdwm43LBsFtMWtV1Xsy2zmnItf0Akg==} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -914,6 +920,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.0.8: + resolution: {integrity: sha512-TcJPw+9RV9dibz1hHUzlLVy8N4X9TnwirAjrU08Juo6BNKggzVfP2ZJ/3ZUSq15Xl5i85i+Z89XBO90pB2PghQ==} + engines: {node: ^18 || >=20} + hasBin: true + node-fetch-native@1.6.4: resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} @@ -1204,6 +1215,11 @@ snapshots: '@atcute/client@2.0.4': {} + '@atcute/oauth-browser-client@1.0.5': + dependencies: + '@atcute/client': 2.0.4 + nanoid: 5.0.8 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -2031,6 +2047,8 @@ snapshots: nanoid@3.3.7: {} + nanoid@5.0.8: {} + node-fetch-native@1.6.4: {} node-releases@2.0.18: {} diff --git a/public/client-metadata.json b/public/client-metadata.json new file mode 100644 index 0000000..638b744 --- /dev/null +++ b/public/client-metadata.json @@ -0,0 +1,12 @@ +{ + "client_id": "https://pdsls.dev/client-metadata.json", + "client_name": "pdsls", + "client_uri": "https://pdsls.dev", + "redirect_uris": ["https://pdsls.dev/"], + "scope": "atproto transition:generic", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +} diff --git a/src/lib/json.tsx b/src/components/json.tsx similarity index 100% rename from src/lib/json.tsx rename to src/components/json.tsx diff --git a/src/components/login.tsx b/src/components/login.tsx new file mode 100644 index 0000000..2962d9a --- /dev/null +++ b/src/components/login.tsx @@ -0,0 +1,159 @@ +import { createSignal, onMount, Show, type Component } from "solid-js"; +import { + configureOAuth, + createAuthorizationUrl, + finalizeAuthorization, + getSession, + OAuthUserAgent, + resolveFromIdentity, + type Session, +} from "@atcute/oauth-browser-client"; +import { At } from "@atcute/client/lexicons"; + +configureOAuth({ + metadata: { + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URL, + }, +}); + +const [loginState, setLoginState] = createSignal(false); +const [notice, setNotice] = createSignal(""); +const [handle, setHandle] = createSignal(""); +let agent: OAuthUserAgent; + +const resolveDid = async (did: string) => { + const res = await fetch( + did.startsWith("did:web") ? + `https://${did.split(":")[2]}/.well-known/did.json` + : "https://plc.directory/" + did, + ); + + return res + .json() + .then((doc) => { + for (const alias of doc.alsoKnownAs) { + if (alias.includes("at://")) { + return alias.split("//")[1]; + } + } + }) + .catch(() => ""); +}; + +const Login: Component = () => { + const [loginInput, setLoginInput] = createSignal(""); + + const loginBsky = async (handle: string) => { + try { + setNotice(`Resolving your identity...`); + const resolved = await resolveFromIdentity(handle); + + setNotice(`Contacting your data server...`); + const authUrl = await createAuthorizationUrl({ + scope: import.meta.env.VITE_OAUTH_SCOPE, + ...resolved, + }); + + setNotice(`Redirecting...`); + await new Promise((resolve) => setTimeout(resolve, 250)); + + location.assign(authUrl); + } catch { + setNotice("Error during OAuth login"); + } + }; + + return ( +
+
e.preventDefault()}> +
+ +
+
+ setLoginInput(e.currentTarget.value)} + /> + +
+
+ +
{notice()}
+
+
+ ); +}; + +const LoginStatus: Component = () => { + onMount(async () => { + setNotice("Loading..."); + + const init = async (): Promise => { + const params = new URLSearchParams(location.hash.slice(1)); + + if (params.has("state") && (params.has("code") || params.has("error"))) { + history.replaceState(null, "", location.pathname + location.search); + + const session = await finalizeAuthorization(params); + const did = session.info.sub; + + localStorage.setItem("lastSignedIn", did); + return session; + } else { + const lastSignedIn = localStorage.getItem("lastSignedIn"); + + if (lastSignedIn) { + try { + return await getSession(lastSignedIn as At.DID); + } catch (err) { + localStorage.removeItem("lastSignedIn"); + throw err; + } + } + } + }; + + const session = await init().catch(() => {}); + + if (session) { + agent = new OAuthUserAgent(session); + setHandle(await resolveDid(agent.sub)); + setLoginState(true); + } + + setNotice(""); + }); + + const logoutBsky = async () => { + setLoginState(false); + await agent.signOut(); + }; + + return ( + +
+ Logged in as @{handle()} + logoutBsky()} + > + Logout + +
+
+ ); +}; + +export { Login, LoginStatus, loginState, agent }; diff --git a/src/lib/svg.tsx b/src/components/svg.tsx similarity index 100% rename from src/lib/svg.tsx rename to src/components/svg.tsx diff --git a/src/lib/video-player.tsx b/src/components/video-player.tsx similarity index 100% rename from src/lib/video-player.tsx rename to src/components/video-player.tsx diff --git a/src/index.tsx b/src/index.tsx index 635aad6..8941470 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,8 @@ /* @refresh reload */ import { render } from "solid-js/web"; import "virtual:uno.css"; -import "./tailwind-compat.css"; -import "./index.css"; +import "./styles/tailwind-compat.css"; +import "./styles/index.css"; import { Route, Router } from "@solidjs/router"; import { Layout, @@ -11,12 +11,14 @@ import { RecordView, RepoView, Home, -} from "./App.tsx"; +} from "./main.tsx"; +import { Login } from "./components/login.tsx"; render( () => ( + diff --git a/src/App.tsx b/src/main.tsx similarity index 81% rename from src/App.tsx rename to src/main.tsx index 406cd84..23c40a1 100644 --- a/src/App.tsx +++ b/src/main.tsx @@ -1,4 +1,12 @@ -import { createSignal, onMount, For, Show, type Component } from "solid-js"; +import { + createSignal, + onMount, + For, + Show, + type Component, + onCleanup, + createEffect, +} from "solid-js"; import { CredentialManager, XRPC } from "@atcute/client"; import { ComAtprotoRepoDescribeRepo, @@ -15,7 +23,7 @@ import { useLocation, useParams, } from "@solidjs/router"; -import { JSONValue } from "./lib/json.jsx"; +import { JSONValue } from "./components/json.jsx"; import { AiFillGithub, Bluesky, @@ -23,8 +31,9 @@ import { BsClipboardCheck, TbMoonStar, TbSun, -} from "./lib/svg.jsx"; +} from "./components/svg.jsx"; import { authenticate_post } from "public-transport"; +import { agent, loginState, LoginStatus } from "./components/login.jsx"; let rpc = new XRPC({ handler: new CredentialManager({ service: "https://public.api.bsky.app" }), @@ -109,8 +118,19 @@ const resolvePDS = async (repo: string) => { const RecordView: Component = () => { const params = useParams(); const [record, setRecord] = createSignal(); + const [modal, setModal] = createSignal(); + const [open, setOpen] = createSignal(false); + + let clickEvent = (event: MouseEvent) => { + if (modal() && event.target == modal()) setOpen(false); + }; + let keyEvent = (event: KeyboardEvent) => { + if (modal() && event.key == "Escape") setOpen(false); + }; onMount(async () => { + window.addEventListener("click", clickEvent); + window.addEventListener("keydown", keyEvent); setNotice("Loading..."); setPDS(params.pds); let pds = @@ -131,6 +151,11 @@ const RecordView: Component = () => { } }); + onCleanup(() => { + window.removeEventListener("click", clickEvent); + window.removeEventListener("keydown", keyEvent); + }); + const getRecord = query( (repo: string, collection: string, rkey: string) => rpc.get("com.atproto.repo.getRecord", { @@ -139,8 +164,61 @@ const RecordView: Component = () => { "getRecord", ); + const deleteRecord = action(async () => { + rpc = new XRPC({ handler: agent }); + rpc.call("com.atproto.repo.deleteRecord", { + data: { + repo: params.repo, + collection: params.collection, + rkey: params.rkey, + }, + }); + throw redirect(`/at/${params.repo}/${params.collection}`); + }); + + createEffect(() => { + if (open()) document.body.style.overflow = "hidden"; + else document.body.style.overflow = "auto"; + }); + return ( + +
+ + +
+

Delete this record?

+
+
+ + +
+
+
+
+
+ +
+
@@ -387,9 +465,12 @@ const Layout: Component> = (props) => { setNotice(""); return ( -
+
-
+
{ @@ -404,6 +485,11 @@ const Layout: Component> = (props) => { : }
+ +
+ Login +
+
+
+/// + +interface ImportMetaEnv { + readonly VITE_DEV_SERVER_PORT?: string; + readonly VITE_CLIENT_URI: string; + readonly VITE_OAUTH_CLIENT_ID: string; + readonly VITE_OAUTH_REDIRECT_URL: string; + readonly VITE_OAUTH_SCOPE: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/vite.config.ts b/vite.config.ts index b7c158b..02c25ff 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,12 +2,44 @@ import { defineConfig } from "vite"; import solidPlugin from "vite-plugin-solid"; import wasm from "vite-plugin-wasm"; import UnoCSS from "unocss/vite"; +import metadata from "./public/client-metadata.json"; const SERVER_HOST = "127.0.0.1"; const SERVER_PORT = 13213; export default defineConfig({ - plugins: [UnoCSS(), wasm(), solidPlugin()], + plugins: [ + UnoCSS(), + wasm(), + solidPlugin(), + // Injects OAuth-related variables + { + name: "oauth", + config(_conf, { command }) { + if (command === "build") { + process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; + process.env.VITE_OAUTH_REDIRECT_URL = metadata.redirect_uris[0]; + } else { + const redirectUri = ((): string => { + const url = new URL(metadata.redirect_uris[0]); + return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; + })(); + + const clientId = + `http://localhost` + + `?redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(metadata.scope)}`; + + process.env.VITE_DEV_SERVER_PORT = "" + SERVER_PORT; + process.env.VITE_OAUTH_CLIENT_ID = clientId; + process.env.VITE_OAUTH_REDIRECT_URL = redirectUri; + } + + process.env.VITE_CLIENT_URI = metadata.client_uri; + process.env.VITE_OAUTH_SCOPE = metadata.scope; + }, + }, + ], server: { host: SERVER_HOST, port: SERVER_PORT,