diff --git a/src/backend/common/infrastructure/config/client/atproto.ts b/src/backend/common/infrastructure/config/client/atproto.ts new file mode 100644 index 00000000..6915835c --- /dev/null +++ b/src/backend/common/infrastructure/config/client/atproto.ts @@ -0,0 +1,23 @@ +import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node"; + +export interface ATProtoUserIdentifierData { + /** + * Identify the account to login as + * + * * For **App Password** Auth - your email + * * For **Oauth** - your handle minus the @ + */ + identifier: string + did?: AtprotoDid +} + +export interface ATProtoAppData { + /** + * The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account + * + * This is created under https://bsky.app/settings/app-passwords + * + * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** + */ + appPassword: string +} \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index 4bc84bf2..482f1b87 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -1,28 +1,14 @@ import { RequestRetryOptions } from "../common.js" +import { ATProtoAppData, ATProtoUserIdentifierData } from "./atproto.js" import { CommonClientConfig, CommonClientData, CommonClientOptions } from "./index.js" -export interface TealData extends RequestRetryOptions { +export type TealData = RequestRetryOptions & ATProtoUserIdentifierData & Partial & { /** * The base URI of the Multi-Scrobbler to use for ATProto OAuth * * Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol. */ baseUri?: string - /** - * Identify the account to login as - * - * * For **App Password** Auth - your email - * * For **Oauth** - your handle minus the @ - */ - identifier: string - /** - * The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account - * - * This is created under https://bsky.app/settings/app-passwords - * - * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** - */ - appPassword?: string } export interface TealClientData extends TealData, CommonClientData { diff --git a/src/backend/common/vendor/atproto/ATProtoAppApiClient.ts b/src/backend/common/vendor/atproto/ATProtoAppApiClient.ts index fdec3781..9b024483 100644 --- a/src/backend/common/vendor/atproto/ATProtoAppApiClient.ts +++ b/src/backend/common/vendor/atproto/ATProtoAppApiClient.ts @@ -2,28 +2,12 @@ import { AbstractApiOptions } from "../../infrastructure/Atomic.js"; import { TealClientData } from "../../infrastructure/config/client/tealfm.js"; import { Agent, CredentialSession, AtpSessionEvent, AtpSessionData } from "@atproto/api"; import { AbstractATProtoApiClient } from "./AbstractATProtoApiClient.js"; -import { isPortReachableConnect, normalizeWebAddress } from "../../../utils/NetworkUtils.js"; -import { URLData } from "../../../../core/Atomic.js"; -import { isNodeNetworkException } from "../../errors/NodeErrors.js"; -import { - CompositeDidDocumentResolver, - CompositeHandleResolver, - DohJsonHandleResolver, - PlcDidDocumentResolver, - WebDidDocumentResolver, - WellKnownHandleResolver, -} from "@atcute/identity-resolver"; -import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node"; import { identifierToAtProtoHandle, isDID } from "./atUtils.js"; - -interface HandleData { - did: string - pds: string -} +import { ATProtoAppData, ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js"; export class ATProtoAppApiClient extends AbstractATProtoApiClient { - declare config: TealClientData & {did?: AtprotoDid}; + declare config: ATProtoUserIdentifierData & ATProtoAppData; appSession?: CredentialSession; appPwAuth: boolean @@ -38,10 +22,13 @@ export class ATProtoAppApiClient extends AbstractATProtoApiClient { } else { this.config.identifier = identifierToAtProtoHandle(this.config.identifier, {logger: this.logger, defaultDomain: 'bsky.social'}); } + if(this.config.appPassword === undefined) { + throw new Error('Must provide app password'); + } } async initClient(): Promise { - const hd = await this.getATProtoIdentifier(); + const hd = await this.getATProtoIdentifier(this.config); this.logger.verbose(`Using ${hd.did} on PDS ${hd.pds}`); this.appSession = new CredentialSession(new URL(hd.pds), undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { this.cache.cacheAuth.set(`appPwSession-${this.name}-${hd.did}`, sess, '1000h'); @@ -49,65 +36,8 @@ export class ATProtoAppApiClient extends AbstractATProtoApiClient { this.agent = new Agent(this.appSession); } - protected async getATProtoIdentifier(): Promise { - - let hd: HandleData; - hd = await this.cache.cacheAuth.get(`${this.name}-handleData`); - if (hd !== undefined) { - this.logger.debug('Found cached handle data'); - return hd; - } else { - this.logger.debug('Handle data not cached, attempting to resolve...'); - } - - const handleResolver = new CompositeHandleResolver({ - strategy: "race", - methods: { - dns: new DohJsonHandleResolver({ - dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", - }), - http: new WellKnownHandleResolver(), - }, - }); - - let did: AtprotoDid = this.config.did; - if(did === undefined) { - try { - did = await handleResolver.resolve(this.config.identifier as `${string}.${string}`); - this.logger.debug(`Resolved ${did}`); - } catch (e) { - throw new Error('Unable to resolve handle', { cause: e }); - } - } - - const docResolver = new CompositeDidDocumentResolver({ - methods: { - plc: new PlcDidDocumentResolver(), - web: new WebDidDocumentResolver(), - }, - }); - - let doc: Awaited>; - try { - doc = await docResolver.resolve(did); - } catch (e) { - throw new Error('Unable to resolve did document', { cause: e }); - } - if (doc.service === undefined || doc.service.length === 0) { - throw new Error('did document did not return a service'); - } - - if (typeof doc.service[0].serviceEndpoint !== 'string') { - throw new Error(`Do not know how to handle this serviceEndpoint data structure!\n${JSON.stringify(doc.service[0].serviceEndpoint)}`); - } - hd = { did, pds: doc.service[0].serviceEndpoint }; - - this.cache.cacheAuth.set(`${this.name}-handleData`, hd, '1d'); - return hd; - } - restoreSession = async (): Promise => { - const hd = await this.getATProtoIdentifier(); + const hd = await this.getATProtoIdentifier(this.config); const savedSession = await this.cache.cacheAuth.get(`appPwSession-${this.name}-${hd.did}`); if (savedSession !== undefined) { try { @@ -142,25 +72,4 @@ export class ATProtoAppApiClient extends AbstractATProtoApiClient { } } - async checkPds(): Promise { - let hd: HandleData; - try { - hd = await this.getATProtoIdentifier(); - } catch(e) { - throw new Error('Unable to get handle data', {cause: e}); - } - - const normal = normalizeWebAddress(hd.pds); - - try { - await isPortReachableConnect(normal.port, {host: normal.url.hostname}); - return true; - } catch (e) { - if(isNodeNetworkException(e)) { - throw new Error('Could not communicate with PDS server', {cause: e}); - } - throw new Error('Unexpected error when trying to communicate with PDS server', {cause: e}); - } - } - } \ No newline at end of file diff --git a/src/backend/common/vendor/atproto/AbstractATProtoApiClient.ts b/src/backend/common/vendor/atproto/AbstractATProtoApiClient.ts index 1f6c3ffc..b5b65f3f 100644 --- a/src/backend/common/vendor/atproto/AbstractATProtoApiClient.ts +++ b/src/backend/common/vendor/atproto/AbstractATProtoApiClient.ts @@ -5,7 +5,23 @@ import AbstractApiClient from "../AbstractApiClient.js"; import { Agent, ComAtprotoRepoListRecords } from "@atproto/api"; import { MSCache } from "../../Cache.js"; import { UpstreamError } from "../../errors/UpstreamError.js"; -import { streamBodyProgress } from "../../../utils/NetworkUtils.js"; +import { isPortReachableConnect, normalizeWebAddress, streamBodyProgress } from "../../../utils/NetworkUtils.js"; +import { + CompositeDidDocumentResolver, + CompositeHandleResolver, + DohJsonHandleResolver, + PlcDidDocumentResolver, + WebDidDocumentResolver, + WellKnownHandleResolver, +} from "@atcute/identity-resolver"; +import { AtprotoDid, DidDocument } from "@atproto/oauth-client-node"; +import { isNodeNetworkException } from "../../errors/NodeErrors.js"; +import { ATProtoUserIdentifierData } from "../../infrastructure/config/client/atproto.js"; + +interface HandleData { + did: string + pds: string +} export abstract class AbstractATProtoApiClient extends AbstractApiClient { @@ -14,7 +30,7 @@ export abstract class AbstractATProtoApiClient extends AbstractApiClient { cache: MSCache; constructor(name: any, config: TealClientData, options: AbstractApiOptions) { - super('blueSky', name, config, options); + super('atproto', name, config, options); this.cache = getRoot().items.cache(); } @@ -39,6 +55,90 @@ export abstract class AbstractATProtoApiClient extends AbstractApiClient { } } + protected async getATProtoIdentifier(data: ATProtoUserIdentifierData): Promise { + + let hd: HandleData; + hd = await this.cache.cacheAuth.get(`${this.name}-handleData`); + if (hd !== undefined) { + this.logger.debug('Found cached handle data'); + return hd; + } else { + this.logger.debug('Handle data not cached, attempting to resolve...'); + } + + const handleResolver = new CompositeHandleResolver({ + strategy: "race", + methods: { + dns: new DohJsonHandleResolver({ + dohUrl: "https://mozilla.cloudflare-dns.com/dns-query", + }), + http: new WellKnownHandleResolver(), + }, + }); + + const { + did: givenDid, + identifier + } = data; + + let did: AtprotoDid = givenDid; + if(did === undefined) { + try { + did = await handleResolver.resolve(identifier as `${string}.${string}`); + this.logger.debug(`Resolved ${did}`); + } catch (e) { + throw new Error('Unable to resolve handle', { cause: e }); + } + } + + const docResolver = new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }); + + let doc: Awaited>; + try { + doc = await docResolver.resolve(did); + } catch (e) { + throw new Error('Unable to resolve did document', { cause: e }); + } + if (doc.service === undefined || doc.service.length === 0) { + throw new Error('did document did not return a service'); + } + + if (typeof doc.service[0].serviceEndpoint !== 'string') { + throw new Error(`Do not know how to handle this serviceEndpoint data structure!\n${JSON.stringify(doc.service[0].serviceEndpoint)}`); + } + hd = { did, pds: doc.service[0].serviceEndpoint }; + + this.cache.cacheAuth.set(`${this.name}-handleData`, hd, '1d'); + return hd; + } + + + async checkPds(data: ATProtoUserIdentifierData): Promise { + let hd: HandleData; + try { + hd = await this.getATProtoIdentifier(data); + } catch(e) { + throw new Error('Unable to get handle data', {cause: e}); + } + + const normal = normalizeWebAddress(hd.pds); + + try { + await isPortReachableConnect(normal.port, {host: normal.url.hostname}); + return true; + } catch (e) { + if(isNodeNetworkException(e)) { + throw new Error('Could not communicate with PDS server', {cause: e}); + } + throw new Error('Unexpected error when trying to communicate with PDS server', {cause: e}); + } + } + async getCAR() { const resp = await this.agent.sessionManager.fetchHandler(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(this.agent.sessionManager.did)}`, { method: 'GET', diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index 18b78c8b..cb563e7b 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -84,7 +84,7 @@ export default class TealScrobbler extends AbstractHistoricalScrobbleClient { protected async doCheckConnection(): Promise { if (this.client.client instanceof ATProtoAppApiClient) { try { - return await this.client.client.checkPds(); + return await this.client.client.checkPds(this.config.data); } catch (e) { throw e; } diff --git a/src/backend/sources/TealfmSource.ts b/src/backend/sources/TealfmSource.ts index ae96e70b..ab85194a 100644 --- a/src/backend/sources/TealfmSource.ts +++ b/src/backend/sources/TealfmSource.ts @@ -72,7 +72,7 @@ export default class TealfmSource extends MemorySource { protected async doCheckConnection(): Promise { if (this.client instanceof ATProtoAppApiClient) { try { - return await this.client.checkPds(); + return await this.client.checkPds(this.config.data); } catch (e) { throw e; }