diff --git a/.eslintrc.js b/.eslintrc.js index ce54a38..b13962d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -10,6 +10,7 @@ module.exports = { rules: { 'prettier/prettier': 'off', 'arrow-body-style': 'off', - 'prefer-arrow-callback': 'off' + 'prefer-arrow-callback': 'off', + '@typescript-eslint/strict-boolean-expressions': 'off' } } diff --git a/.gitignore b/.gitignore index a1a9021..d72a312 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,7 @@ dist # Output dist +out # MacOS .DS_Store diff --git a/README.md b/README.md index aec2847..869dbbd 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ npm install ## Usage -Export an empty wallet. +### Export an empty wallet. ```ts export() // no wallet passed in, generates an empty Universal Wallet Backup TAR file @@ -71,6 +71,39 @@ meta: url: https://github.com/interop-alliance/wallet-export-ts ``` +### Export an ActivityPub Actor Profile + +```js +import * as fs from 'node:fs' +import { exportActorProfile } from '@interop/wallet-export-ts' + +const filename = 'out/test-export-2024-01-01.tar' +const tarball = fs.createWriteStream(filename) + +// Each of the arguments passed in is Optional +const packStream = exportActorProfile({ + actorProfile, outbox, followers, followingAccounts, lists, bookmarks, likes, + blockedAccounts, blockedDomains, mutedAccounts +}) + +packStream.pipe(tarball) +``` + +then + +``` +cd out +tar -vtf test-export-2024-01-01.tar + +drwxr-xr-x 0 0 0 0 Oct 9 20:19 activitypub +-rw-r--r-- 0 0 0 3526 Oct 9 20:19 activitypub/actor.json +-rw-r--r-- 0 0 0 4367 Oct 9 20:19 activitypub/outbox.json +-rw-r--r-- 0 0 0 386 Oct 9 20:19 manifest.yaml +``` + +see https://codeberg.org/fediverse/fep/src/branch/main/fep/6fcd/fep-6fcd.md#activitypub-export-example +for contents + ## Contribute PRs accepted. diff --git a/package.json b/package.json index 16c3a3f..bdb4498 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,15 @@ "./package.json": "./package.json" }, "dependencies": { - "tar-stream": "^3.1.7" + "tar-stream": "^3.1.7", + "yaml": "^2.5.1" }, "devDependencies": { "@types/chai": "^4.3.10", "@types/mocha": "^10.0.4", "@types/node": "^20.9.1", + "@types/streamx": "^2.9.5", + "@types/tar-stream": "^3.1.3", "@typescript-eslint/eslint-plugin": "^6.11.0", "@typescript-eslint/parser": "^6.11.0", "chai": "^4.3.10", diff --git a/src/Example.ts b/src/Example.ts deleted file mode 100644 index 4f6c548..0000000 --- a/src/Example.ts +++ /dev/null @@ -1,8 +0,0 @@ -/*! - * Copyright (c) 2021 Interop Alliance and Dmitri Zagidulin. All rights reserved. - */ -export class Example { - public hello(): string { - return 'world' - } -} diff --git a/src/index.ts b/src/index.ts index 66e421f..4e7b998 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,132 @@ /*! - * Copyright (c) 2021 Interop Alliance and Dmitri Zagidulin. All rights reserved. + * Copyright (c) 2024 Interop Alliance and Dmitri Zagidulin. All rights reserved. */ -export { Example } from './Example' +import * as tar from 'tar-stream' +import { type Pack } from 'tar-stream' +import YAML from 'yaml' + +export type ActorProfileOptions = { + actorProfile?: any + outbox?: any + followers?: any + followingAccounts?: any + lists?: any + bookmarks?: any + likes?: any + blockedAccounts?: any + blockedDomains?: any + mutedAccounts?: any +} + +export function exportActorProfile ({ + actorProfile, outbox, followers, followingAccounts, lists, bookmarks, likes, + blockedAccounts, blockedDomains, mutedAccounts +}: ActorProfileOptions): tar.Pack { + const pack: Pack = tar.pack() // pack is a stream + + const manifest: any = { + // Universal Backup Container spec version + 'ubc-version': '0.1', + meta: { + createdBy: { + client: { + // URL to the client that generated this export file + url: 'https://github.com/interop-alliance/wallet-export-ts' + } + } + }, + contents: { + 'manifest.yml': { + url: 'https://w3id.org/fep/6fcd#manifest-file' + }, + // Directory with ActivityPub-relevant exports + activitypub: { + contents: {} + } + } + } + + pack.entry({ name: 'activitypub', type: 'directory' }) + + if (actorProfile) { + // Serialized ActivityPub Actor profile + manifest.contents.activitypub.contents['actor.json'] = { + url: 'https://www.w3.org/TR/activitypub/#actor-objects' + } + pack.entry({ name: 'activitypub/actor.json' }, JSON.stringify(actorProfile, null, 2)) + } + + if (outbox) { + // ActivityStreams OrderedCollection representing the contents of the actor's Outbox + manifest.contents.activitypub.contents['outbox.json'] = { + url: 'https://www.w3.org/TR/activitystreams-core/#collections' + } + pack.entry({ name: 'activitypub/outbox.json' }, JSON.stringify(outbox, null, 2)) + } + + if (followers) { + // ActivityStreams OrderedCollection representing the actor's Followers + manifest.contents.activitypub.contents['followers.json'] = { + url: 'https://www.w3.org/TR/activitystreams-core/#collections' + } + pack.entry({ name: 'activitypub/followers.json' }, JSON.stringify(followers, null, 2)) + } + + if (likes) { + // ActivityStreams OrderedCollection representing Activities and Objects the actor liked + manifest.contents.activitypub.contents['likes.json'] = { + url: 'https://www.w3.org/TR/activitystreams-core/#collections' + } + pack.entry({ name: 'activitypub/likes.json' }, JSON.stringify(likes, null, 2)) + } + + if (bookmarks) { + // ActivityStreams OrderedCollection representing the actor's Bookmarks + manifest.contents.activitypub.contents['bookmarks.json'] = { + url: 'https://www.w3.org/TR/activitystreams-core/#collections' + } + pack.entry({ name: 'activitypub/bookmarks.json' }, JSON.stringify(bookmarks, null, 2)) + } + + if (followingAccounts) { + // CSV headers: + // Account address, Show boosts, Notify on new posts, Languages + manifest.contents.activitypub.contents['following_accounts.csv'] = { + url: 'https://docs.joinmastodon.org/user/moving/#export' + } + pack.entry({ name: 'activitypub/following_accounts.csv' }, followingAccounts) + } + + if (lists) { + manifest.contents.activitypub.contents['lists.csv'] = { + url: 'https://docs.joinmastodon.org/user/moving/#export' + } + pack.entry({ name: 'activitypub/lists.csv' }, lists) + } + + if (blockedAccounts) { + manifest.contents.activitypub.contents['blocked_accounts.csv'] = { + url: 'https://docs.joinmastodon.org/user/moving/#export' + } + pack.entry({ name: 'activitypub/blocked_accounts.csv' }, blockedAccounts) + } + + if (blockedDomains) { + manifest.contents.activitypub.contents['blocked_domains.csv'] = { + url: 'https://docs.joinmastodon.org/user/moving/#export' + } + pack.entry({ name: 'activitypub/blocked_domains.csv' }, blockedDomains) + } + + if (mutedAccounts) { + manifest.contents.activitypub.contents['muted_accounts.csv'] = { + url: 'https://docs.joinmastodon.org/user/moving/#export' + } + pack.entry({ name: 'activitypub/muted_accounts.csv' }, mutedAccounts) + } + + pack.entry({ name: 'manifest.yaml' }, YAML.stringify(manifest)) + + return pack +} + diff --git a/test.js b/test.js deleted file mode 100644 index 7e11777..0000000 --- a/test.js +++ /dev/null @@ -1,9 +0,0 @@ -const tar = require('tar-stream') -const pack = tar.pack() // pack is a stream - -pack.entry({ name: 'app', type: 'directory' }) -pack.entry({ name: 'keys', type: 'directory' }) -pack.entry({ name: 'manifest.yaml' }, '---\nubc-version: 0.1\n') - -// pipe the pack stream somewhere -pack.pipe(process.stdout) diff --git a/test/Example.spec.ts b/test/Example.spec.ts deleted file mode 100644 index 3bf5e63..0000000 --- a/test/Example.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expect } from 'chai' -import { Example } from '../src' - -describe('Example', () => { - it('calls function', async () => { - const ex = new Example() - expect(ex.hello()).to.equal('world') - }) -}) diff --git a/test/fixtures/actorProfile.ts b/test/fixtures/actorProfile.ts new file mode 100644 index 0000000..0f31ad0 --- /dev/null +++ b/test/fixtures/actorProfile.ts @@ -0,0 +1,105 @@ +// Sample Mastodon profile circa Oct 2024 +export const actorProfile = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "toot": "http://joinmastodon.org/ns#", + "featured": { + "@id": "toot:featured", + "@type": "@id" + }, + "featuredTags": { + "@id": "toot:featuredTags", + "@type": "@id" + }, + "alsoKnownAs": { + "@id": "as:alsoKnownAs", + "@type": "@id" + }, + "movedTo": { + "@id": "as:movedTo", + "@type": "@id" + }, + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value", + "discoverable": "toot:discoverable", + "Device": "toot:Device", + "Ed25519Signature": "toot:Ed25519Signature", + "Ed25519Key": "toot:Ed25519Key", + "Curve25519Key": "toot:Curve25519Key", + "EncryptedMessage": "toot:EncryptedMessage", + "publicKeyBase64": "toot:publicKeyBase64", + "deviceId": "toot:deviceId", + "claim": { + "@type": "@id", + "@id": "toot:claim" + }, + "fingerprintKey": { + "@type": "@id", + "@id": "toot:fingerprintKey" + }, + "identityKey": { + "@type": "@id", + "@id": "toot:identityKey" + }, + "devices": { + "@type": "@id", + "@id": "toot:devices" + }, + "messageFranking": "toot:messageFranking", + "messageType": "toot:messageType", + "cipherText": "toot:cipherText", + "suspended": "toot:suspended", + "Hashtag": "as:Hashtag", + "focalPoint": { + "@container": "@list", + "@id": "toot:focalPoint" + } + } + ], + "id": "https://example.com/users/alice", + "type": "Person", + "following": "https://example.com/users/alice/following", + "followers": "https://example.com/users/alice/followers", + "inbox": "https://example.com/users/alice/inbox", + "outbox": "outbox.json", + "featured": "https://example.com/users/alice/collections/featured", + "featuredTags": "https://example.com/users/alice/collections/tags", + "preferredUsername": "alice", + "name": "Alice", + "summary": "

