diff --git a/config/tealfm.json.example b/config/tealfm.json.example index d6ff53fa..352667d2 100644 --- a/config/tealfm.json.example +++ b/config/tealfm.json.example @@ -3,7 +3,7 @@ "name": "myTeal", "configureAs": "client", "data": { - "identifier": "myemail@gmail.com", + "identifier": "alice.bsky.social", "appPassword": "twog-phu7-4dhe-y4j3" } }, @@ -11,7 +11,7 @@ "name": "myTealSource", "configureAs": "source", "data": { - "identifier": "myemail@gmail.com", + "identifier": "alice.bsky.social", "appPassword": "twog-phu7-4dhe-y4j3" } } diff --git a/docsite/docs/configuration/clients/tealfm.mdx b/docsite/docs/configuration/clients/tealfm.mdx index 7f5287ac..063bbf14 100644 --- a/docsite/docs/configuration/clients/tealfm.mdx +++ b/docsite/docs/configuration/clients/tealfm.mdx @@ -40,8 +40,6 @@ So... The default Bluesky PDS is **public**, meaning your scrobbles are also public (read-only). This isn't any different than using Last.fm or Listenbrainz, in terms of privacy. -Multi-scrobbler lets you define a different **PDS** so you can store your scrobbles on **any** ATProto PDS/instance you have access to. - ::: :::note @@ -66,10 +64,19 @@ To view your teal.fm scrobbles you can: ## Configuration +### Handle + +The handle used with multi-scrobbler should be your **full** ATProto handle, including TLD. + +* For regular Bluesky account this will be like: `alice.bsky.social` +* For Bluesky accounts that [use their website as their account](https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial) this is your domain: `mydomain.com` +* For non-Bluesky users, you probably already know your handle + +If you do not include a TLD then multi-scrobbler will automatically append `.bsky.social` to your handle value. + - | Environmental Variable | Required? | Default | Description | - | :--------------------- | --------- | --------------------- | ---------------------------------------------- | - | TEALFM_IDENTIFIER | Yes | | Email used to login to Bluesky/ATProto network | - | TEALFM_APP_PW | Yes | | Bluesky/ATProto network App Password | - | TEALFM_PDS | No | `https://bsky.social` | The URL for the PDS | + | Environmental Variable | Required? | Default | Description | + | :--------------------- | --------- | ------- | ----------------------------------------------------------------------------------- | + | TEALFM_IDENTIFIER | Yes | | Your **full** ATProto handle. For Bluesky account this is like `myUser.bsky.social` | + | TEALFM_APP_PW | Yes | | Bluesky/ATProto network App Password | \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 4cdb7ff5..ea495abf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "MIT", "dependencies": { "@astronautlabs/mdns": "^1.0.7", + "@atcute/identity-resolver-node": "^1.0.3", "@atproto/api": "^0.18.0", "@atproto/oauth-client-node": "^0.3.10", "@awaitjs/express": "^0.6.3", @@ -172,6 +173,65 @@ "resolved": "https://registry.npmjs.org/@astronautlabs/mdns/-/mdns-1.0.10.tgz", "integrity": "sha512-9bVdQ15Tbzpor1z/bteaemC9NvRDZgMD4Cfbu1/r/6Zv6fCBXvIJJoJ6Dh/BMvTlAiT2Xewm36i0VWRnZ4yw6A==" }, + "node_modules/@atcute/identity": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-1.1.1.tgz", + "integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@atcute/lexicons": "^1.2.2", + "@badrap/valita": "^0.4.6" + } + }, + "node_modules/@atcute/identity-resolver": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-1.1.4.tgz", + "integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@atcute/lexicons": "^1.2.2", + "@atcute/util-fetch": "^1.0.3", + "@badrap/valita": "^0.4.6" + }, + "peerDependencies": { + "@atcute/identity": "^1.0.0" + } + }, + "node_modules/@atcute/identity-resolver-node": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver-node/-/identity-resolver-node-1.0.3.tgz", + "integrity": "sha512-RPH5M4ZRayKRcGnJWUOPVhN5WSYURXXZxKzgVT9lj/WZCH6ij2Vg3P3Eva7GGs0SG1ytnX1XVBTMoIk8nF/SLQ==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^1.2.2" + }, + "peerDependencies": { + "@atcute/identity": "^1.0.0", + "@atcute/identity-resolver": "^1.0.0" + } + }, + "node_modules/@atcute/lexicons": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-1.2.2.tgz", + "integrity": "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA==", + "license": "0BSD", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/util-fetch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-1.0.3.tgz", + "integrity": "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@badrap/valita": "^0.4.6" + } + }, "node_modules/@atproto-labs/did-resolver": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.2.2.tgz", @@ -717,6 +777,16 @@ "node": ">=6.9.0" } }, + "node_modules/@badrap/valita": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@badrap/valita/-/valita-0.4.6.tgz", + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 18" + } + }, "node_modules/@bufbuild/protobuf": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.1.tgz", @@ -2520,6 +2590,12 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, "node_modules/@stdlib/error-tools-fmtprodmsg": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/@stdlib/error-tools-fmtprodmsg/-/error-tools-fmtprodmsg-0.2.2.tgz", @@ -5676,6 +5752,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", diff --git a/package.json b/package.json index 4c174333..17168e6e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "homepage": ".", "dependencies": { "@astronautlabs/mdns": "^1.0.7", + "@atcute/identity-resolver-node": "^1.0.3", "@atproto/api": "^0.18.0", "@atproto/oauth-client-node": "^0.3.10", "@awaitjs/express": "^0.6.3", diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index 1ffea0d6..1ee12498 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -41,12 +41,6 @@ export interface TealClientConfig extends CommonClientConfig { } export interface TealOptions { - /** The [PDS (Personal Data Server)](https://github.com/bluesky-social/pds) to use - * - * @default "https://bsky.social" - * @examples ["https://bsky.social"] - */ - pds?: string } diff --git a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts index 990198b7..70257313 100644 --- a/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts +++ b/src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts @@ -25,7 +25,7 @@ export abstract class AbstractBlueSkyApiClient extends AbstractApiClient { this.cache = getRoot().items.cache(); } - abstract initClient(): void; + abstract initClient(): Promise; abstract restoreSession(): Promise; diff --git a/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts b/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts index 3f582a0e..412288c3 100644 --- a/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts +++ b/src/backend/common/vendor/bluesky/BlueSkyAppApiClient.ts @@ -5,40 +5,100 @@ import { AbstractBlueSkyApiClient } from "./AbstractBlueSkyApiClient.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"; +interface HandleData { + did: string + pds: string +} export class BlueSkyAppApiClient extends AbstractBlueSkyApiClient { declare config: TealClientData; - - pds: URLData appSession?: CredentialSession; appPwAuth: boolean - constructor(name: any, config: TealClientData & {pds?: string}, options: AbstractApiOptions) { + constructor(name: any, config: TealClientData, options: AbstractApiOptions) { super(name, config, options); - - this.pds = normalizeWebAddress(config.pds ?? 'https://bsky.social'); - this.logger.verbose(`Using App Password auth for session with PDS ${this.pds.url}`); + this.logger.verbose(`Using App Password auth for session`); } - protected initClientApp() { - this.appSession = new CredentialSession(this.pds.url, undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.cache.cacheAuth.set(`appPwSession-${this.name}`, sess); + async initClient(): Promise { + const hd = await this.getATProtoIdentifier(); + 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'); }); this.agent = new Agent(this.appSession); } - initClient() { - this.appSession = new CredentialSession(this.pds.url, undefined, (evt: AtpSessionEvent, sess?: AtpSessionData) => { - this.cache.cacheAuth.set(`appPwSession-${this.name}`, sess); + 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(), + }, }); - this.agent = new Agent(this.appSession); + + let did: AtprotoDid; + 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 savedSession = await this.cache.cacheAuth.get(`appPwSession-${this.name}`); + const hd = await this.getATProtoIdentifier(); + const savedSession = await this.cache.cacheAuth.get(`appPwSession-${this.name}-${hd.did}`); if (savedSession !== undefined) { try { this.logger.debug('Found existing session, trying to resume...'); @@ -73,8 +133,17 @@ export class BlueSkyAppApiClient extends AbstractBlueSkyApiClient { } 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(this.pds.port, {host: this.pds.url.hostname}); + await isPortReachableConnect(normal.port, {host: normal.url.hostname}); return true; } catch (e) { if(isNodeNetworkException(e)) { diff --git a/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts b/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts index 81b8415d..6d31dd44 100644 --- a/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts +++ b/src/backend/common/vendor/bluesky/BlueSkyOauthApiClient.ts @@ -24,7 +24,7 @@ export class BlueSkyOauthApiClient extends AbstractBlueSkyApiClient { this.logger.verbose('Will use oauth for session'); } - initClient = () => { + initClient = async () => { const sessionStore: NodeSavedSessionStore = { set: (k: string, state) => this.cache.cacheAuth.set(`session-${this.name}-${k}`, state).then(() => null), get: (k: string) => this.cache.cacheAuth.get(`session-${this.name}-${k}`), diff --git a/src/backend/scrobblers/TealfmScrobbler.ts b/src/backend/scrobblers/TealfmScrobbler.ts index 9320ae12..81ced851 100644 --- a/src/backend/scrobblers/TealfmScrobbler.ts +++ b/src/backend/scrobblers/TealfmScrobbler.ts @@ -29,10 +29,10 @@ export default class TealScrobbler extends AbstractScrobbleClient { this.scrobbleDelay = 1500; this.supportsNowPlaying = false; if(config.data.appPassword !== undefined) { - this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger}); + this.client = new BlueSkyAppApiClient(name, config.data, {...options, logger}); this.requiresAuthInteraction = false; } else if(config.data.baseUri !== undefined) { - this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...options, logger}); + this.client = new BlueSkyOauthApiClient(name, config.data, {...options, logger}); } else { throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`); } @@ -54,7 +54,7 @@ export default class TealScrobbler extends AbstractScrobbleClient { if (identifier === undefined) { throw new Error('Must provide an identifier'); } - this.client.initClient(); + await this.client.initClient(); return true; } diff --git a/src/backend/sources/TealfmSource.ts b/src/backend/sources/TealfmSource.ts index 6bafa837..3d6cd742 100644 --- a/src/backend/sources/TealfmSource.ts +++ b/src/backend/sources/TealfmSource.ts @@ -30,10 +30,10 @@ export default class TealfmSource extends MemorySource { this.canPoll = true; this.canBacklog = true; if(config.data.appPassword !== undefined) { - this.client = new BlueSkyAppApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger}); + this.client = new BlueSkyAppApiClient(name, config.data, {...internal, logger: internal.logger}); this.requiresAuthInteraction = false; } else if(config.data.baseUri !== undefined) { - this.client = new BlueSkyOauthApiClient(name, {...config.data, pds: config.options?.pds}, {...internal, logger: internal.logger}); + this.client = new BlueSkyOauthApiClient(name, config.data, {...internal, logger: internal.logger}); } else { throw new Error(`Must define either 'baseUri' or 'appPassword' in configuration!`); } @@ -53,7 +53,7 @@ export default class TealfmSource extends MemorySource { if (identifier === undefined) { throw new Error('Must provide an identifier'); } - this.client.initClient(); + await this.client.initClient(); return true; }