diff --git a/lexicons.json b/lexicons.json index 61b6a522e..6cd9ed1e3 100644 --- a/lexicons.json +++ b/lexicons.json @@ -1,7 +1,15 @@ { "version": 1, - "lexicons": ["site.standard.document"], + "lexicons": ["app.bsky.actor.profile", "site.standard.document"], "resolutions": { + "app.bsky.actor.profile": { + "uri": "at://did:plc:4v4y5r3lwsbtmsxhile2ljac/com.atproto.lexicon.schema/app.bsky.actor.profile", + "cid": "bafyreia6umzg3a6d7mjbow4p57tviey45muohklhgsvjoamcctoiusr4pe" + }, + "com.atproto.label.defs": { + "uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.label.defs", + "cid": "bafyreig4hmnb2xkecyg4aaqfhr2rrcxxb3gsr4xks4rqb7rscrycalbrji" + }, "com.atproto.repo.strongRef": { "uri": "at://did:plc:6msi3pj7krzih5qxqtryxlzw/com.atproto.lexicon.schema/com.atproto.repo.strongRef", "cid": "bafyreifrkdbnkvfjujntdaeigolnrjj3srrs53tfixjhmacclps72qlov4" diff --git a/lexicons/app/bsky/actor/profile.json b/lexicons/app/bsky/actor/profile.json new file mode 100644 index 000000000..1d22cc4ba --- /dev/null +++ b/lexicons/app/bsky/actor/profile.json @@ -0,0 +1,67 @@ +{ + "id": "app.bsky.actor.profile", + "defs": { + "main": { + "key": "literal:self", + "type": "record", + "record": { + "type": "object", + "properties": { + "avatar": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000, + "description": "Small image to be displayed next to posts from account. AKA, 'profile picture'" + }, + "banner": { + "type": "blob", + "accept": ["image/png", "image/jpeg"], + "maxSize": 1000000, + "description": "Larger horizontal image to display behind profile view." + }, + "labels": { + "refs": ["com.atproto.label.defs#selfLabels"], + "type": "union", + "description": "Self-label values, specific to the Bluesky application, on the overall account." + }, + "website": { + "type": "string", + "format": "uri" + }, + "pronouns": { + "type": "string", + "maxLength": 200, + "description": "Free-form pronouns text.", + "maxGraphemes": 20 + }, + "createdAt": { + "type": "string", + "format": "datetime" + }, + "pinnedPost": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + }, + "description": { + "type": "string", + "maxLength": 2560, + "description": "Free-form profile description text.", + "maxGraphemes": 256 + }, + "displayName": { + "type": "string", + "maxLength": 640, + "maxGraphemes": 64 + }, + "joinedViaStarterPack": { + "ref": "com.atproto.repo.strongRef", + "type": "ref" + } + } + }, + "description": "A declaration of a Bluesky account profile." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/lexicons/com/atproto/label/defs.json b/lexicons/com/atproto/label/defs.json new file mode 100644 index 000000000..6790ebafb --- /dev/null +++ b/lexicons/com/atproto/label/defs.json @@ -0,0 +1,163 @@ +{ + "id": "com.atproto.label.defs", + "defs": { + "label": { + "type": "object", + "required": ["src", "uri", "val", "cts"], + "properties": { + "cid": { + "type": "string", + "format": "cid", + "description": "Optionally, CID specifying the specific version of 'uri' resource this label applies to." + }, + "cts": { + "type": "string", + "format": "datetime", + "description": "Timestamp when this label was created." + }, + "exp": { + "type": "string", + "format": "datetime", + "description": "Timestamp at which this label expires (no longer applies)." + }, + "neg": { + "type": "boolean", + "description": "If true, this is a negation label, overwriting a previous label." + }, + "sig": { + "type": "bytes", + "description": "Signature of dag-cbor encoded label." + }, + "src": { + "type": "string", + "format": "did", + "description": "DID of the actor who created this label." + }, + "uri": { + "type": "string", + "format": "uri", + "description": "AT URI of the record, repository (account), or other resource that this label applies to." + }, + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + }, + "ver": { + "type": "integer", + "description": "The AT Protocol version of the label object." + } + }, + "description": "Metadata tag on an atproto resource (eg, repo or record)." + }, + "selfLabel": { + "type": "object", + "required": ["val"], + "properties": { + "val": { + "type": "string", + "maxLength": 128, + "description": "The short string name of the value or type of this label." + } + }, + "description": "Metadata tag on an atproto record, published by the author within the record. Note that schemas should use #selfLabels, not #selfLabel." + }, + "labelValue": { + "type": "string", + "knownValues": [ + "!hide", + "!no-promote", + "!warn", + "!no-unauthenticated", + "dmca-violation", + "doxxing", + "porn", + "sexual", + "nudity", + "nsfl", + "gore" + ] + }, + "selfLabels": { + "type": "object", + "required": ["values"], + "properties": { + "values": { + "type": "array", + "items": { + "ref": "#selfLabel", + "type": "ref" + }, + "maxLength": 10 + } + }, + "description": "Metadata tags on an atproto record, published by the author within the record." + }, + "labelValueDefinition": { + "type": "object", + "required": ["identifier", "severity", "blurs", "locales"], + "properties": { + "blurs": { + "type": "string", + "description": "What should this label hide in the UI, if applied? 'content' hides all of the target; 'media' hides the images/video/audio; 'none' hides nothing.", + "knownValues": ["content", "media", "none"] + }, + "locales": { + "type": "array", + "items": { + "ref": "#labelValueDefinitionStrings", + "type": "ref" + } + }, + "severity": { + "type": "string", + "description": "How should a client visually convey this label? 'inform' means neutral and informational; 'alert' means negative and warning; 'none' means show nothing.", + "knownValues": ["inform", "alert", "none"] + }, + "adultOnly": { + "type": "boolean", + "description": "Does the user need to have adult content enabled in order to configure this label?" + }, + "identifier": { + "type": "string", + "maxLength": 100, + "description": "The value of the label being defined. Must only include lowercase ascii and the '-' character ([a-z-]+).", + "maxGraphemes": 100 + }, + "defaultSetting": { + "type": "string", + "default": "warn", + "description": "The default setting for this label.", + "knownValues": ["ignore", "warn", "hide"] + } + }, + "description": "Declares a label value and its expected interpretations and behaviors." + }, + "labelValueDefinitionStrings": { + "type": "object", + "required": ["lang", "name", "description"], + "properties": { + "lang": { + "type": "string", + "format": "language", + "description": "The code of the language these strings are written in." + }, + "name": { + "type": "string", + "maxLength": 640, + "description": "A short human-readable name for the label.", + "maxGraphemes": 64 + }, + "description": { + "type": "string", + "maxLength": 100000, + "description": "A longer description of what the label means and why it might be applied.", + "maxGraphemes": 10000 + } + }, + "description": "Strings which describe the label in the UI, localized into a specific language." + } + }, + "$type": "com.atproto.lexicon.schema", + "lexicon": 1 +} diff --git a/package.json b/package.json index 5d8bc947d..e172d8911 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@atproto/common": "0.5.10", "@atproto/lex": "0.0.13", "@atproto/oauth-client-node": "^0.3.15", + "@atproto/syntax": "0.4.3", "@deno/doc": "jsr:^0.189.1", "@floating-ui/vue": "1.1.10", "@iconify-json/carbon": "1.2.18", @@ -75,7 +76,6 @@ "defu": "6.1.4", "fast-npm-meta": "1.0.0", "focus-trap": "^7.8.0", - "tinyglobby": "0.2.15", "marked": "17.0.1", "module-replacements": "2.11.0", "nuxt": "4.3.0", @@ -88,6 +88,7 @@ "simple-git": "3.30.0", "spdx-license-list": "6.11.0", "std-env": "3.10.0", + "tinyglobby": "0.2.15", "ufo": "1.6.3", "unocss": "66.6.0", "unplugin-vue-router": "0.19.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b06b4a98..085d39a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@atproto/oauth-client-node': specifier: ^0.3.15 version: 0.3.16 + '@atproto/syntax': + specifier: 0.4.3 + version: 0.4.3 '@deno/doc': specifier: jsr:^0.189.1 version: '@jsr/deno__doc@0.189.1(patch_hash=24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832)' diff --git a/server/api/auth/atproto.get.ts b/server/api/auth/atproto.get.ts index 39721941d..581c91294 100644 --- a/server/api/auth/atproto.get.ts +++ b/server/api/auth/atproto.get.ts @@ -6,14 +6,41 @@ import { useOAuthStorage } from '#server/utils/atproto/storage' import { SLINGSHOT_HOST } from '#shared/utils/constants' import { useServerSession } from '#server/utils/server-session' import type { PublicUserSession } from '#shared/schemas/publicUserSession' +import { handleResolver } from '#server/utils/atproto/oauth' +import { Client } from '@atproto/lex' +import * as app from '#shared/types/lexicons/app' +import { ensureValidAtIdentifier } from '@atproto/syntax' -interface ProfileRecord { - avatar?: { - $type: 'blob' - ref: { $link: string } - mimeType: string - size: number +/** + * Fetch the user's profile record to get their avatar blob reference + * @param did + * @param pds + * @returns + */ +async function getAvatar(did: string, pds: string) { + let avatar: string | undefined + try { + const pdsUrl = new URL(pds) + // Only fetch from HTTPS PDS endpoints to prevent SSRF + if (did && pdsUrl.protocol === 'https:') { + ensureValidAtIdentifier(did) + const client = new Client(pdsUrl) + const profileResponse = await client.get(app.bsky.actor.profile, { + repo: did, + rkey: 'self', + }) + + const validatedResponse = app.bsky.actor.profile.main.validate(profileResponse.value) + + if (validatedResponse.avatar?.ref) { + // Use Bluesky CDN for faster image loading + avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${validatedResponse.avatar?.ref}@jpeg` + } + } + } catch { + // Avatar fetch failed, continue without it } + return avatar } export default defineEventHandler(async event => { @@ -35,24 +62,34 @@ export default defineEventHandler(async event => { sessionStore, clientMetadata, requestLock: getOAuthLock(), + handleResolver, }) if (!query.code) { - const handle = query.handle?.toString() - const create = query.create?.toString() + try { + const handle = query.handle?.toString() + const create = query.create?.toString() - if (!handle) { - throw createError({ - status: 400, - message: 'Handle not provided in query', + if (!handle) { + throw createError({ + statusCode: 401, + message: 'Handle not provided in query', + }) + } + + const redirectUrl = await atclient.authorize(handle, { + scope, + prompt: create ? 'create' : undefined, }) - } + return sendRedirect(event, redirectUrl.toString()) + } catch (error) { + const message = error instanceof Error ? error.message : 'Authentication failed.' - const redirectUrl = await atclient.authorize(handle, { - scope, - prompt: create ? 'create' : undefined, - }) - return sendRedirect(event, redirectUrl.toString()) + return handleApiError(error, { + statusCode: 401, + message: `${message}. Please login and try again.`, + }) + } } const { session: authSession } = await atclient.callback( @@ -68,29 +105,7 @@ export default defineEventHandler(async event => { if (response.ok) { const miniDoc: PublicUserSession = await response.json() - // Fetch the user's profile record to get their avatar blob reference - let avatar: string | undefined - const did = agent.did - try { - const pdsUrl = new URL(miniDoc.pds) - // Only fetch from HTTPS PDS endpoints to prevent SSRF - if (did && pdsUrl.protocol === 'https:') { - const profileResponse = await fetch( - `${pdsUrl.origin}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=app.bsky.actor.profile&rkey=self`, - { headers: { 'User-Agent': 'npmx' } }, - ) - if (profileResponse.ok) { - const record = (await profileResponse.json()) as { value: ProfileRecord } - const avatarBlob = record.value.avatar - if (avatarBlob?.ref?.$link) { - // Use Bluesky CDN for faster image loading - avatar = `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${avatarBlob.ref.$link}@jpeg` - } - } - } - } catch { - // Avatar fetch failed, continue without it - } + let avatar: string | undefined = await getAvatar(authSession.did, miniDoc.pds) await session.update({ public: { @@ -98,7 +113,18 @@ export default defineEventHandler(async event => { avatar, }, }) + } else { + //If slingshot fails we still want to set some key info we need. + const pdsBase = (await authSession.getTokenInfo()).aud + let avatar: string | undefined = await getAvatar(authSession.did, pdsBase) + await session.update({ + public: { + did: authSession.did, + handle: 'Not available', + pds: pdsBase, + avatar, + }, + }) } - return sendRedirect(event, '/') }) diff --git a/server/api/auth/session.get.ts b/server/api/auth/session.get.ts index 9dbd11d7a..771ab6c00 100644 --- a/server/api/auth/session.get.ts +++ b/server/api/auth/session.get.ts @@ -1,7 +1,8 @@ import { PublicUserSessionSchema } from '#shared/schemas/publicUserSession' import { safeParse } from 'valibot' -export default eventHandlerWithOAuthSession(async (event, oAuthSession, serverSession) => { +export default defineEventHandler(async event => { + const serverSession = await useServerSession(event) const result = safeParse(PublicUserSessionSchema, serverSession.data.public) if (!result.success) { return null diff --git a/server/utils/atproto/oauth-session-store.ts b/server/utils/atproto/oauth-session-store.ts index 532f9894d..041e09719 100644 --- a/server/utils/atproto/oauth-session-store.ts +++ b/server/utils/atproto/oauth-session-store.ts @@ -17,9 +17,18 @@ export class OAuthSessionStore implements NodeSavedSessionStore { async set(_key: string, val: NodeSavedSession) { // We are ignoring the key since the mapping is already done in the session - await this.session.update({ - oauthSession: val, - }) + try { + await this.session.update({ + oauthSession: val, + }) + } catch (error) { + // Not sure if this has been happening. But helps with debugging + console.error( + '[oauth session store] Failed to set session:', + error instanceof Error ? error.message : 'Unknown error', + ) + throw error + } } async del() { diff --git a/server/utils/atproto/oauth.ts b/server/utils/atproto/oauth.ts index 8b53dc72e..c8fc6cd41 100644 --- a/server/utils/atproto/oauth.ts +++ b/server/utils/atproto/oauth.ts @@ -1,6 +1,6 @@ import type { OAuthClientMetadataInput, OAuthSession } from '@atproto/oauth-client-node' import type { EventHandlerRequest, H3Event, SessionManager } from 'h3' -import { NodeOAuthClient } from '@atproto/oauth-client-node' +import { NodeOAuthClient, AtprotoDohHandleResolver } from '@atproto/oauth-client-node' import { parse } from 'valibot' import { getOAuthLock } from '#server/utils/atproto/lock' import { useOAuthStorage } from '#server/utils/atproto/storage' @@ -11,6 +11,13 @@ import { clientUri } from '#oauth/config' // TODO: If you add writing a new record you will need to add a scope for it export const scope = `atproto ${LIKES_SCOPE}` +/** + * Resolves a did to a handle via DoH or via the http website calls + */ +export const handleResolver = new AtprotoDohHandleResolver({ + dohEndpoint: 'https://cloudflare-dns.com/dns-query', +}) + export function getOauthClientMetadata() { const dev = import.meta.dev @@ -42,10 +49,13 @@ type EventHandlerWithOAuthSession = ( serverSession: SessionManager, ) => Promise -async function getOAuthSession(event: H3Event): Promise { +async function getOAuthSession( + event: H3Event, +): Promise<{ oauthSession: OAuthSession | undefined; serverSession: SessionManager }> { + const serverSession = await useServerSession(event) + try { const clientMetadata = getOauthClientMetadata() - const serverSession = await useServerSession(event) const { stateStore, sessionStore } = useOAuthStorage(serverSession) const client = new NodeOAuthClient({ @@ -53,13 +63,14 @@ async function getOAuthSession(event: H3Event): Promise( handler: EventHandlerWithOAuthSession, ) { return defineEventHandler(async event => { - const serverSession = await useServerSession(event) - - const oAuthSession = await getOAuthSession(event) - return await handler(event, oAuthSession, serverSession) + const { oauthSession, serverSession } = await getOAuthSession(event) + return await handler(event, oauthSession, serverSession) }) }