Profile description goes here.
(she/her) #nobot

", + "url": "https://example.com/@alice", + "manuallyApprovesFollowers": false, + "discoverable": true, + "published": "2022-11-24T00:00:00Z", + "devices": "https://example.com/users/alice/collections/devices", + "alsoKnownAs": [ + "https://alice.example" + ], + "publicKey": { + "id": "https://example.com/users/alice#main-key", + "owner": "https://example.com/users/alice", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzPpH+K1JcB3fH7889Lt\nJIwV2nhU0TovHRsDT2SN3Ew/c03YxEIjICJUy3rrejNOyLL0cegspzRYQDrEIbh8\nsIxuNB7wdHajjW9KkF/yvKHKuXT9RXIB4HIXXzkdjEpVrEJgn5LnLZyyWb4ZXBPF\nyhVf0l3+OcQ5hcS7WinVAbcoLU5G3eMa7w4QV6+kEkoRzGUHMtlQMWtLePAKgWM1\nXsLFC3ZPNk/j4gvHPKWmN+hhLSoB4nIJ91dEDeg12OfpIgbnEPzFyXhopVn2GmJ8\n163omcPS5tpheMNxkkYXOmG+qzVFzCXACSXmual/sRP8z+44Z92ONKjg01+5aeMN\n+QIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "tag": [ + { + "type": "Hashtag", + "href": "https://example.com/tags/nobot", + "name": "#nobot" + } + ], + "attachment": [], + "endpoints": { + "sharedInbox": "https://example.com/inbox" + }, + "icon": { + "type": "Image", + "mediaType": "image/jpeg", + "url": "avatar.jpg" + }, + "likes": "likes.json", + "bookmarks": "bookmarks.json" +} diff --git a/test/fixtures/outbox.ts b/test/fixtures/outbox.ts new file mode 100644 index 0000000..da4bde7 --- /dev/null +++ b/test/fixtures/outbox.ts @@ -0,0 +1,99 @@ +export const outbox = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "outbox.json", + "type": "OrderedCollection", + "totalItems": 3, + "orderedItems": [ + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/alice/statuses/109407353983685867/activity", + "type": "Announce", + "actor": "https://example.com/users/alice", + "published": "2022-11-26T00:48:56Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://mastodon.social/users/bobwyman", + "https://example.com/users/alice/followers" + ], + "object": "https://mastodon.social/users/bobwyman/statuses/109399822260648081" + }, + { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/alice/statuses/109412389200730237/activity", + "type": "Announce", + "actor": "https://example.com/users/alice", + "published": "2022-11-26T22:09:27Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://omfg.town/users/dansinker", + "https://example.com/users/alice/followers" + ], + "object": "https://omfg.town/users/dansinker/statuses/109411849664571485" + }, + { + "id": "https://example.com/users/alice/statuses/109419229622587183/activity", + "type": "Create", + "actor": "https://example.com/users/alice", + "published": "2022-11-28T03:09:04Z", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/users/alice/followers", + "https://mindly.social/users/djdellamorte" + ], + "object": { + "id": "https://example.com/users/alice/statuses/109419229622587183", + "type": "Note", + "summary": null, + "inReplyTo": "https://mindly.social/users/djdellamorte/statuses/109417853187485668", + "published": "2022-11-28T03:09:04Z", + "url": "https://example.com/@alice/109419229622587183", + "attributedTo": "https://example.com/users/alice", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/users/alice/followers", + "https://mindly.social/users/djdellamorte" + ], + "sensitive": false, + "atomUri": "https://example.com/users/alice/statuses/109419229622587183", + "inReplyToAtomUri": "https://mindly.social/users/djdellamorte/statuses/109417853187485668", + "conversation": "tag:mindly.social,2022-11-27:objectId=5397243:objectType=Conversation", + "content": "

