From b226c73e56ddcc025f053c7cd67b556e37dc01ba Mon Sep 17 00:00:00 2001 From: Shreyan Jain Date: Sun, 28 Jun 2026 18:22:34 -0700 Subject: [PATCH 1/2] Resolve PDS so login works for any atproto account Login previously only worked for bsky.social accounts. The web form already resolved the user's PDS and sent it as `service`, but the API schema in routes/api/poll.ts didn't include that field, so it was silently dropped and every login fell back to https://bsky.social. The PDS resolver in identity_utils.ts was also broken (undeclared variable, wrong did:web parsing, assumed the PDS was the first service entry). - Add `service` to postPollSchema; resolve it server-side from the handle when the client doesn't supply it (bsky.social only as a last resort). - Rewrite identity_utils: correct did:plc/did:web doc URLs, find the PDS by its #atproto_pds id / AtprotoPersonalDataServer type, accept a DID or handle, proper return types and error handling. Verified end-to-end against real accounts including a self-hosted third-party PDS (mackuba.eu -> lab.martianbase.net), and confirmed the resolved hosts accept createSession directly (no regression for bsky.social accounts). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/identity_utils.ts | 91 ++++++++++++++++++++++++++++++++++++------- routes/api/poll.ts | 15 ++++++- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/app/identity_utils.ts b/app/identity_utils.ts index 29a46c6..60a80b6 100644 --- a/app/identity_utils.ts +++ b/app/identity_utils.ts @@ -1,32 +1,95 @@ -export async function getDidFromHandle(handle: string): string { - const response = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); +// Helpers for resolving an atproto handle/DID to its PDS service endpoint, +// so poll.blue can talk to any PDS instead of assuming bsky.social. + +/** + * Resolve a handle to a DID. If a DID is passed in, it is returned unchanged. + */ +export async function getDidFromHandle(handleOrDid: string): Promise { + const identifier = handleOrDid.trim().replace(/^@/, ""); + if (identifier.startsWith("did:")) { + return identifier; + } + const response = await fetch( + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ + encodeURIComponent(identifier) + }`, + ); + if (!response.ok) { + throw new Error( + `failed to resolve handle "${identifier}" (status ${response.status})`, + ); + } const json = await response.json(); + if (!json?.did) { + throw new Error(`no DID found for handle "${identifier}"`); + } return json.did; } +/** + * Build the URL where a DID document can be fetched, for the DID methods + * atproto supports (did:plc and did:web). + */ export function getDidDocPath(did: string): string { - switch (did.slice(0, 7)) { - case "did:plc": - return `https://plc.directory/${did}`; - case "did:web": - domain = did.split(":")[1]; + if (did.startsWith("did:plc:")) { + return `https://plc.directory/${did}`; + } + if (did.startsWith("did:web:")) { + // did:web:example.com -> https://example.com/.well-known/did.json + // did:web:example.com:foo:bar -> https://example.com/foo/bar/did.json + // (colon-encoded ports like %3A are decoded per-segment) + const segments = did.slice("did:web:".length).split(":").map( + decodeURIComponent, + ); + const domain = segments[0]; + if (!domain) { + throw new Error(`invalid did:web: ${did}`); + } + if (segments.length === 1) { return `https://${domain}/.well-known/did.json`; - default: - throw new Error("Invalid DID"); + } + return `https://${domain}/${segments.slice(1).join("/")}/did.json`; } + throw new Error(`unsupported DID method: ${did}`); } +// deno-lint-ignore no-explicit-any export async function fetchDidDoc(did: string): Promise { - const path_to_fetch = getDidDocPath(did); - const response = await fetch(path_to_fetch); + const response = await fetch(getDidDocPath(did)); + if (!response.ok) { + throw new Error( + `failed to fetch DID document for ${did} (status ${response.status})`, + ); + } return response.json(); } +/** + * Find the atproto Personal Data Server endpoint within a DID document. + * The PDS is not guaranteed to be the first service entry, so we match it by + * its conventional id/type rather than by position. + */ +// deno-lint-ignore no-explicit-any export function getPdsFromDidDoc(didDoc: any): string { - return didDoc["service"][0]["serviceEndpoint"]; + // deno-lint-ignore no-explicit-any + const services: any[] = Array.isArray(didDoc?.service) ? didDoc.service : []; + const pds = services.find((service) => + service?.id === "#atproto_pds" || + (typeof service?.id === "string" && + service.id.endsWith("#atproto_pds")) || + service?.type === "AtprotoPersonalDataServer" + ); + const endpoint = pds?.serviceEndpoint; + if (typeof endpoint !== "string" || endpoint.length === 0) { + throw new Error("no atproto PDS endpoint found in DID document"); + } + return endpoint; } -export async function getPds(handle_or_did: string): string { - const did = await getDidFromHandle(handle_or_did); +/** + * Resolve a handle or DID all the way to its PDS service endpoint. + */ +export async function getPds(handleOrDid: string): Promise { + const did = await getDidFromHandle(handleOrDid); return getPdsFromDidDoc(await fetchDidDoc(did)); } diff --git a/routes/api/poll.ts b/routes/api/poll.ts index 0a31f01..7071c7a 100644 --- a/routes/api/poll.ts +++ b/routes/api/poll.ts @@ -16,6 +16,9 @@ const postPollSchema = z.object({ password: z.string(), user_agent: z.string().max(100), reply_to: z.string().max(200).optional(), + // The PDS endpoint to log into. Optional: if omitted, it is resolved + // server-side from the handle so login works for any atproto account. + service: z.string().url().max(200).optional(), }); export const handler = async (req: Request, _ctx: HandlerContext): Promise => { @@ -34,11 +37,21 @@ export const handler = async (req: Request, _ctx: HandlerContext): Promise 0).concat([0]); const createdAt = (new Date()).toISOString(); + // Prefer a client-supplied PDS, but resolve it from the handle when absent + // (or when the client could not). Falls back to bsky.social as a last resort. + let service = pollParse.data.service; + if (!service) { + try { + service = await getPds(handle); + } catch (e) { + log.error(e); + } + } const agent = new Agent({ service: service || "https://bsky.social" }); try { await agent.login({ From 4623b8e87c0b437e9bf252f176a3f748bee21ed9 Mon Sep 17 00:00:00 2001 From: Shreyan Jain Date: Sun, 28 Jun 2026 18:22:50 -0700 Subject: [PATCH 2/2] Post polls client-side via atproto OAuth (no password to server) The web form sent the user's app password in plaintext to the poll.blue server, which logged in and created the post server-side. Move post creation into the browser using atproto OAuth: the user signs in with their PDS, the poll post record is created directly against their own PDS, and the server only receives the resulting post URI. poll.blue never sees credentials on this path. - islands/post.tsx: "Sign in with Bluesky" OAuth flow (loaded via dynamic import so the browser-only lib stays out of SSR), signed-in compose, and client-side createRecord. The app-password flow is kept as a fallback under Advanced options for PDSes without OAuth. - routes/api/poll/register.ts: password-free registration. Since there's no password to authenticate with, it verifies the post by reading the record straight from the author's PDS and confirming it links back to this poll, closing a spam vector before inserting and triggering the bot like/repost. - routes/client-metadata.json.ts: OAuth client metadata for production. - import_map.json: add @atproto/oauth-client-browser + a recent @atproto/api (browser-only; server/bot stay on the pinned 0.2.3). - poll-utils.ts: import type to keep the old API out of the client bundle. Note: deno.lock (old "version 2" format) needs refreshing under the project's own Deno to pick up the new deps; left untouched to avoid a format conversion. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/poll-utils.ts | 2 +- fresh.gen.ts | 28 +- import_map.json | 4 +- islands/post.tsx | 547 ++++++++++++++++++++++----------- routes/api/poll/register.ts | 126 ++++++++ routes/client-metadata.json.ts | 27 ++ routes/post.tsx | 7 +- 7 files changed, 553 insertions(+), 188 deletions(-) create mode 100644 routes/api/poll/register.ts create mode 100644 routes/client-metadata.json.ts diff --git a/app/poll-utils.ts b/app/poll-utils.ts index 3c9863c..e1dcfc5 100644 --- a/app/poll-utils.ts +++ b/app/poll-utils.ts @@ -1,4 +1,4 @@ -import { AppBskyFeedPost, AppBskyRichtextFacet } from "https://esm.sh/v115/@atproto/api@0.2.3" +import type { AppBskyFeedPost, AppBskyRichtextFacet } from "https://esm.sh/v115/@atproto/api@0.2.3" const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/fresh.gen.ts b/fresh.gen.ts index 64a700f..3ed03f3 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -6,12 +6,14 @@ import config from "./deno.json" assert { type: "json" }; import * as $0 from "./routes/.well-known/did.json.ts"; import * as $1 from "./routes/api/poll.ts"; import * as $2 from "./routes/api/poll/[poll].ts"; -import * as $3 from "./routes/index.tsx"; -import * as $4 from "./routes/p/[poll].tsx"; -import * as $5 from "./routes/p/[poll]/[vote].tsx"; -import * as $6 from "./routes/post.tsx"; -import * as $7 from "./routes/status.ts"; -import * as $8 from "./routes/xrpc/app/bsky/feed/getFeedSkeleton.ts"; +import * as $3 from "./routes/api/poll/register.ts"; +import * as $4 from "./routes/client-metadata.json.ts"; +import * as $5 from "./routes/index.tsx"; +import * as $6 from "./routes/p/[poll].tsx"; +import * as $7 from "./routes/p/[poll]/[vote].tsx"; +import * as $8 from "./routes/post.tsx"; +import * as $9 from "./routes/status.ts"; +import * as $10 from "./routes/xrpc/app/bsky/feed/getFeedSkeleton.ts"; import * as $$0 from "./islands/post.tsx"; const manifest = { @@ -19,12 +21,14 @@ const manifest = { "./routes/.well-known/did.json.ts": $0, "./routes/api/poll.ts": $1, "./routes/api/poll/[poll].ts": $2, - "./routes/index.tsx": $3, - "./routes/p/[poll].tsx": $4, - "./routes/p/[poll]/[vote].tsx": $5, - "./routes/post.tsx": $6, - "./routes/status.ts": $7, - "./routes/xrpc/app/bsky/feed/getFeedSkeleton.ts": $8, + "./routes/api/poll/register.ts": $3, + "./routes/client-metadata.json.ts": $4, + "./routes/index.tsx": $5, + "./routes/p/[poll].tsx": $6, + "./routes/p/[poll]/[vote].tsx": $7, + "./routes/post.tsx": $8, + "./routes/status.ts": $9, + "./routes/xrpc/app/bsky/feed/getFeedSkeleton.ts": $10, }, islands: { "./islands/post.tsx": $$0, diff --git a/import_map.json b/import_map.json index 75326e3..673f6f5 100644 --- a/import_map.json +++ b/import_map.json @@ -7,7 +7,9 @@ "preact": "https://esm.sh/preact@10.13.1", "preact/": "https://esm.sh/preact@10.13.1/", "twind": "https://esm.sh/twind@0.16.19", - "twind/": "https://esm.sh/twind@0.16.19/" + "twind/": "https://esm.sh/twind@0.16.19/", + "@atproto/oauth-client-browser": "https://esm.sh/@atproto/oauth-client-browser@0.4.3", + "@atproto/api": "https://esm.sh/@atproto/api@0.20.22" }, "scopes": {} } diff --git a/islands/post.tsx b/islands/post.tsx index 7e4bc50..712eed8 100644 --- a/islands/post.tsx +++ b/islands/post.tsx @@ -1,200 +1,405 @@ -import { useState } from "preact/hooks"; -import { postUriToBskyLink } from "../app/poll-utils.ts"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { postUriToBskyLink, generateId, generatePollText } from "../app/poll-utils.ts"; import { getPds } from "../app/identity_utils.ts"; +import type { BrowserOAuthClient } from "@atproto/oauth-client-browser"; +import type { Agent } from "@atproto/api"; + +const HANDLE_RESOLVER = "https://bsky.social"; + +interface Session { + did: string; + handle: string; +} export default function PostPoll() { - const [handle, setHandle] = useState(""); - const [password, setPassword] = useState(""); + // Poll content (shared by both posting paths). const [question, setQuestion] = useState(""); const [options, setOptions] = useState(["", "", "", ""]); + const [replyTo, setReplyTo] = useState(""); + const [showAdvanced, setShowAdvanced] = useState(false); + + // OAuth state. + const clientRef = useRef(null); + const agentRef = useRef(null); + const [session, setSession] = useState(null); + const [initializing, setInitializing] = useState(true); + const [handle, setHandle] = useState(""); + + // App-password fallback state. + const [showPasswordFallback, setShowPasswordFallback] = useState(false); + const [password, setPassword] = useState(""); + + // Result/UI state. const [postUri, setPostUri] = useState(""); const [error, setError] = useState(""); - const [showAdvanced, setShowAdvanced] = useState(false); - const [replyTo, setReplyTo] = useState(undefined); - async function postPoll(evt: Event) { - evt.preventDefault(); - const service = await getPds(handle); - const response = await fetch("/api/poll", { - method: "POST", - body: JSON.stringify({ - handle, - password, - question, - answers: options.filter((opt) => opt != ""), - user_agent: "poll.blue", - reply_to: replyTo, - service, - }), - }); - if (response.status === 200) { - setPostUri((await response.json()).post_uri); - setError(""); + const [busy, setBusy] = useState(false); + + // Initialize the OAuth client once, on the client only. Loaded dynamically so + // the browser-only library never runs during server-side rendering. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const { BrowserOAuthClient } = await import("@atproto/oauth-client-browser"); + const { Agent } = await import("@atproto/api"); + const isLocalhost = ["localhost", "127.0.0.1"].includes(location.hostname); + const client = isLocalhost + ? new BrowserOAuthClient({ + handleResolver: HANDLE_RESOLVER, + clientMetadata: undefined, + }) + : await BrowserOAuthClient.load({ + clientId: `https://${location.host}/client-metadata.json`, + handleResolver: HANDLE_RESOLVER, + }); + if (cancelled) return; + clientRef.current = client; + const result = await client.init(); + if (cancelled) return; + if (result?.session) { + const agent = new Agent(result.session); + agentRef.current = agent; + await setSignedIn(agent, result.session.sub); + } + } catch (e) { + if (!cancelled) setError("could not start OAuth: " + (e as Error).message); + } finally { + if (!cancelled) setInitializing(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + async function setSignedIn(agent: Agent, did: string) { + let resolvedHandle = did; + try { + const prof = await agent.getProfile({ actor: did }); + resolvedHandle = prof.data.handle ?? did; + } catch { + // Non-critical: fall back to showing the DID. + } + setSession({ did, handle: resolvedHandle }); + } + + async function signIn() { + setError(""); + const input = handle.trim().replace(/^@/, ""); + if (!input) { + setError("enter your handle to sign in"); + return; + } + try { + // Redirects away; the returned promise only settles on failure. + await clientRef.current?.signIn(input); + } catch (e) { + setError("sign in failed: " + (e as Error).message); + } + } + + async function signOut() { + try { + // deno-lint-ignore no-explicit-any + await (clientRef.current as any)?.revoke?.(session?.did); + } catch { + // ignore + } + agentRef.current = null; + setSession(null); + } + + // Resolve a reply-to link (bsky.app URL or at:// URI) to a post ref, using + // the signed-in agent. Mirrors Bot.linkToReplyRef in app/bot.ts. + async function resolveReply(agent: Agent, link: string) { + let did: string | undefined; + let rkey: string | undefined; + if (link.startsWith("at://")) { + const seg = link.split("/").slice(-3); + did = seg[0]; + rkey = seg[2]; } else { - setPostUri(""); - setError(await response.json()); + const m = /https:\/\/(?:staging\.)?bsky\.app\/profile\/([^/]+)\/post\/([^/]+)/.exec(link); + if (m) { + const r = await agent.com.atproto.identity.resolveHandle({ handle: m[1] }); + did = r.data.did; + rkey = m[2]; + } + } + if (!did || !rkey) return undefined; + const rec = await agent.com.atproto.repo.getRecord({ + repo: did, + collection: "app.bsky.feed.post", + rkey, + }); + const post = { uri: rec.data.uri, cid: rec.data.cid! }; + // deno-lint-ignore no-explicit-any + const root = (rec.data.value as any)?.reply?.root ?? post; + return { root, parent: post }; + } + + function activeOptions() { + return options.filter((opt) => opt !== ""); + } + + // Primary path: create the post in the browser via OAuth, then register it. + async function postPollOAuth(evt: Event) { + evt.preventDefault(); + const agent = agentRef.current; + if (!agent || !session) return; + setBusy(true); + setError(""); + setPostUri(""); + try { + const answers = activeOptions(); + const visibleId = generateId(6); + const { text, links, pollFacets } = generatePollText({ + visibleId, + poll: { question, answers, enumeration: "number" }, + author: session.handle, + pollStyle: "plain", + }); + const createdAt = new Date().toISOString(); + const reply = replyTo ? await resolveReply(agent, replyTo) : undefined; + const created = await agent.com.atproto.repo.createRecord({ + repo: agent.assertDid, + collection: "app.bsky.feed.post", + record: { + $type: "app.bsky.feed.post", + text, + facets: [...links, ...pollFacets], + reply, + createdAt, + }, + }); + const resp = await fetch("/api/poll/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + question, + answers, + visible_id: visibleId, + post_uri: created.data.uri, + }), + }); + if (!resp.ok) { + const j = await resp.json().catch(() => ({})); + throw new Error(typeof j.error === "string" ? j.error : "failed to register poll"); + } + setPostUri(created.data.uri); + } catch (e) { + setError((e as Error).message || "failed to post poll"); + } finally { + setBusy(false); } } + + // Fallback path: send handle + app password to the server (legacy). + async function postPollPassword(evt: Event) { + evt.preventDefault(); + setBusy(true); + setError(""); + setPostUri(""); + try { + let service: string | undefined; + try { + service = await getPds(handle); + } catch (_) { + service = undefined; // server will resolve + } + const response = await fetch("/api/poll", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + handle: handle.trim().replace(/^@/, ""), + password, + question, + answers: activeOptions(), + user_agent: "poll.blue", + reply_to: replyTo || undefined, + service, + }), + }); + if (response.status === 200) { + setPostUri((await response.json()).post_uri); + } else { + const j = await response.json().catch(() => ({})); + throw new Error(typeof j.error === "string" ? j.error : "failed to post poll"); + } + } catch (e) { + setError((e as Error).message || "failed to post poll"); + } finally { + setBusy(false); + } + } + + const inputClass = + "appearance-none border rounded mb-2 w-full py-2 px-3 text-gray-400 leading-tight focus:outline-none focus:shadow-outline"; + const labelClass = "block text-gray-400 text-sm font-bold mb-2"; + const canPost = question !== "" && activeOptions().length >= 2; + return ( - <> -
postPoll(evt)}> + evt.preventDefault()}> + {/* Question */} +
+ + setQuestion((target as HTMLInputElement).value)} + /> +
+ + {/* Options */} + {options.map((option, i) => (
- + { - setHandle(target && (target as any).value); - }} - placeholder="something.bsky.social" + onInput={({ target }) => + setOptions(options.map((o, j) => (i === j ? (target as HTMLInputElement).value : o)))} />
-
- - { - setPassword(target && (target as any).value); - }} - placeholder="" - /> + ))} + + {/* Live preview */} + {activeOptions().length >= 2 && ( +
+

{question}

+
    + {activeOptions().map((opt, idx) => ( +
  1. {["1️⃣", "2️⃣", "3️⃣", "4️⃣"][idx]} {opt}
  2. + ))} +
+

📊 Show results

-
- + )} + + {/* Advanced options (reply-to + app-password fallback) */} +
+