@djdellamorte I see them used a lot as a teleprompter, when recording videos or podcasts.

", + "contentMap": { + "en": "

@djdellamorte I see them used a lot as a teleprompter, when recording videos or podcasts.

" + }, + "attachment": [], + "tag": [ + { + "type": "Mention", + "href": "https://mindly.social/users/djdellamorte", + "name": "@djdellamorte@mindly.social" + } + ], + "replies": { + "id": "https://example.com/users/alice/statuses/109419229622587183/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.com/users/alice/statuses/109419229622587183/replies?only_other_accounts=true&page=true", + "partOf": "https://example.com/users/alice/statuses/109419229622587183/replies", + "items": [] + } + } + }, + "signature": { + "type": "RsaSignature2017", + "creator": "https://example.com/users/alice#main-key", + "created": "2023-05-09T02:46:04Z", + "signatureValue": "eLb+AzyjDei4G6MkRlw/CCxObyWS+dMAo+8NlvPATt9xjud+KLSq8oc9vaSEYk+3uovw5XfdVdlFF+FAgq1kDGJJfGq4xOVpm8JzLtqbMsEfB6BFAEGyCvO8iQD9pFhNRrzZOKoznKrnFjLnItbv9eyNefZISEqHRuO6wHcfTvuPGrChwNPg9FKUQaSvB1wx9KShgypzcQbZA5BMXhJSQGcIIZXGa2GenXi6brGlIorFxb5nNtJnGpn2kxKHQpcfFsA4L2q/sIzVYTPO+O/KyLtjWIaITHm0R1SeToGK47M/yOR3a/7oh/r/5ncKornLnTwKW+EBn41E8cxMnHIZsw==" + } + }, + ] +} diff --git a/test/index.spec.ts b/test/index.spec.ts new file mode 100644 index 0000000..5f718f5 --- /dev/null +++ b/test/index.spec.ts @@ -0,0 +1,18 @@ +// import { expect } from 'chai' +import * as fs from 'node:fs' + +import { exportActorProfile } from '../src' +import { outbox } from './fixtures/outbox' +import { actorProfile } from './fixtures/actorProfile' + +describe('exportActorProfile', () => { + it('calls function', async () => { + const filename = 'out/test-export-2024-01-01.tar' + const tarball = fs.createWriteStream(filename) + + const packStream = exportActorProfile({ actorProfile, outbox }) + + packStream.pipe(tarball) + }) +}) + diff --git a/tsconfig.json b/tsconfig.json index 2fd5bca..cb4402b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,7 @@ "sourceMap": true, /* Generates corresponding '.map' file. */ // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./dist", /* Redirect output structure to the directory. */ - "rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + "rootDirs": ["src", "test/fixtures"], /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ // "composite": true, /* Enable project compilation */ // "removeComments": true, /* Do not emit comments to output. */ // "noEmit": true, /* Do not emit outputs. */