+ { e.preventDefault(); setShowAdvanced(!showAdvanced); }}> + Advanced options (Toggle) + +

+
+ { - setQuestion(target && (target as any).value); - }} - placeholder="" + onInput={({ target }) => setReplyTo((target as HTMLInputElement).value)} + placeholder="https://bsky.app/profile/jay.bsky.team/post/3juflvnb3d62u" /> -
- {options.map((option, i) => ( -
- - { - setOptions( - options.map((o, j) => (i == j ? (target as any).value : o)), - ); - }} - placeholder="" - /> -
- ))} -
-

- setShowAdvanced(!showAdvanced)}> - Advanced options (Toggle) - -

-
- - { - setReplyTo(target && (target as any).value); - }} - placeholder="https://bsky.app/profile/jay.bsky.team/post/3juflvnb3d62u" - /> -
-
- {options.filter((opt) => opt != "").length >= 2 && - ( -
-

- {question} -

-
    - {options.filter((opt) => opt != "").map((opt, idx) => ( -
  1. - {["1️⃣", "2️⃣", "3️⃣", "4️⃣"][idx]} {opt} -
  2. - ))} -
-

- 📊 Show results + + {!session && ( +

+

+ { e.preventDefault(); setShowPasswordFallback(!showPasswordFallback); }} + > + Post with an app password instead (Toggle) +

+
+

+ Only use this if your PDS doesn't support OAuth. Use an app + password (Settings → App passwords), not your main password. + Sign in with Bluesky above is preferred. +

+ + setPassword((target as HTMLInputElement).value)} + /> + +
)} -
-
- {postUri && ( -
-

- + + {/* Auth + primary action */} +

+ {initializing + ?

Loading…

+ : session + ? ( +
+ ) + : ( +
+ + setHandle((target as HTMLInputElement).value)} + placeholder="something.bsky.social" + /> +
- )} - {error !== "" && ( -
-

- Error: {(error as { error?: string }).error} -

-
- )} - - + Sign in with Bluesky + +
+ )} +
+ + {/* Result / error */} + {postUri && ( + + )} + {error !== "" && ( +
+

Error: {error}

+
+ )} + ); } diff --git a/routes/api/poll/register.ts b/routes/api/poll/register.ts new file mode 100644 index 0000000..5ed58f0 --- /dev/null +++ b/routes/api/poll/register.ts @@ -0,0 +1,126 @@ +import { HandlerContext } from "$fresh/server.ts"; +import * as log from "https://deno.land/std@0.183.0/log/mod.ts"; +import { z } from "https://deno.land/x/zod@v3.16.1/mod.ts"; +import { getDbClient } from "../../../app/db.ts"; +import { getBotClient } from "../../../app/bot-client.ts"; +import { fetchDidDoc, getPdsFromDidDoc } from "../../../app/identity_utils.ts"; +import { json } from "../../../app/utils.ts"; + +// Registers a poll whose post was already created client-side via OAuth, so no +// credentials ever reach this server. Because we can't authenticate the +// request with a password, we instead VERIFY the post: we read the record +// straight from the author's PDS and confirm it is a real poll.blue poll. This +// prevents the bot from being tricked into liking/reposting arbitrary posts. +const registerSchema = z.object({ + question: z.string().min(1).max(200), + answers: z.array(z.string().min(1).max(50)).min(2).max(4), + visible_id: z.string().min(1).max(16), + post_uri: z.string().max(200).regex(/^at:\/\//, "must be an at:// URI"), +}); + +// Parse an at:// post URI into its DID and rkey, ensuring it's a feed post. +function parsePostUri(uri: string): { did: string; rkey: string } | null { + // at://did:plc:xxx/app.bsky.feed.post/rkey + if (!uri.startsWith("at://")) { + return null; + } + const rest = uri.slice("at://".length); + const [did, collection, rkey] = rest.split("/"); + if (!did || !did.startsWith("did:") || collection !== "app.bsky.feed.post" || !rkey) { + return null; + } + return { did, rkey }; +} + +export const handler = async (req: Request, _ctx: HandlerContext): Promise => { + if (req.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + const body = await req.json().catch(() => null); + const parse = registerSchema.safeParse(body); + if (!parse.success) { + return json({ error: parse.error.format() }, 400); + } + const { question, answers, visible_id: visibleId, post_uri: postUri } = parse.data; + + const parsed = parsePostUri(postUri); + if (!parsed) { + return json({ error: "invalid post_uri" }, 400); + } + const { did, rkey } = parsed; + + // Resolve the author's DID document once: it gives us both the PDS endpoint + // (to read the record from the authoritative source, no appview lag) and + // the handle (for display on the results page). + let didDoc: { alsoKnownAs?: string[] }; + let pds: string; + try { + didDoc = await fetchDidDoc(did); + pds = getPdsFromDidDoc(didDoc); + } catch (e) { + log.error(e); + return json({ error: "could not resolve author identity" }, 400); + } + + // Fetch the post record directly from the PDS. + let record: { cid?: string; value?: Record }; + try { + const url = + `${pds}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}` + + `&collection=app.bsky.feed.post&rkey=${encodeURIComponent(rkey)}`; + const resp = await fetch(url); + if (!resp.ok) { + return json({ error: "poll post not found on PDS" }, 400); + } + record = await resp.json(); + } catch (e) { + log.error(e); + return json({ error: "failed to fetch poll post" }, 400); + } + + // Verify it's a real post that links back to this exact poll. The poll + // option links (built in poll-utils) all contain `/p/{visibleId}/`. + const value = record.value; + const cid = record.cid; + if (!value || value.$type !== "app.bsky.feed.post" || !cid) { + return json({ error: "post is not a feed post" }, 400); + } + if (!JSON.stringify(value).includes(`/p/${visibleId}/`)) { + return json({ error: "post does not reference this poll" }, 400); + } + + const handle = (didDoc.alsoKnownAs ?? []) + .find((aka) => aka.startsWith("at://")) + ?.slice("at://".length) ?? did; + + // Persist the poll (results has a leading slot for abstentions, mirroring + // the legacy /api/poll path). + const results = answers.map(() => 0).concat([0]); + try { + await getDbClient().queryObject`INSERT INTO polls ( + posted_by, post_uri, question, answers, results, visible_id, results_posted, user_agent + ) VALUES ( + ${handle}, ${postUri}, ${question}, ${JSON.stringify(answers)}, + ${JSON.stringify(results)}, ${visibleId}, ${false}, ${"poll.blue"} + )`; + } catch (e) { + log.error(e); + return json({ error: "failed to register poll (already registered?)" }, 409); + } + log.info(`registered poll ${visibleId} by @${handle} at ${postUri}`); + + // Bot like + repost (uses the bot's own credentials, not the user's). + try { + const ref = { + parent: { uri: postUri, cid }, + root: { uri: postUri, cid }, + }; + await getBotClient()?.likePost(ref); + await getBotClient()?.repost(ref); + } catch (e) { + log.error(e); + } + + return json({ id: visibleId, post_uri: postUri }); +}; diff --git a/routes/client-metadata.json.ts b/routes/client-metadata.json.ts new file mode 100644 index 0000000..08dfc0e --- /dev/null +++ b/routes/client-metadata.json.ts @@ -0,0 +1,27 @@ +import { HandlerContext } from "$fresh/server.ts"; +import { getConfig } from "../app/config.ts"; +import { json } from "../app/utils.ts"; + +// atproto OAuth client metadata. The document's URL is the client_id, so this +// must be served at https://{HOSTNAME}/client-metadata.json in production. +// (Local dev uses the loopback client and does not fetch this file.) +export const handler = (_req: Request, _ctx: HandlerContext): Response => { + const host = getConfig("HOSTNAME"); + const clientId = `https://${host}/client-metadata.json`; + return json({ + client_id: clientId, + client_name: "poll.blue", + client_uri: `https://${host}`, + // The home page hosts the post island, which consumes the OAuth + // redirect via client.init() on mount. + redirect_uris: [`https://${host}/`], + // `transition:generic` grants the record read/write access needed to + // create the poll post (and like) on the user's behalf. + 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/routes/post.tsx b/routes/post.tsx index 46ac4b5..69e07c7 100644 --- a/routes/post.tsx +++ b/routes/post.tsx @@ -39,9 +39,10 @@ export default function Home() { .

- Post a poll directly from your account here! You'll need to put in - your account credentials down below. Make sure to use an app password - (Settings → App passwords) and not your main password. + Post a poll directly from your account here! Sign in with Bluesky + (OAuth) below — poll.blue never sees your password. If your PDS + doesn't support OAuth yet, there's an app-password fallback under + Advanced options.