From 25be15b2a073197082d278c286bc93fd95d94057 Mon Sep 17 00:00:00 2001 From: Sven Sauleau Date: Mon, 5 Dec 2022 20:14:56 +0000 Subject: [PATCH] Import wildebeest code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sven Sauleau Co-authored-by: Dario Piotrowicz Co-authored-by: André Cruz Co-authored-by: James Culveyhouse Co-authored-by: Pete Bacon Darwin --- .eslintignore | 31 + .eslintrc.cjs | 44 + .github/workflows/PRs.yml | 42 + .gitignore | 4 + .node-version | 1 + .prettierignore | 6 + .prettierrc.json | 7 + .wrangler/state/d1/.gitkeep | 0 CONTRIBUTING.md | 97 + LICENSE | 13 + README.md | 9 + backend/src/access/index.ts | 191 + backend/src/activitypub/activities/accept.ts | 14 + .../src/activitypub/activities/announce.ts | 16 + backend/src/activitypub/activities/create.ts | 39 + backend/src/activitypub/activities/follow.ts | 14 + backend/src/activitypub/activities/handle.ts | 347 + backend/src/activitypub/activities/index.ts | 7 + backend/src/activitypub/activities/like.ts | 16 + .../src/activitypub/activities/unfollow.ts | 15 + backend/src/activitypub/activities/update.ts | 16 + backend/src/activitypub/actors/follow.ts | 24 + backend/src/activitypub/actors/inbox.ts | 13 + backend/src/activitypub/actors/index.ts | 237 + backend/src/activitypub/actors/outbox.ts | 56 + backend/src/activitypub/core.ts | 15 + backend/src/activitypub/deliver.ts | 75 + backend/src/activitypub/objects/image.ts | 12 + backend/src/activitypub/objects/index.ts | 172 + backend/src/activitypub/objects/note.ts | 52 + backend/src/config/index.ts | 59 + backend/src/errors/index.ts | 38 + backend/src/mastodon/account.ts | 95 + backend/src/mastodon/client.ts | 60 + backend/src/mastodon/follow.ts | 119 + backend/src/mastodon/like.ts | 32 + backend/src/mastodon/notification.ts | 239 + backend/src/mastodon/reblog.ts | 34 + backend/src/mastodon/reply.ts | 58 + backend/src/mastodon/status.ts | 176 + backend/src/mastodon/subscription.ts | 138 + backend/src/mastodon/timeline.ts | 124 + backend/src/media/image.ts | 51 + backend/src/media/index.ts | 81 + backend/src/middleware/error.ts | 11 + backend/src/middleware/logger.ts | 15 + backend/src/middleware/main.ts | 120 + backend/src/types/account.ts | 70 + backend/src/types/configs.ts | 21 + backend/src/types/context.ts | 21 + backend/src/types/env.ts | 7 + backend/src/types/index.ts | 4 + backend/src/types/media.ts | 11 + backend/src/types/notification.ts | 22 + backend/src/types/status.ts | 34 + backend/src/utils/handle.ts | 10 + backend/src/utils/http-signing-cavage.ts | 170 + backend/src/utils/http-signing.ts | 36 + backend/src/utils/httpsigjs/LICENSE | 18 + backend/src/utils/httpsigjs/parser.ts | 365 ++ backend/src/utils/httpsigjs/utils.ts | 53 + backend/src/utils/httpsigjs/verifier.ts | 29 + backend/src/utils/key-ops.ts | 163 + backend/src/utils/parse.ts | 21 + backend/src/webfinger/index.ts | 48 + backend/src/webpush/hkdf.ts | 18 + backend/src/webpush/index.ts | 48 + backend/src/webpush/jwk.ts | 9 + backend/src/webpush/message.ts | 174 + backend/src/webpush/util.ts | 81 + backend/src/webpush/vapid.ts | 48 + backend/src/webpush/webpushinfos.ts | 22 + backend/test/activitypub.spec.ts | 345 + backend/test/activitypub/follow.spec.ts | 183 + backend/test/activitypub/inbox.spec.ts | 322 + backend/test/mastodon.spec.ts | 246 + backend/test/mastodon/accounts.spec.ts | 883 +++ backend/test/mastodon/media.spec.ts | 58 + backend/test/mastodon/notifications.spec.ts | 154 + backend/test/mastodon/oauth.spec.ts | 220 + backend/test/mastodon/search.spec.ts | 172 + backend/test/mastodon/statuses.spec.ts | 487 ++ backend/test/mastodon/timelines.spec.ts | 239 + backend/test/mastodon/trends.spec.ts | 16 + backend/test/middleware.spec.ts | 120 + backend/test/start-instance.spec.ts | 50 + backend/test/test-data.ts | 38 + backend/test/utils.spec.ts | 74 + backend/test/utils.ts | 66 + backend/test/webfinger.spec.ts | 55 + cfsetup.yaml | 19 + config/accounts.ts | 6 + frontend/.eslintrc.cjs | 37 + frontend/.gitignore | 41 + frontend/README.md | 17 + .../adaptors/cloudflare-pages/vite.config.ts | 20 + frontend/jest.config.js | 7 + frontend/mock-db/init.ts | 42 + frontend/mock-db/run.mjs | 33 + frontend/mock-db/worker.ts | 35 + frontend/package.json | 41 + frontend/public/_headers | 4 + frontend/public/_redirects | 1 + frontend/public/_routes.json | 5 + frontend/public/favicon.ico | Bin 0 -> 15086 bytes frontend/public/manifest.json | 9 + frontend/public/robots.txt | 0 frontend/src/components/MastodonLogo.tsx | 52 + frontend/src/components/Sparkline/index.tsx | 90 + frontend/src/components/Status.scss | 45 + frontend/src/components/Status.tsx | 57 + .../components/StickyHeader/StickyHeader.scss | 10 + .../components/StickyHeader/StickyHeader.tsx | 13 + .../src/components/TagDetailsCard/index.tsx | 26 + frontend/src/components/header/header.tsx | 4 + .../components/router-head/router-head.tsx | 32 + frontend/src/dummyData.tsx | 5650 +++++++++++++++++ frontend/src/entry.cloudflare-pages.tsx | 16 + frontend/src/entry.ssr.tsx | 21 + frontend/src/root.tsx | 27 + .../routes/LeftColumn/InstanceDetails.scss | 9 + .../src/routes/LeftColumn/InstanceDetails.tsx | 39 + .../src/routes/LeftColumn/LeftColumn.scss | 17 + frontend/src/routes/LeftColumn/LeftColumn.tsx | 14 + .../src/routes/RightColumn/RightColumn.scss | 3 + .../src/routes/RightColumn/RightColumn.tsx | 53 + .../routes/[accountId]/[statusId]/index.scss | 14 + .../routes/[accountId]/[statusId]/index.tsx | 87 + frontend/src/routes/[accountId]/index.tsx | 8 + frontend/src/routes/explore/Explore.tsx | 75 + frontend/src/routes/explore/index.tsx | 19 + frontend/src/routes/explore/layout.scss | 38 + frontend/src/routes/explore/layout.tsx | 56 + frontend/src/routes/explore/links/index.scss | 13 + frontend/src/routes/explore/links/index.tsx | 41 + frontend/src/routes/explore/tags/index.tsx | 29 + frontend/src/routes/first-login/index.tsx | 58 + frontend/src/routes/index.tsx | 11 + frontend/src/routes/layout.scss | 17 + frontend/src/routes/layout.tsx | 43 + frontend/src/routes/public/index.tsx | 34 + frontend/src/routes/public/local/index.tsx | 33 + frontend/src/routes/service-worker.ts | 18 + frontend/src/routes/start-instance/index.tsx | 76 + frontend/src/routes/start-instance/step-1.tsx | 125 + frontend/src/routes/start-instance/step-2.tsx | 80 + frontend/src/routes/start-instance/utils.ts | 28 + frontend/src/styles/background.scss | 46 + frontend/src/styles/border.scss | 78 + frontend/src/styles/display.scss | 86 + frontend/src/styles/global.scss | 40 + .../src/styles/interactive-utilities.scss | 35 + frontend/src/styles/layout.scss | 9 + frontend/src/styles/outline.scss | 3 + frontend/src/styles/rounding.scss | 43 + frontend/src/styles/sizing.scss | 56 + frontend/src/styles/spacing.scss | 97 + frontend/src/styles/text.scss | 128 + frontend/src/styles/theme.scss | 47 + frontend/src/styles/utility.scss | 9 + frontend/src/types.ts | 221 + frontend/src/utils/dateTime.ts | 48 + frontend/src/utils/history.ts | 16 + frontend/src/utils/numbers.ts | 23 + frontend/src/utils/useDomain.ts | 8 + frontend/test/posts-page.spec.ts | 17 + frontend/tsconfig.json | 26 + frontend/vite.config.ts | 27 + frontend/yarn.lock | 4805 ++++++++++++++ functions/.well-known/webfinger.ts | 59 + functions/[[path]].ts | 3 + functions/_middleware.ts | 4 + functions/ap/_middleware.ts | 3 + functions/ap/users/[id].ts | 45 + functions/ap/users/[id]/followers.ts | 40 + functions/ap/users/[id]/followers/page.ts | 47 + functions/ap/users/[id]/following.ts | 40 + functions/ap/users/[id]/following/page.ts | 47 + functions/ap/users/[id]/inbox.ts | 69 + functions/ap/users/[id]/outbox.ts | 56 + functions/ap/users/[id]/outbox/page.ts | 116 + functions/api/_middleware.ts | 3 + functions/api/v1/accounts/[id].ts | 62 + .../api/v1/accounts/[id]/featured_tags.ts | 9 + functions/api/v1/accounts/[id]/follow.ts | 57 + functions/api/v1/accounts/[id]/followers.ts | 47 + functions/api/v1/accounts/[id]/following.ts | 47 + functions/api/v1/accounts/[id]/lists.ts | 9 + functions/api/v1/accounts/[id]/statuses.ts | 176 + functions/api/v1/accounts/[id]/unfollow.ts | 55 + functions/api/v1/accounts/relationships.ts | 62 + .../api/v1/accounts/update_credentials.ts | 114 + .../api/v1/accounts/verify_credentials.ts | 43 + functions/api/v1/apps.ts | 45 + functions/api/v1/custom_emojis.ts | 10 + functions/api/v1/instance.ts | 47 + functions/api/v1/notifications.ts | 34 + functions/api/v1/notifications/[id].ts | 84 + functions/api/v1/push/subscription.ts | 90 + functions/api/v1/statuses.ts | 112 + functions/api/v1/statuses/[id].ts | 28 + functions/api/v1/statuses/[id]/context.ts | 33 + functions/api/v1/statuses/[id]/favourite.ts | 58 + functions/api/v1/statuses/[id]/reblog.ts | 67 + functions/api/v1/timelines/home.ts | 35 + functions/api/v1/timelines/public.ts | 36 + functions/api/v1/trends/statuses.ts | 12 + functions/api/v2/instance.ts | 6 + functions/api/v2/media.ts | 69 + functions/api/v2/search.ts | 79 + functions/first-login.ts | 40 + functions/oauth/authorize.ts | 84 + functions/oauth/token.ts | 46 + functions/start-instance-test-access.ts | 48 + functions/start-instance.ts | 40 + jest.config.js | 12 + migrations/0000_initial.sql | 190 + package.json | 46 + tsconfig.json | 109 + wrangler.toml | 14 + yarn.lock | 3936 ++++++++++++ 221 files changed, 28155 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/PRs.yml create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .wrangler/state/d1/.gitkeep create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/src/access/index.ts create mode 100644 backend/src/activitypub/activities/accept.ts create mode 100644 backend/src/activitypub/activities/announce.ts create mode 100644 backend/src/activitypub/activities/create.ts create mode 100644 backend/src/activitypub/activities/follow.ts create mode 100644 backend/src/activitypub/activities/handle.ts create mode 100644 backend/src/activitypub/activities/index.ts create mode 100644 backend/src/activitypub/activities/like.ts create mode 100644 backend/src/activitypub/activities/unfollow.ts create mode 100644 backend/src/activitypub/activities/update.ts create mode 100644 backend/src/activitypub/actors/follow.ts create mode 100644 backend/src/activitypub/actors/inbox.ts create mode 100644 backend/src/activitypub/actors/index.ts create mode 100644 backend/src/activitypub/actors/outbox.ts create mode 100644 backend/src/activitypub/core.ts create mode 100644 backend/src/activitypub/deliver.ts create mode 100644 backend/src/activitypub/objects/image.ts create mode 100644 backend/src/activitypub/objects/index.ts create mode 100644 backend/src/activitypub/objects/note.ts create mode 100644 backend/src/config/index.ts create mode 100644 backend/src/errors/index.ts create mode 100644 backend/src/mastodon/account.ts create mode 100644 backend/src/mastodon/client.ts create mode 100644 backend/src/mastodon/follow.ts create mode 100644 backend/src/mastodon/like.ts create mode 100644 backend/src/mastodon/notification.ts create mode 100644 backend/src/mastodon/reblog.ts create mode 100644 backend/src/mastodon/reply.ts create mode 100644 backend/src/mastodon/status.ts create mode 100644 backend/src/mastodon/subscription.ts create mode 100644 backend/src/mastodon/timeline.ts create mode 100644 backend/src/media/image.ts create mode 100644 backend/src/media/index.ts create mode 100644 backend/src/middleware/error.ts create mode 100644 backend/src/middleware/logger.ts create mode 100644 backend/src/middleware/main.ts create mode 100644 backend/src/types/account.ts create mode 100644 backend/src/types/configs.ts create mode 100644 backend/src/types/context.ts create mode 100644 backend/src/types/env.ts create mode 100644 backend/src/types/index.ts create mode 100644 backend/src/types/media.ts create mode 100644 backend/src/types/notification.ts create mode 100644 backend/src/types/status.ts create mode 100644 backend/src/utils/handle.ts create mode 100644 backend/src/utils/http-signing-cavage.ts create mode 100644 backend/src/utils/http-signing.ts create mode 100644 backend/src/utils/httpsigjs/LICENSE create mode 100644 backend/src/utils/httpsigjs/parser.ts create mode 100644 backend/src/utils/httpsigjs/utils.ts create mode 100644 backend/src/utils/httpsigjs/verifier.ts create mode 100644 backend/src/utils/key-ops.ts create mode 100644 backend/src/utils/parse.ts create mode 100644 backend/src/webfinger/index.ts create mode 100644 backend/src/webpush/hkdf.ts create mode 100644 backend/src/webpush/index.ts create mode 100644 backend/src/webpush/jwk.ts create mode 100644 backend/src/webpush/message.ts create mode 100644 backend/src/webpush/util.ts create mode 100644 backend/src/webpush/vapid.ts create mode 100644 backend/src/webpush/webpushinfos.ts create mode 100644 backend/test/activitypub.spec.ts create mode 100644 backend/test/activitypub/follow.spec.ts create mode 100644 backend/test/activitypub/inbox.spec.ts create mode 100644 backend/test/mastodon.spec.ts create mode 100644 backend/test/mastodon/accounts.spec.ts create mode 100644 backend/test/mastodon/media.spec.ts create mode 100644 backend/test/mastodon/notifications.spec.ts create mode 100644 backend/test/mastodon/oauth.spec.ts create mode 100644 backend/test/mastodon/search.spec.ts create mode 100644 backend/test/mastodon/statuses.spec.ts create mode 100644 backend/test/mastodon/timelines.spec.ts create mode 100644 backend/test/mastodon/trends.spec.ts create mode 100644 backend/test/middleware.spec.ts create mode 100644 backend/test/start-instance.spec.ts create mode 100644 backend/test/test-data.ts create mode 100644 backend/test/utils.spec.ts create mode 100644 backend/test/utils.ts create mode 100644 backend/test/webfinger.spec.ts create mode 100644 cfsetup.yaml create mode 100644 config/accounts.ts create mode 100644 frontend/.eslintrc.cjs create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/adaptors/cloudflare-pages/vite.config.ts create mode 100644 frontend/jest.config.js create mode 100644 frontend/mock-db/init.ts create mode 100644 frontend/mock-db/run.mjs create mode 100644 frontend/mock-db/worker.ts create mode 100644 frontend/package.json create mode 100644 frontend/public/_headers create mode 100644 frontend/public/_redirects create mode 100644 frontend/public/_routes.json create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/robots.txt create mode 100644 frontend/src/components/MastodonLogo.tsx create mode 100644 frontend/src/components/Sparkline/index.tsx create mode 100644 frontend/src/components/Status.scss create mode 100644 frontend/src/components/Status.tsx create mode 100644 frontend/src/components/StickyHeader/StickyHeader.scss create mode 100644 frontend/src/components/StickyHeader/StickyHeader.tsx create mode 100644 frontend/src/components/TagDetailsCard/index.tsx create mode 100644 frontend/src/components/header/header.tsx create mode 100644 frontend/src/components/router-head/router-head.tsx create mode 100644 frontend/src/dummyData.tsx create mode 100644 frontend/src/entry.cloudflare-pages.tsx create mode 100644 frontend/src/entry.ssr.tsx create mode 100644 frontend/src/root.tsx create mode 100644 frontend/src/routes/LeftColumn/InstanceDetails.scss create mode 100644 frontend/src/routes/LeftColumn/InstanceDetails.tsx create mode 100644 frontend/src/routes/LeftColumn/LeftColumn.scss create mode 100644 frontend/src/routes/LeftColumn/LeftColumn.tsx create mode 100644 frontend/src/routes/RightColumn/RightColumn.scss create mode 100644 frontend/src/routes/RightColumn/RightColumn.tsx create mode 100644 frontend/src/routes/[accountId]/[statusId]/index.scss create mode 100644 frontend/src/routes/[accountId]/[statusId]/index.tsx create mode 100644 frontend/src/routes/[accountId]/index.tsx create mode 100644 frontend/src/routes/explore/Explore.tsx create mode 100644 frontend/src/routes/explore/index.tsx create mode 100644 frontend/src/routes/explore/layout.scss create mode 100644 frontend/src/routes/explore/layout.tsx create mode 100644 frontend/src/routes/explore/links/index.scss create mode 100644 frontend/src/routes/explore/links/index.tsx create mode 100644 frontend/src/routes/explore/tags/index.tsx create mode 100644 frontend/src/routes/first-login/index.tsx create mode 100644 frontend/src/routes/index.tsx create mode 100644 frontend/src/routes/layout.scss create mode 100644 frontend/src/routes/layout.tsx create mode 100644 frontend/src/routes/public/index.tsx create mode 100644 frontend/src/routes/public/local/index.tsx create mode 100644 frontend/src/routes/service-worker.ts create mode 100644 frontend/src/routes/start-instance/index.tsx create mode 100644 frontend/src/routes/start-instance/step-1.tsx create mode 100644 frontend/src/routes/start-instance/step-2.tsx create mode 100644 frontend/src/routes/start-instance/utils.ts create mode 100644 frontend/src/styles/background.scss create mode 100644 frontend/src/styles/border.scss create mode 100644 frontend/src/styles/display.scss create mode 100644 frontend/src/styles/global.scss create mode 100644 frontend/src/styles/interactive-utilities.scss create mode 100644 frontend/src/styles/layout.scss create mode 100644 frontend/src/styles/outline.scss create mode 100644 frontend/src/styles/rounding.scss create mode 100644 frontend/src/styles/sizing.scss create mode 100644 frontend/src/styles/spacing.scss create mode 100644 frontend/src/styles/text.scss create mode 100644 frontend/src/styles/theme.scss create mode 100644 frontend/src/styles/utility.scss create mode 100644 frontend/src/types.ts create mode 100644 frontend/src/utils/dateTime.ts create mode 100644 frontend/src/utils/history.ts create mode 100644 frontend/src/utils/numbers.ts create mode 100644 frontend/src/utils/useDomain.ts create mode 100644 frontend/test/posts-page.spec.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/yarn.lock create mode 100644 functions/.well-known/webfinger.ts create mode 100644 functions/[[path]].ts create mode 100644 functions/_middleware.ts create mode 100644 functions/ap/_middleware.ts create mode 100644 functions/ap/users/[id].ts create mode 100644 functions/ap/users/[id]/followers.ts create mode 100644 functions/ap/users/[id]/followers/page.ts create mode 100644 functions/ap/users/[id]/following.ts create mode 100644 functions/ap/users/[id]/following/page.ts create mode 100644 functions/ap/users/[id]/inbox.ts create mode 100644 functions/ap/users/[id]/outbox.ts create mode 100644 functions/ap/users/[id]/outbox/page.ts create mode 100644 functions/api/_middleware.ts create mode 100644 functions/api/v1/accounts/[id].ts create mode 100644 functions/api/v1/accounts/[id]/featured_tags.ts create mode 100644 functions/api/v1/accounts/[id]/follow.ts create mode 100644 functions/api/v1/accounts/[id]/followers.ts create mode 100644 functions/api/v1/accounts/[id]/following.ts create mode 100644 functions/api/v1/accounts/[id]/lists.ts create mode 100644 functions/api/v1/accounts/[id]/statuses.ts create mode 100644 functions/api/v1/accounts/[id]/unfollow.ts create mode 100644 functions/api/v1/accounts/relationships.ts create mode 100644 functions/api/v1/accounts/update_credentials.ts create mode 100644 functions/api/v1/accounts/verify_credentials.ts create mode 100644 functions/api/v1/apps.ts create mode 100644 functions/api/v1/custom_emojis.ts create mode 100644 functions/api/v1/instance.ts create mode 100644 functions/api/v1/notifications.ts create mode 100644 functions/api/v1/notifications/[id].ts create mode 100644 functions/api/v1/push/subscription.ts create mode 100644 functions/api/v1/statuses.ts create mode 100644 functions/api/v1/statuses/[id].ts create mode 100644 functions/api/v1/statuses/[id]/context.ts create mode 100644 functions/api/v1/statuses/[id]/favourite.ts create mode 100644 functions/api/v1/statuses/[id]/reblog.ts create mode 100644 functions/api/v1/timelines/home.ts create mode 100644 functions/api/v1/timelines/public.ts create mode 100644 functions/api/v1/trends/statuses.ts create mode 100644 functions/api/v2/instance.ts create mode 100644 functions/api/v2/media.ts create mode 100644 functions/api/v2/search.ts create mode 100644 functions/first-login.ts create mode 100644 functions/oauth/authorize.ts create mode 100644 functions/oauth/token.ts create mode 100644 functions/start-instance-test-access.ts create mode 100644 functions/start-instance.ts create mode 100644 jest.config.js create mode 100644 migrations/0000_initial.sql create mode 100644 package.json create mode 100644 tsconfig.json create mode 100644 wrangler.toml create mode 100644 yarn.lock diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..039dbd2 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,31 @@ +**/*.log +**/.DS_Store +*. +.vscode/settings.json +.history +.yarn +bazel-* +bazel-bin +bazel-out +bazel-qwik +bazel-testlogs +dist +dist-dev +lib +lib-types +etc +external +node_modules +temp +tsc-out +tsdoc-metadata.json +target +output +rollup.config.js +build +.cache +.vscode +.rollup.cache +dist +tsconfig.tsbuildinfo +vite.config.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..5c1bf2f --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,44 @@ +module.exports = { + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + ], + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + }, + plugins: ['@typescript-eslint'], + root: true, + rules: { + 'no-var': 'error', + /* + Note: the following rules have been set to off so that linting + can pass with the current code, but we need to gradually + re-enable most of them + */ + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/restrict-plus-operands': 'off', + 'no-constant-condition': 'off', + '@typescript-eslint/await-thenable': 'off', + 'prefer-const': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'off', + 'no-console': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-inferrable-types': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/no-empty-interface': 'off', + }, +} diff --git a/.github/workflows/PRs.yml b/.github/workflows/PRs.yml new file mode 100644 index 0000000..af2dd1c --- /dev/null +++ b/.github/workflows/PRs.yml @@ -0,0 +1,42 @@ +name: Pull request checks +on: + push: + pull_request: + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + test-api: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: 16.13.x + - name: Install + run: yarn + - name: Check formatting + run: yarn pretty + - name: Run API tests + run: yarn test + + test-ui: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: 16.13.x + - name: Install + run: yarn && yarn --cwd frontend + - name: Initialize local database + run: yarn database:create-mock + - name: Run UI tests + run: yarn test:ui diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c48073 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +yarn-error.log +package-lock.json +.wrangler/state/d1/*.sqlite3 diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..7da30cb --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +16.13 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..1592248 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +# Files Prettier should not format +**/*.log +**/.DS_Store +*. +dist +node_modules diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..9ecf81c --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "trailingComma": "es5", + "useTabs": true, + "semi": false, + "singleQuote": true, + "printWidth": 120 +} diff --git a/.wrangler/state/d1/.gitkeep b/.wrangler/state/d1/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7eaa1a6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contribute to Wildebeest + +## Getting started + +Install: + +```sh +yarn +``` + +## Running tests + +Run the API (backend) unit tests: + +```sh +yarn test +``` + +Run the UI (frontend) integration tests: + +```sh +yarn database:create-mock # this initializes a local test database +yarn test:ui +``` + +## Debugging locally + +```sh +yarn database:create-mock # this initializes a local test database +yarn dev +``` + +If only working on the REST API endpoints this is sufficient. +Any changes to the `functions` directory and the files it imports will re-run the Pages build. + +Changes to the UI code will not trigger a rebuild automatically. +To do so, run the following in second terminal: + +```sh +yarn --cwd frontend watch +``` + +## Deploying + +This is a Cloudflare Pages project and can be deployed directly from the command line using Wrangler. + +First you must create and configure the Pages project and bindings (D1 database, KV namespace, etc). + +### Initialization + +Run the following command to create the Pages project and the D1 database in your account. + +``` +yarn deploy:init +``` + +You should see output like: + +``` +✅ Successfully created DB 'wildebeest'! + +Add the following to your wrangler.toml to connect to it from a Worker: + +[[ d1_databases ]] +binding = "DB" # i.e. available in your Worker on env.DB +database_name = "wildebeest" +database_id = "ddce04a1-fd51-40cb-be21-e899d70fb9f3" +``` + +Grab the database_id from the command line output and add it to the wrangler.toml file. Don't change the binding name in the wrangler.toml. It should stay as `DATABASE`. + +Next go to the Pages dashboard and add the D1 database to the newly created Pages project. This can be found at + +``` +wildebeest->Settings->Functions->D1 database bindings->Add binding +``` + +Enter `DATABASE` for the variable name and select the `wildebeest` database from the dropdown. + +### Environment variables + +wildebeest expectes the Pages project to inject the following environment variables. + +Secret used to encrypt user private key in the database: +- `USER_KEY` + +API token for integration with Cloudflare services (Cloudflare Images for example): +- `CF_ACCOUNT_ID` +- `CF_API_TOKEN` + +### Deployment + +Run the following command to deploy the current working directory to Cloudflare Pages: + +``` +yarn deploy +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..192405b --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +Copyright (c) 2023 Cloudflare, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7353747 --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Wildebeest + +Nothing to see yet here, follow the Cloudflare blog. + +## User registration + +User registration is not supported by Wildebeest. Instead it relies on [Cloudflare Access] for user management, when you are allowed by [Cloudflare Access] you can use the Mastodon login flow and which will register you if needed. + +[Cloudflare Access]: https://www.cloudflare.com/products/zero-trust/access/ diff --git a/backend/src/access/index.ts b/backend/src/access/index.ts new file mode 100644 index 0000000..defbbb5 --- /dev/null +++ b/backend/src/access/index.ts @@ -0,0 +1,191 @@ +// Copied from the @cloudflare/pages-plugin-cloudflare-access package but fixes two important issues: +// - uses the Authorization header to find the Access JWT (instead os Cf-Access-Jwt-Assertion). +// - prevents loosing the Response.status value across Pages middleware + +const isTesting = typeof jest !== 'undefined' + +const textDecoder = new TextDecoder('utf-8') + +export type Identity = { + id: string + name: string + email: string + groups: string[] + amr: string[] + idp: { id: string; type: string } + geo: { country: string } + user_uuid: string + account_id: string + ip: string + auth_status: string + common_name: string + service_token_id: string + service_token_status: boolean + is_warp: boolean + is_gateway: boolean + version: number + device_sessions: Record + iat: number +} + +export type JWTPayload = { + aud: string | string[] + common_name?: string // Service token client ID + country?: string + custom?: unknown + email?: string + exp: number + iat: number + nbf?: number + iss: string // https://.cloudflareaccess.com + type?: string // Always just 'app'? + identity_nonce?: string + sub: string // Empty string for service tokens or user ID otherwise +} + +export type PluginArgs = { + aud: string + domain: string +} + +type CloudflareAccessPagesPluginFunction< + Env = unknown, + Params extends string = any, + Data extends Record = Record +> = PagesPluginFunction + +// Adapted slightly from https://github.com/cloudflare/workers-access-external-auth-example +const base64URLDecode = (s: string) => { + s = s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '') + return new Uint8Array( + // @ts-ignore + Array.prototype.map.call(atob(s), (c: string) => c.charCodeAt(0)) + ) +} + +const asciiToUint8Array = (s: string) => { + let chars = [] + for (let i = 0; i < s.length; ++i) { + chars.push(s.charCodeAt(i)) + } + return new Uint8Array(chars) +} + +export function getPayload(jwt: string): JWTPayload { + const parts = jwt.split('.') + if (parts.length !== 3) { + throw new Error('JWT does not have three parts.') + } + const [, payload] = parts + + const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload))) + return payloadObj +} + +export const generateValidator = + ({ domain, aud, jwt }: { domain: string; aud: string; jwt: string }) => + async ( + request: Request + ): Promise<{ + payload: object + }> => { + const parts = jwt.split('.') + if (parts.length !== 3) { + throw new Error('JWT does not have three parts.') + } + const [header, payload, signature] = parts + + const { kid, alg } = JSON.parse(textDecoder.decode(base64URLDecode(header))) + if (alg !== 'RS256') { + throw new Error('Unknown JWT type or algorithm.') + } + + const certsURL = new URL('/cdn-cgi/access/certs', 'https://' + domain) + const certsResponse = await fetch(certsURL.toString()) + const { keys } = (await certsResponse.json()) as { + keys: ({ + kid: string + } & JsonWebKey)[] + public_cert: { kid: string; cert: string } + public_certs: { kid: string; cert: string }[] + } + if (!keys) { + throw new Error('Could not fetch signing keys.') + } + const jwk = keys.find((key) => key.kid === kid) + if (!jwk) { + throw new Error('Could not find matching signing key.') + } + if (jwk.kty !== 'RSA' || jwk.alg !== 'RS256') { + throw new Error('Unknown key type of algorithm.') + } + + const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, [ + 'verify', + ]) + + const unroundedSecondsSinceEpoch = Date.now() / 1000 + + const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload))) + + // For testing disable JWT checks. + // Ideally we match the production behavior in testing but that + // requires using local key pair to generate a valid JWT token. + // For now, let's keep it simple. + if (!isTesting) { + if (payloadObj.iss && payloadObj.iss !== certsURL.origin) { + throw new Error('JWT issuer is incorrect.') + } + if (payloadObj.aud && !payloadObj.aud.includes(aud)) { + throw new Error('JWT audience is incorrect.') + } + if (payloadObj.exp && Math.floor(unroundedSecondsSinceEpoch) >= payloadObj.exp) { + throw new Error('JWT has expired.') + } + if (payloadObj.nbf && Math.ceil(unroundedSecondsSinceEpoch) < payloadObj.nbf) { + throw new Error('JWT is not yet valid.') + } + } + + const verified = await crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + key, + base64URLDecode(signature), + asciiToUint8Array(`${header}.${payload}`) + ) + if (!verified) { + throw new Error('Could not verify JWT.') + } + + return { payload: payloadObj } + } + +export const getIdentity = async ({ jwt, domain }: { jwt: string; domain: string }): Promise => { + const identityURL = new URL('/cdn-cgi/access/get-identity', 'https://' + domain) + const response = await fetch(identityURL.toString(), { + headers: { Cookie: `CF_Authorization=${jwt}` }, + }) + if (response.ok) return await response.json() +} + +export const generateLoginURL = ({ + redirectURL: redirectURLInit, + domain, + aud, +}: { + redirectURL: string | URL + domain: string + aud: string +}): string => { + const redirectURL = typeof redirectURLInit === 'string' ? new URL(redirectURLInit) : redirectURLInit + const { hostname } = redirectURL + const loginPathname = `/cdn-cgi/access/login/${hostname}?` + const searchParams = new URLSearchParams({ + kid: aud, + redirect_url: redirectURL.pathname + redirectURL.search, + }) + return new URL(loginPathname + searchParams.toString(), 'https://' + domain).toString() +} + +export const generateLogoutURL = ({ domain }: { domain: string }) => + new URL(`/cdn-cgi/access/logout`, 'https://' + domain).toString() diff --git a/backend/src/activitypub/activities/accept.ts b/backend/src/activitypub/activities/accept.ts new file mode 100644 index 0000000..30a58d1 --- /dev/null +++ b/backend/src/activitypub/activities/accept.ts @@ -0,0 +1,14 @@ +import type { Object } from '../objects' +import type { Actor } from '../actors' +import type { Activity } from '.' + +const ACCEPT = 'Accept' + +export function create(actor: Actor, object: Object): Activity { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: ACCEPT, + actor: actor.id, + object, + } +} diff --git a/backend/src/activitypub/activities/announce.ts b/backend/src/activitypub/activities/announce.ts new file mode 100644 index 0000000..35e166d --- /dev/null +++ b/backend/src/activitypub/activities/announce.ts @@ -0,0 +1,16 @@ +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce + +import type { Object } from '../objects' +import type { Actor } from '../actors' +import type { Activity } from '.' + +const ANNOUNCE = 'Announce' + +export function create(actor: Actor, object: URL): Activity { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: ANNOUNCE, + actor: actor.id, + object, + } +} diff --git a/backend/src/activitypub/activities/create.ts b/backend/src/activitypub/activities/create.ts new file mode 100644 index 0000000..2b6421b --- /dev/null +++ b/backend/src/activitypub/activities/create.ts @@ -0,0 +1,39 @@ +import type { Note } from '../objects/note' +import type { Actor } from '../actors' +import type { Activity } from '.' +import * as activity from '.' + +const CREATE = 'Create' + +export function create(domain: string, actor: Actor, object: Note): Activity { + const a: Activity = { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + ostatus: 'http://ostatus.org#', + atomUri: 'ostatus:atomUri', + inReplyToAtomUri: 'ostatus:inReplyToAtomUri', + conversation: 'ostatus:conversation', + sensitive: 'as:sensitive', + toot: 'http://joinmastodon.org/ns#', + votersCount: 'toot:votersCount', + }, + ], + id: activity.uri(domain), + type: CREATE, + actor: actor.id, + object, + } + + if (object.published) { + a.published = object.published + } + if (object.to) { + a.to = object.to + } + if (object.cc) { + a.cc = object.cc + } + + return a +} diff --git a/backend/src/activitypub/activities/follow.ts b/backend/src/activitypub/activities/follow.ts new file mode 100644 index 0000000..4acb0aa --- /dev/null +++ b/backend/src/activitypub/activities/follow.ts @@ -0,0 +1,14 @@ +import type { Object } from '../objects' +import type { Actor } from '../actors' +import type { Activity } from '.' + +const FOLLOW = 'Follow' + +export function create(actor: Actor, object: Object): Activity { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: FOLLOW, + actor: actor.id, + object: object.id, + } +} diff --git a/backend/src/activitypub/activities/handle.ts b/backend/src/activitypub/activities/handle.ts new file mode 100644 index 0000000..39ac499 --- /dev/null +++ b/backend/src/activitypub/activities/handle.ts @@ -0,0 +1,347 @@ +import * as actors from 'wildebeest/backend/src/activitypub/actors' +import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' +import { actorURL } from 'wildebeest/backend/src/activitypub/actors' +import * as objects from 'wildebeest/backend/src/activitypub/objects' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import * as accept from 'wildebeest/backend/src/activitypub/activities/accept' +import { addObjectInInbox } from 'wildebeest/backend/src/activitypub/actors/inbox' +import { + sendMentionNotification, + sendLikeNotification, + sendFollowNotification, + sendReblogNotification, + createNotification, + insertFollowNotification, +} from 'wildebeest/backend/src/mastodon/notification' +import { type Object, updateObject } from 'wildebeest/backend/src/activitypub/objects' +import { parseHandle } from 'wildebeest/backend/src/utils/parse' +import type { Note } from 'wildebeest/backend/src/activitypub/objects/note' +import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' +import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver' +import { getSigningKey } from 'wildebeest/backend/src/mastodon/account' +import { insertLike } from 'wildebeest/backend/src/mastodon/like' +import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog' +import { insertReply } from 'wildebeest/backend/src/mastodon/reply' +import type { Activity } from 'wildebeest/backend/src/activitypub/activities' + +function extractID(domain: string, s: string | URL): string { + return s.toString().replace(`https://${domain}/ap/users/`, '') +} + +export type HandleResponse = { + createdObjects: Array +} + +export type HandleMode = 'caching' | 'inbox' + +export async function handle( + domain: string, + activity: Activity, + db: D1Database, + userKEK: string, + mode: HandleMode +): Promise { + const createdObjects: Array = [] + + // The `object` field of the activity is required to be an object, with an + // `id` and a `type` field. + const requireComplexObject = () => { + if (typeof activity.object !== 'object') { + throw new Error('`activity.object` must be of type object') + } + } + + const getObjectAsId = () => { + let url: any = null + if (activity.object.id !== undefined) { + url = activity.object.id + } + if (typeof activity.object === 'string') { + url = activity.object + } + if (activity.object instanceof URL) { + // This is used for testing only. + return activity.object + } + if (url === null) { + throw new Error('unknown value: ' + JSON.stringify(activity.object)) + } + + try { + return new URL(url) + } catch (err) { + console.warn('invalid URL: ' + url) + throw err + } + } + + const getActorAsId = () => { + let url: any = null + if (activity.actor.id !== undefined) { + url = activity.actor.id + } + if (typeof activity.actor === 'string') { + url = activity.actor + } + if (activity.actor instanceof URL) { + // This is used for testing only. + return activity.actor + } + if (url === null) { + throw new Error('unknown value: ' + JSON.stringify(activity.actor)) + } + + try { + return new URL(url) + } catch (err) { + console.warn('invalid URL: ' + url) + throw err + } + } + + console.log(activity) + switch (activity.type) { + case 'Update': { + requireComplexObject() + const actorId = getActorAsId() + const objectId = getObjectAsId() + + // check current object + const object = await objects.getObjectBy(db, 'original_object_id', objectId.toString()) + if (object === null) { + throw new Error(`object ${objectId} does not exist`) + } + + if (actorId.toString() !== object.originalActorId) { + throw new Error('actorid mismatch when updating object') + } + + const updated = await updateObject(db, activity.object, object.id) + if (!updated) { + throw new Error('could not update object in database') + } + break + } + + // https://www.w3.org/TR/activitypub/#create-activity-inbox + case 'Create': { + requireComplexObject() + const actorId = getActorAsId() + + // FIXME: download any attachment Objects + + let recipients: Array = [] + + if (Array.isArray(activity.to)) { + recipients = [...recipients, ...activity.to] + } + if (Array.isArray(activity.cc)) { + recipients = [...recipients, ...activity.cc] + } + + const objectId = getObjectAsId() + const obj = await createObject(domain, activity.object, db, actorId, objectId) + if (obj === null) { + break + } + createdObjects.push(obj) + + const actor = await actors.getAndCache(actorId, db) + + // This note is actually a reply to another one, record it in the replies + // table. + if (obj.type === 'Note' && obj.inReplyTo) { + const inReplyToObjectId = new URL(obj.inReplyTo) + let inReplyToObject = await objects.getObjectByOriginalId(db, inReplyToObjectId) + + if (inReplyToObject === null) { + const remoteObject = await objects.get(inReplyToObjectId) + inReplyToObject = await objects.cacheObject(domain, db, remoteObject, actorId, inReplyToObjectId, false) + createdObjects.push(inReplyToObject) + } + + await insertReply(db, actor, obj, inReplyToObject) + } + + const fromActor = await actors.getAndCache(getActorAsId(), db) + // Add the object in the originating actor's outbox, allowing other + // actors on this instance to see the note in their timelines. + await addObjectInOutbox(db, fromActor, obj, activity.published) + + if (mode === 'inbox') { + for (let i = 0, len = recipients.length; i < len; i++) { + const handle = parseHandle(extractID(domain, recipients[i])) + if (handle.domain !== null && handle.domain !== domain) { + console.warn('activity not for current instance') + continue + } + + const person = await actors.getPersonById(db, actorURL(domain, handle.localPart)) + if (person === null) { + console.warn(`person ${recipients[i]} not found`) + continue + } + + // FIXME: check if the actor mentions the person + const notifId = await createNotification(db, 'mention', person, fromActor, obj) + await Promise.all([ + await addObjectInInbox(db, person, obj), + await sendMentionNotification(db, fromActor, person, notifId), + ]) + } + } + + break + } + + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept + case 'Accept': { + requireComplexObject() + const actorId = getActorAsId() + + const actor = await actors.getPersonById(db, activity.object.actor) + if (actor !== null) { + const follower = await actors.getAndCache(new URL(actorId), db) + await acceptFollowing(db, actor, follower) + } else { + console.warn(`actor ${activity.object.actor} not found`) + } + + break + } + + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow + case 'Follow': { + const objectId = getObjectAsId() + const actorId = getActorAsId() + + const receiver = await actors.getPersonById(db, objectId) + if (receiver !== null) { + const originalActor = await actors.getAndCache(new URL(actorId), db) + const receiverAcct = `${receiver.preferredUsername}@${domain}` + + await Promise.all([ + addFollowing(db, originalActor, receiver, receiverAcct), + acceptFollowing(db, originalActor, receiver), + ]) + + // Automatically send the Accept reply + const reply = accept.create(receiver, activity) + const signingKey = await getSigningKey(userKEK, db, receiver) + await deliverToActor(signingKey, receiver, originalActor, reply) + + // Notify the user + const notifId = await insertFollowNotification(db, receiver, originalActor) + await sendFollowNotification(db, originalActor, receiver, notifId) + } else { + console.warn(`actor ${objectId} not found`) + } + + break + } + + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce + case 'Announce': { + const actorId = getActorAsId() + const objectId = getObjectAsId() + + let obj: any = null + + const localObject = await objects.getObjectById(db, objectId) + if (localObject === null) { + try { + // Object doesn't exists locally, we'll need to download it. + const remoteObject = await objects.get(objectId) + + obj = await createObject(domain, remoteObject, db, actorId, objectId) + if (obj === null) { + break + } + createdObjects.push(obj) + } catch (err: any) { + console.warn(`failed to retrieve object ${objectId}: ${err.message}`) + break + } + } else { + // Object already exists locally, we can just use it. + obj = localObject + } + + const fromActor = await actors.getAndCache(actorId, db) + + // notify the user + const targetActor = await actors.getPersonById(db, new URL(obj.originalActorId)) + if (targetActor === null) { + console.warn('object actor not found') + break + } + + const notifId = await createNotification(db, 'reblog', targetActor, fromActor, obj) + + await Promise.all([ + // Add the object in the originating actor's outbox, allowing other + // actors on this instance to see the note in their timelines. + addObjectInOutbox(db, fromActor, obj, activity.published), + + // Store the reblog for counting + insertReblog(db, fromActor, obj), + + sendReblogNotification(db, fromActor, targetActor, notifId), + ]) + break + } + + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like + case 'Like': { + const actorId = getActorAsId() + const objectId = getObjectAsId() + + const obj = await objects.getObjectById(db, objectId) + if (obj === null || !obj.originalActorId) { + console.warn('unknown object') + break + } + + const fromActor = await actors.getAndCache(actorId, db) + const targetActor = await actors.getPersonById(db, new URL(obj.originalActorId)) + if (targetActor === null) { + console.warn('object actor not found') + break + } + + const [notifId] = await Promise.all([ + // Notify the user + createNotification(db, 'favourite', targetActor, fromActor, obj), + // Store the like for counting + insertLike(db, fromActor, obj), + ]) + + await sendLikeNotification(db, fromActor, targetActor, notifId) + break + } + + default: + console.warn(`Unsupported activity: ${activity.type}`) + } + + return { createdObjects } +} + +async function createObject( + domain: string, + obj: Object, + db: D1Database, + originalActorId: URL, + originalObjectId: URL +): Promise { + switch (obj.type) { + case 'Note': { + return objects.cacheObject(domain, db, obj, originalActorId, originalObjectId, false) + } + + default: { + console.warn(`Unsupported Create object: ${obj.type}`) + return null + } + } +} diff --git a/backend/src/activitypub/activities/index.ts b/backend/src/activitypub/activities/index.ts new file mode 100644 index 0000000..39cadd3 --- /dev/null +++ b/backend/src/activitypub/activities/index.ts @@ -0,0 +1,7 @@ +export type Activity = any + +// Generate a unique ID. Note that currently the generated URL aren't routable. +export function uri(domain: string): URL { + const id = crypto.randomUUID() + return new URL('/ap/a/' + id, 'https://' + domain) +} diff --git a/backend/src/activitypub/activities/like.ts b/backend/src/activitypub/activities/like.ts new file mode 100644 index 0000000..c3593a4 --- /dev/null +++ b/backend/src/activitypub/activities/like.ts @@ -0,0 +1,16 @@ +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like + +import type { Object } from '../objects' +import type { Actor } from '../actors' +import type { Activity } from '.' + +const Like = 'Like' + +export function create(actor: Actor, object: URL): Activity { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: Like, + actor: actor.id, + object, + } +} diff --git a/backend/src/activitypub/activities/unfollow.ts b/backend/src/activitypub/activities/unfollow.ts new file mode 100644 index 0000000..585e2df --- /dev/null +++ b/backend/src/activitypub/activities/unfollow.ts @@ -0,0 +1,15 @@ +import type { Object } from '../objects' +import type { Actor } from '../actors' +import type { Activity } from '.' +import * as follow from './follow' + +const UNDO = 'Undo' + +export function create(actor: Actor, object: Object): Activity { + return { + '@context': 'https://www.w3.org/ns/activitystreams', + type: UNDO, + actor: actor.id, + object: follow.create(actor, object), + } +} diff --git a/backend/src/activitypub/activities/update.ts b/backend/src/activitypub/activities/update.ts new file mode 100644 index 0000000..886b26c --- /dev/null +++ b/backend/src/activitypub/activities/update.ts @@ -0,0 +1,16 @@ +import type { Object } from '../objects' +import type { Actor } from '../actors' +import type { Activity } from '.' +import * as activity from '.' + +const UPDATE = 'Update' + +export function create(domain: string, actor: Actor, object: Object): Activity { + return { + '@context': ['https://www.w3.org/ns/activitystreams'], + id: activity.uri(domain), + type: UPDATE, + actor: actor.id, + object, + } +} diff --git a/backend/src/activitypub/actors/follow.ts b/backend/src/activitypub/actors/follow.ts new file mode 100644 index 0000000..1481364 --- /dev/null +++ b/backend/src/activitypub/actors/follow.ts @@ -0,0 +1,24 @@ +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core' + +const headers = { + accept: 'application/activity+json', +} + +export async function getFollowingMetadata(actor: Actor): Promise> { + const res = await fetch(actor.following, { headers }) + if (!res.ok) { + throw new Error(`${actor.following} returned ${res.status}`) + } + + return res.json>() +} + +export async function getFollowersMetadata(actor: Actor): Promise> { + const res = await fetch(actor.followers, { headers }) + if (!res.ok) { + throw new Error(`${actor.followers} returned ${res.status}`) + } + + return res.json>() +} diff --git a/backend/src/activitypub/actors/inbox.ts b/backend/src/activitypub/actors/inbox.ts new file mode 100644 index 0000000..9f90c3b --- /dev/null +++ b/backend/src/activitypub/actors/inbox.ts @@ -0,0 +1,13 @@ +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' + +export async function addObjectInInbox(db: D1Database, actor: Actor, obj: Object) { + const id = crypto.randomUUID() + const out = await db + .prepare('INSERT INTO inbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)') + .bind(id, actor.id.toString(), obj.id.toString()) + .run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } +} diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts new file mode 100644 index 0000000..d0e60f7 --- /dev/null +++ b/backend/src/activitypub/actors/index.ts @@ -0,0 +1,237 @@ +import { MastodonAccount } from 'wildebeest/backend/src/types/account' +import { defaultImages } from 'wildebeest/config/accounts' +import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops' +import type { Object } from '../objects' + +const PERSON = 'Person' +const isTesting = typeof jest !== 'undefined' +export const emailSymbol = Symbol() + +export function actorURL(domain: string, id: string): URL { + return new URL(`/ap/users/${id}`, 'https://' + domain) +} + +function inboxURL(id: URL): URL { + return new URL(id + '/inbox') +} + +function outboxURL(id: URL): URL { + return new URL(id + '/outbox') +} + +function followingURL(id: URL): URL { + return new URL(id + '/following') +} + +export function followersURL(id: URL): URL { + return new URL(id + '/followers') +} + +// https://www.w3.org/TR/activitystreams-vocabulary/#actor-types +export interface Actor extends Object { + inbox: URL + outbox: URL + following: URL + followers: URL + + [emailSymbol]: string +} + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person +export interface Person extends Actor { + publicKey: string +} + +export async function get(url: string | URL): Promise { + const headers = { + accept: 'application/activity+json', + } + const res = await fetch(url.toString(), { headers }) + if (!res.ok) { + throw new Error(`${url} returned: ${res.status}`) + } + + const data = await res.json() + const actor: Actor = { ...data } + actor.id = new URL(data.id) + + // This is mostly for testing where for convenience not all values + // are provided. + // TODO: eventually clean that to better match production. + if (data.inbox !== undefined) { + actor.inbox = new URL(data.inbox) + } + if (data.following !== undefined) { + actor.following = new URL(data.following) + } + if (data.followers !== undefined) { + actor.followers = new URL(data.followers) + } + if (data.outbox !== undefined) { + actor.outbox = new URL(data.outbox) + } + + return actor +} + +export async function getAndCache(url: URL, db: D1Database): Promise { + const person = await getPersonById(db, url) + if (person !== null) { + return person + } + + const actor = await get(url) + if (!actor.type || !actor.id) { + throw new Error('missing fields on Actor') + } + + const properties = actor + + const sql = ` + INSERT INTO actors (id, type, properties) + VALUES (?, ?, ?) + ` + + const { success, error } = await db + .prepare(sql) + .bind(actor.id.toString(), actor.type, JSON.stringify(properties)) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } + return actor +} + +export async function getPersonByEmail(db: D1Database, email: string): Promise { + const stmt = db.prepare('SELECT * FROM actors WHERE email=? AND type=?').bind(email, PERSON) + const { results } = await stmt.all() + if (!results || results.length === 0) { + return null + } + const row: any = results[0] + return personFromRow(row) +} + +type Properties = { [key: string]: Properties | string } + +export async function createPerson( + domain: string, + db: D1Database, + userKEK: string, + email: string, + properties: Properties = {} +): Promise { + const userKeyPair = await generateUserKey(userKEK) + + let privkey, salt + // Since D1 and better-sqlite3 behaviors don't exactly match, presumable + // because Buffer support is different in Node/Worker. We have to transform + // the values depending on the platform. + if (isTesting) { + privkey = Buffer.from(userKeyPair.wrappedPrivKey) + salt = Buffer.from(userKeyPair.salt) + } else { + privkey = [...new Uint8Array(userKeyPair.wrappedPrivKey)] + salt = [...new Uint8Array(userKeyPair.salt)] + } + + if (properties.preferredUsername === undefined) { + const parts = email.split('@') + properties.preferredUsername = parts[0] + } + + if (properties.preferredUsername !== undefined && typeof properties.preferredUsername !== 'string') { + throw new Error( + `preferredUsername should be a string, received ${JSON.stringify(properties.preferredUsername)} instead` + ) + } + + const id = actorURL(domain, properties.preferredUsername).toString() + + const { success, error } = await db + .prepare( + 'INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties)) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } + + return new URL(id) +} + +export async function updateActorProperty(db: D1Database, actorId: URL, key: string, value: string) { + const { success, error } = await db + .prepare(`UPDATE actors SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`) + .bind(value, actorId.toString()) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } +} + +export async function getPersonById(db: D1Database, id: URL): Promise { + const stmt = db.prepare('SELECT * FROM actors WHERE id=? AND type=?').bind(id.toString(), PERSON) + const { results } = await stmt.all() + if (!results || results.length === 0) { + return null + } + const row: any = results[0] + return personFromRow(row) +} + +export function personFromRow(row: any): Person { + const icon: Object = { + type: 'Image', + mediaType: 'image/jpeg', + url: new URL(defaultImages.avatar), + id: new URL(row.id + '#icon'), + } + const image: Object = { + type: 'Image', + mediaType: 'image/jpeg', + url: new URL(defaultImages.header), + id: new URL(row.id + '#image'), + } + + let publicKey = null + if (row.pubkey !== null) { + publicKey = { + id: row.id + '#main-key', + owner: row.id, + publicKeyPem: row.pubkey, + } + } + + const id = new URL(row.id) + + let domain = id.hostname + if (row.original_actor_id) { + domain = new URL(row.original_actor_id).hostname + } + + return { + // Hidden values + [emailSymbol]: row.email, + + name: row.preferredUsername, + icon, + image, + discoverable: true, + publicKey, + type: PERSON, + id, + published: new Date(row.cdate).toISOString(), + inbox: inboxURL(row.id), + outbox: outboxURL(row.id), + following: followingURL(row.id), + followers: followersURL(row.id), + + url: new URL('@' + row.preferredUsername, 'https://' + domain), + + // It's very possible that properties override the values set above. + // Almost guaranteed for remote user. + ...JSON.parse(row.properties), + } +} diff --git a/backend/src/activitypub/actors/outbox.ts b/backend/src/activitypub/actors/outbox.ts new file mode 100644 index 0000000..0f8355b --- /dev/null +++ b/backend/src/activitypub/actors/outbox.ts @@ -0,0 +1,56 @@ +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { Activity } from 'wildebeest/backend/src/activitypub/activities' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core' + +export async function addObjectInOutbox(db: D1Database, actor: Actor, obj: Object, published_date?: string) { + const id = crypto.randomUUID() + let out: any = null + + if (published_date !== undefined) { + out = await db + .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date) VALUES(?, ?, ?, ?)') + .bind(id, actor.id.toString(), obj.id.toString(), published_date) + .run() + } else { + out = await db + .prepare('INSERT INTO outbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)') + .bind(id, actor.id.toString(), obj.id.toString()) + .run() + } + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } +} + +const headers = { + accept: 'application/activity+json', +} + +export async function getMetadata(actor: Actor): Promise> { + const res = await fetch(actor.outbox, { headers }) + if (!res.ok) { + throw new Error(`${actor.outbox} returned ${res.status}`) + } + + return res.json>() +} + +export async function get(actor: Actor): Promise> { + const collection = await getMetadata(actor) + collection.items = await loadItems(collection, 20) + + return collection +} + +async function loadItems(collection: OrderedCollection, max: number): Promise> { + // FIXME: implement max and multi page support + + const res = await fetch(collection.first, { headers }) + if (!res.ok) { + throw new Error(`${collection.first} returned ${res.status}`) + } + + const data = await res.json>() + return data.orderedItems +} diff --git a/backend/src/activitypub/core.ts b/backend/src/activitypub/core.ts new file mode 100644 index 0000000..4fdb023 --- /dev/null +++ b/backend/src/activitypub/core.ts @@ -0,0 +1,15 @@ +import type { Object } from 'wildebeest/backend/src/activitypub/objects' + +export interface Collection extends Object { + totalItems: number + current?: string + first: URL + last: URL + items: Array +} + +export interface OrderedCollection extends Collection {} + +export interface OrderedCollectionPage extends Object { + orderedItems: Array +} diff --git a/backend/src/activitypub/deliver.ts b/backend/src/activitypub/deliver.ts new file mode 100644 index 0000000..3b06f1e --- /dev/null +++ b/backend/src/activitypub/deliver.ts @@ -0,0 +1,75 @@ +// https://www.w3.org/TR/activitypub/#delivery + +import * as actors from 'wildebeest/backend/src/activitypub/actors' +import type { Activity } from './activities' +import type { Actor } from './actors' +import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage' +import { signRequest } from 'wildebeest/backend/src/utils/http-signing' +import { getFollowers } from 'wildebeest/backend/src/mastodon/follow' + +const headers = { + 'content-type': 'application/activity+json', +} + +export async function deliverToActor(signingKey: CryptoKey, from: Actor, to: Actor, activity: Activity) { + const body = JSON.stringify(activity) + console.log({ body }) + let req = new Request(to.inbox, { + method: 'POST', + body, + headers, + }) + const digest = await generateDigestHeader(body) + req.headers.set('Digest', digest) + await signRequest(req, signingKey, new URL(from.id)) + + const res = await fetch(req) + if (!res.ok) { + const body = await res.text() + throw new Error(`delivery to ${to.inbox} returned ${res.status}: ${body}`) + } + { + const body = await res.text() + console.log(`${to.inbox} returned 200: ${body}`) + } +} + +export async function deliverFollowers(db: D1Database, signingKey: CryptoKey, from: Actor, activity: Activity) { + const body = JSON.stringify(activity) + const followers = await getFollowers(db, from) + + const promises = followers.map(async (id) => { + const follower = new URL(id) + + // FIXME: When an actor follows another Actor we should download its object + // locally, so we can retrieve the Actor's inbox without a request. + + const targetActor = await actors.getAndCache(follower, db) + if (targetActor === null) { + console.warn(`actor ${follower} not found`) + return + } + + const req = new Request(targetActor.inbox, { + method: 'POST', + body, + headers, + }) + const digest = await generateDigestHeader(body) + req.headers.set('Digest', digest) + await signRequest(req, signingKey, new URL(from.id)) + + const res = await fetch(req) + if (!res.ok) { + const body = await res.text() + console.error(`delivery to ${targetActor.inbox} returned ${res.status}: ${body}`) + return + } + { + const body = await res.text() + console.log(`${targetActor.inbox} returned 200: ${body}`) + } + }) + + await Promise.allSettled(promises) +} diff --git a/backend/src/activitypub/objects/image.ts b/backend/src/activitypub/objects/image.ts new file mode 100644 index 0000000..c6b3a21 --- /dev/null +++ b/backend/src/activitypub/objects/image.ts @@ -0,0 +1,12 @@ +import * as objects from '.' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' + +export const IMAGE = 'Image' + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image +export interface Image extends objects.Document {} + +export async function createImage(domain: string, db: D1Database, actor: Actor, properties: any): Promise { + const actorId = new URL(actor.id) + return (await objects.createObject(domain, db, IMAGE, properties, actorId, true)) as Image +} diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts new file mode 100644 index 0000000..77c3281 --- /dev/null +++ b/backend/src/activitypub/objects/index.ts @@ -0,0 +1,172 @@ +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import type { UUID } from 'wildebeest/backend/src/types' + +// https://www.w3.org/TR/activitystreams-vocabulary/#object-types +export interface Object { + type: string + id: URL + url: URL + published?: string + icon?: Object + image?: Object + summary?: string + name?: string + mediaType?: string + content?: string + inReplyTo?: string + + // Extension + preferredUsername?: string + // Internal + originalActorId?: string + originalObjectId?: string + mastodonId?: UUID +} + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document +export interface Document extends Object {} + +export function uri(domain: string, id: string): URL { + return new URL('/ap/o/' + id, 'https://' + domain) +} + +export async function createObject( + domain: string, + db: D1Database, + type: string, + properties: any, + originalActorId: URL, + local: boolean +): Promise { + const uuid = crypto.randomUUID() + const apId = uri(domain, uuid).toString() + + const row: any = await db + .prepare( + 'INSERT INTO objects(id, type, properties, original_actor_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?) RETURNING *' + ) + .bind(apId, type, JSON.stringify(properties), originalActorId.toString(), local ? 1 : 0, uuid) + .first() + + return { + ...properties, + + type, + id: new URL(row.id), + mastodonId: row.mastodon_id, + published: new Date(row.cdate).toISOString(), + originalActorId: row.original_actor_id, + } +} + +export async function get(url: URL): Promise { + const headers = { + accept: 'application/activity+json', + } + const res = await fetch(url, { headers }) + if (!res.ok) { + throw new Error(`${url} returned: ${res.status}`) + } + + return res.json() +} + +export async function cacheObject( + domain: string, + db: D1Database, + properties: any, + originalActorId: URL, + originalObjectId: URL, + local: boolean +): Promise { + const cachedObject = await getObjectBy(db, 'original_object_id', originalObjectId.toString()) + if (cachedObject !== null) { + return cachedObject + } + + const uuid = crypto.randomUUID() + const apId = uri(domain, uuid).toString() + + const row: any = await db + .prepare( + 'INSERT INTO objects(id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING *' + ) + .bind( + apId, + properties.type, + JSON.stringify(properties), + originalActorId.toString(), + originalObjectId.toString(), + local ? 1 : 0, + uuid + ) + .first() + + { + const properties = JSON.parse(row.properties) + + return { + published: new Date(row.cdate).toISOString(), + ...properties, + + type: row.type, + id: new URL(row.id), + mastodonId: row.mastodon_id, + originalActorId: row.original_actor_id, + originalObjectId: row.original_object_id, + } + } +} + +export async function updateObject(db: D1Database, properties: any, id: URL): Promise { + const res: any = await db + .prepare('UPDATE objects SET properties = ? WHERE id = ?') + .bind(JSON.stringify(properties), id.toString()) + .run() + + // TODO: D1 doesn't return changes at the moment + // return res.changes === 1 + return true +} + +export async function getObjectById(db: D1Database, id: string | URL): Promise { + return getObjectBy(db, 'id', id.toString()) +} + +export async function getObjectByOriginalId(db: D1Database, id: string | URL): Promise { + return getObjectBy(db, 'original_object_id', id.toString()) +} + +export async function getObjectByMastodonId(db: D1Database, id: UUID): Promise { + return getObjectBy(db, 'mastodon_id', id) +} + +export async function getObjectBy(db: D1Database, key: string, value: string): Promise { + const query = ` +SELECT * +FROM objects +WHERE objects.${key}=? + ` + const { results, success, error } = await db.prepare(query).bind(value).all() + if (!success) { + throw new Error('SQL error: ' + error) + } + + if (!results || results.length === 0) { + return null + } + + const result: any = results[0] + const properties = JSON.parse(result.properties) + + return { + published: new Date(result.cdate).toISOString(), + ...properties, + + type: result.type, + id: new URL(result.id), + mastodonId: result.mastodon_id, + originalActorId: result.original_actor_id, + originalObjectId: result.original_object_id, + } +} diff --git a/backend/src/activitypub/objects/note.ts b/backend/src/activitypub/objects/note.ts new file mode 100644 index 0000000..c2e908d --- /dev/null +++ b/backend/src/activitypub/objects/note.ts @@ -0,0 +1,52 @@ +// https://www.w3.org/TR/activitystreams-vocabulary/#object-types + +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import type { Document } from 'wildebeest/backend/src/activitypub/objects' +import { followersURL } from 'wildebeest/backend/src/activitypub/actors' +import * as objects from '.' + +const NOTE = 'Note' +export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public' + +// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note +export interface Note extends objects.Object { + content: string + attributedTo?: string + summary?: string + inReplyTo?: string + replies?: string + to: Array + attachment: Array + cc?: Array + tag?: Array +} + +export async function createPublicNote( + domain: string, + db: D1Database, + content: string, + actor: Actor, + attachment: Array = [], + extraProperties: any = {} +): Promise { + const actorId = new URL(actor.id) + + const properties = { + attributedTo: actorId, + content, + to: [PUBLIC], + cc: [followersURL(actorId)], + + // FIXME: stub values + inReplyTo: null, + replies: null, + sensitive: false, + summary: null, + tag: [], + attachment, + + ...extraProperties, + } + + return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts new file mode 100644 index 0000000..405dd17 --- /dev/null +++ b/backend/src/config/index.ts @@ -0,0 +1,59 @@ +export type InstanceConfig = { + title?: string + email?: string + description?: string + accessAud?: string + accessDomain?: string +} + +export async function configure(db: D1Database, data: InstanceConfig) { + const sql = ` + INSERT INTO instance_config + VALUES ('title', ?), + ('email', ?), + ('description', ?); + ` + + const { success, error } = await db.prepare(sql).bind(data.title, data.email, data.description).run() + if (!success) { + throw new Error('SQL error: ' + error) + } +} + +export async function configureAccess(db: D1Database, domain: string, aud: string) { + const sql = ` + INSERT INTO instance_config + VALUES ('accessAud', ?), ('accessDomain', ?); + ` + + const { success, error } = await db.prepare(sql).bind(aud, domain).run() + if (!success) { + throw new Error('SQL error: ' + error) + } +} + +export async function generateVAPIDKeys(db: D1Database) { + const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair + const jwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey) + + const sql = ` + INSERT INTO instance_config + VALUES ('vapid_jwk', ?); + ` + + const { success, error } = await db.prepare(sql).bind(JSON.stringify(jwk)).run() + if (!success) { + throw new Error('SQL error: ' + error) + } +} + +export async function get(db: D1Database, name: string): Promise { + const row: any = await db.prepare('SELECT value FROM instance_config WHERE key = ?').bind(name).first() + if (!row) { + throw new Error(`configuration not found: ${name}`) + } + return row.value +} diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts new file mode 100644 index 0000000..fbe0286 --- /dev/null +++ b/backend/src/errors/index.ts @@ -0,0 +1,38 @@ +type ErrorResponse = { + error: string + error_description?: string +} + +const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'content-type, authorization', + 'content-type': 'application/json', +} as const + +function generateErrorResponse(error: string, status: number, errorDescription?: string): Response { + const res: ErrorResponse = { + error: `${error}. ` + 'If the problem persists please contact your instance administrator.', + ...(errorDescription ? { error_description: errorDescription } : {}), + } + return new Response(JSON.stringify(res), { headers, status }) +} + +export function notAuthorized(error: string, descr?: string): Response { + return generateErrorResponse(`An error occurred (${error})`, 401, descr) +} + +export function domainNotAuthorized(): Response { + return generateErrorResponse(`Domain is not authorizated`, 403) +} + +export function userConflict(): Response { + return generateErrorResponse(`User already exists or conflicts`, 403) +} + +export function timelineMissing(): Response { + return generateErrorResponse(`The timeline is invalid or being regenerated`, 404) +} + +export function clientUnknown(): Response { + return generateErrorResponse(`The client is unknown or invalid`, 403) +} diff --git a/backend/src/mastodon/account.ts b/backend/src/mastodon/account.ts new file mode 100644 index 0000000..88a94fd --- /dev/null +++ b/backend/src/mastodon/account.ts @@ -0,0 +1,95 @@ +import { MastodonAccount } from 'wildebeest/backend/src/types/account' +import { unwrapPrivateKey } from 'wildebeest/backend/src/utils/key-ops' +import type { Actor } from '../activitypub/actors' +import { defaultImages } from 'wildebeest/config/accounts' +import * as apOutbox from 'wildebeest/backend/src/activitypub/actors/outbox' +import * as apFollow from 'wildebeest/backend/src/activitypub/actors/follow' + +function toMastodonAccount(acct: string, res: Actor): MastodonAccount { + let avatar = defaultImages.avatar + let header = defaultImages.header + + if (res.icon !== undefined && typeof res.icon.url === 'string') { + avatar = res.icon.url + } + if (res.image !== undefined && typeof res.image.url === 'string') { + header = res.image.url + } + + return { + acct, + + id: acct, + username: res.preferredUsername || res.name || 'unnamed', + url: res.url ? res.url.toString() : '', + display_name: res.name || res.preferredUsername || '', + note: res.summary || '', + created_at: res.published || new Date().toISOString(), + + avatar, + avatar_static: avatar, + + header, + header_static: header, + + locked: false, + bot: false, + discoverable: true, + group: false, + + emojis: [], + fields: [], + } +} + +// Load an external user, using ActivityPub queries, and return it as a MastodonAccount +export async function loadExternalMastodonAccount( + acct: string, + res: Actor, + loadStats: boolean = false +): Promise { + const account = toMastodonAccount(acct, res) + if (loadStats === true) { + account.statuses_count = (await apOutbox.getMetadata(res)).totalItems + account.followers_count = (await apFollow.getFollowersMetadata(res)).totalItems + account.following_count = (await apFollow.getFollowingMetadata(res)).totalItems + } + return account +} + +// Load a local user and return it as a MastodonAccount +export async function loadLocalMastodonAccount(db: D1Database, res: Actor): Promise { + const query = ` +SELECT + (SELECT count(*) + FROM outbox_objects + INNER JOIN objects ON objects.id = outbox_objects.object_id + WHERE outbox_objects.actor_id=? + AND objects.type = 'Note') AS statuses_count, + + (SELECT count(*) + FROM actor_following + WHERE actor_following.actor_id=?) AS following_count, + + (SELECT count(*) + FROM actor_following + WHERE actor_following.target_actor_id=?) AS followers_count + ` + + // For local user the acct is only the local part of the email address. + const acct = res.preferredUsername || 'unknown' + const account = toMastodonAccount(acct, res) + + const row: any = await db.prepare(query).bind(res.id.toString(), res.id.toString(), res.id.toString()).first() + account.statuses_count = row.statuses_count + account.followers_count = row.followers_count + account.following_count = row.following_count + + return account +} + +export async function getSigningKey(instanceKey: string, db: D1Database, actor: Actor): Promise { + const stmt = db.prepare('SELECT privkey, privkey_salt FROM actors WHERE id=?').bind(actor.id.toString()) + const { privkey, privkey_salt } = (await stmt.first()) as any + return unwrapPrivateKey(instanceKey, new Uint8Array(privkey), new Uint8Array(privkey_salt)) +} diff --git a/backend/src/mastodon/client.ts b/backend/src/mastodon/client.ts new file mode 100644 index 0000000..180e4c5 --- /dev/null +++ b/backend/src/mastodon/client.ts @@ -0,0 +1,60 @@ +import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops' + +export interface Client { + id: string + secret: string + name: string + redirect_uris: string + website: string + scopes: string +} + +export async function createClient( + db: D1Database, + name: string, + redirect_uris: string, + website: string, + scopes: string +): Promise { + const id = crypto.randomUUID() + + const secretBytes = new Uint8Array(64) + crypto.getRandomValues(secretBytes) + + const secret = arrayBufferToBase64(secretBytes.buffer) + + const query = ` + INSERT INTO clients (id, secret, name, redirect_uris, website, scopes) + VALUES (?, ?, ?, ?, ?, ?) + ` + const { success, error } = await db.prepare(query).bind(id, secret, name, redirect_uris, website, scopes).run() + if (!success) { + throw new Error('SQL error: ' + error) + } + + return { + id: id, + secret: secret, + name: name, + redirect_uris: redirect_uris, + website: website, + scopes: scopes, + } +} + +export async function getClientById(db: D1Database, id: string): Promise { + const stmt = db.prepare('SELECT * FROM clients WHERE id=?').bind(id) + const { results } = await stmt.all() + if (!results || results.length === 0) { + return null + } + const row: any = results[0] + return { + id: id, + secret: row.secret, + name: row.name, + redirect_uris: row.redirect_uris, + website: row.website, + scopes: row.scopes, + } +} diff --git a/backend/src/mastodon/follow.ts b/backend/src/mastodon/follow.ts new file mode 100644 index 0000000..127086d --- /dev/null +++ b/backend/src/mastodon/follow.ts @@ -0,0 +1,119 @@ +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' + +const STATE_PENDING = 'pending' +const STATE_ACCEPTED = 'accepted' + +// Add a pending following +export async function addFollowing(db: D1Database, actor: Actor, target: Actor, targetAcct: string): Promise { + const id = crypto.randomUUID() + + const query = ` + INSERT INTO actor_following (id, actor_id, target_actor_id, state, target_actor_acct) + VALUES (?, ?, ?, ?, ?) + ` + + const out = await db + .prepare(query) + .bind(id, actor.id.toString(), target.id.toString(), STATE_PENDING, targetAcct) + .run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + return id +} + +// Accept the pending following request +export async function acceptFollowing(db: D1Database, actor: Actor, target: Actor) { + const id = crypto.randomUUID() + + const query = ` + UPDATE actor_following SET state=? WHERE actor_id=? AND target_actor_id=? AND state=? + ` + + const out = await db + .prepare(query) + .bind(STATE_ACCEPTED, actor.id.toString(), target.id.toString(), STATE_PENDING) + .run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } +} + +export async function removeFollowing(db: D1Database, actor: Actor, target: Actor) { + const query = ` + DELETE FROM actor_following WHERE actor_id=? AND target_actor_id=? + ` + + const out = await db.prepare(query).bind(actor.id.toString(), target.id.toString()).run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } +} + +export async function getFollowingAcct(db: D1Database, actor: Actor): Promise> { + const query = ` + SELECT target_actor_acct FROM actor_following WHERE actor_id=? AND state=? + ` + + const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_ACCEPTED).all() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + if (out.results !== null) { + return out.results.map((x: any) => x.target_actor_acct) + } else { + return [] + } +} + +export async function getFollowingRequestedAcct(db: D1Database, actor: Actor): Promise> { + const query = ` + SELECT target_actor_acct FROM actor_following WHERE actor_id=? AND state=? + ` + + const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_PENDING).all() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + if (out.results !== null) { + return out.results.map((x: any) => x.target_actor_acct) + } else { + return [] + } +} + +export async function getFollowingId(db: D1Database, actor: Actor): Promise> { + const query = ` + SELECT target_actor_id FROM actor_following WHERE actor_id=? AND state=? + ` + + const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_ACCEPTED).all() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + if (out.results !== null) { + return out.results.map((x: any) => x.target_actor_id) + } else { + return [] + } +} + +export async function getFollowers(db: D1Database, actor: Actor): Promise> { + const query = ` + SELECT actor_id FROM actor_following WHERE target_actor_id=? AND state=? + ` + + const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_ACCEPTED).all() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + if (out.results !== null) { + return out.results.map((x: any) => x.actor_id) + } else { + return [] + } +} diff --git a/backend/src/mastodon/like.ts b/backend/src/mastodon/like.ts new file mode 100644 index 0000000..623f582 --- /dev/null +++ b/backend/src/mastodon/like.ts @@ -0,0 +1,32 @@ +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' + +export async function insertLike(db: D1Database, actor: Actor, obj: Object) { + const id = crypto.randomUUID() + + const query = ` + INSERT INTO actor_favourites (id, actor_id, object_id) + VALUES (?, ?, ?) +` + const out = await db.prepare(query).bind(id, actor.id.toString(), obj.id.toString()).run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } +} + +export async function getLikes(db: D1Database, obj: Object): Promise> { + const query = ` + SELECT actor_id FROM actor_favourites WHERE object_id=? + ` + + const out: any = await db.prepare(query).bind(obj.id.toString()).all() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + if (out.results !== null) { + return out.results.map((x: any) => x.actor_id) + } else { + return [] + } +} diff --git a/backend/src/mastodon/notification.ts b/backend/src/mastodon/notification.ts new file mode 100644 index 0000000..d945b01 --- /dev/null +++ b/backend/src/mastodon/notification.ts @@ -0,0 +1,239 @@ +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' +import * as actors from 'wildebeest/backend/src/activitypub/actors' +import { urlToHandle } from 'wildebeest/backend/src/utils/handle' +import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' +import { generateWebPushMessage } from 'wildebeest/backend/src/webpush' +import { getPersonById } from 'wildebeest/backend/src/activitypub/actors' +import type { WebPushInfos, WebPushMessage } from 'wildebeest/backend/src/webpush/webpushinfos' +import { WebPushResult } from 'wildebeest/backend/src/webpush/webpushinfos' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import type { NotificationType, Notification } from 'wildebeest/backend/src/types/notification' +import type { Subscription } from 'wildebeest/backend/src/mastodon/subscription' +import { getSubscriptionForAllClients } from 'wildebeest/backend/src/mastodon/subscription' +import { getVAPIDKeys } from 'wildebeest/backend/src/mastodon/subscription' +import * as config from 'wildebeest/backend/src/config' + +export async function createNotification( + db: D1Database, + type: NotificationType, + actor: Actor, + fromActor: Actor, + obj: Object +): Promise { + const query = ` + INSERT INTO actor_notifications (type, actor_id, from_actor_id, object_id) + VALUES (?, ?, ?, ?) + RETURNING id +` + const row: any = await db + .prepare(query) + .bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString()) + .first() + return row.id +} + +export async function insertFollowNotification(db: D1Database, actor: Actor, fromActor: Actor): Promise { + const type: NotificationType = 'follow' + + const query = ` + INSERT INTO actor_notifications (type, actor_id, from_actor_id) + VALUES (?, ?, ?) + RETURNING id +` + const row: any = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first() + return row.id +} + +export async function sendFollowNotification(db: D1Database, follower: Actor, actor: Actor, notificationId: string) { + const sub = await config.get(db, 'email') + + const data = { + preferred_locale: 'en', + notification_type: 'follow', + notification_id: notificationId, + icon: follower.icon!.url, + title: 'New follower', + body: `${follower.name} is now following you`, + } + + const message: WebPushMessage = { + data: JSON.stringify(data), + urgency: 'normal', + sub, + ttl: 60 * 24 * 7, + } + + return sendNotification(db, actor, message) +} + +export async function sendLikeNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) { + const sub = await config.get(db, 'email') + + const data = { + preferred_locale: 'en', + notification_type: 'favourite', + notification_id: notificationId, + icon: fromActor.icon!.url, + title: 'New favourite', + body: `${fromActor.name} favourited your status`, + } + + const message: WebPushMessage = { + data: JSON.stringify(data), + urgency: 'normal', + sub, + ttl: 60 * 24 * 7, + } + + return sendNotification(db, actor, message) +} + +export async function sendMentionNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) { + const sub = await config.get(db, 'email') + + const data = { + preferred_locale: 'en', + notification_type: 'favourite', + notification_id: notificationId, + icon: fromActor.icon!.url, + title: 'New favourite', + body: `${fromActor.name} favourited your status`, + } + + const message: WebPushMessage = { + data: JSON.stringify(data), + urgency: 'normal', + sub, + ttl: 60 * 24 * 7, + } + + return sendNotification(db, actor, message) +} + +export async function sendReblogNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) { + const sub = await config.get(db, 'email') + + const data = { + preferred_locale: 'en', + notification_type: 'reblog', + notification_id: notificationId, + icon: fromActor.icon!.url, + title: 'New boost', + body: `${fromActor.name} boosted your status`, + } + + const message: WebPushMessage = { + data: JSON.stringify(data), + urgency: 'normal', + sub, + ttl: 60 * 24 * 7, + } + + return sendNotification(db, actor, message) +} + +async function sendNotification(db: D1Database, actor: Actor, message: WebPushMessage) { + const vapidKeys = await getVAPIDKeys(db) + const subscriptions = await getSubscriptionForAllClients(db, actor) + + const promises = subscriptions.map(async (subscription) => { + const device: WebPushInfos = { + endpoint: subscription.gateway.endpoint, + key: subscription.gateway.keys.p256dh, + auth: subscription.gateway.keys.auth, + } + + const result = await generateWebPushMessage(message, device, vapidKeys) + if (result !== WebPushResult.Success) { + throw new Error('failed to send push notification') + } + }) + + await Promise.allSettled(promises) +} + +export async function getNotifications(db: D1Database, actor: Actor): Promise> { + const query = ` + SELECT + objects.*, + actor_notifications.type, + actor_notifications.actor_id, + actor_notifications.from_actor_id as notif_from_actor_id, + actor_notifications.cdate as notif_cdate, + actor_notifications.id as notif_id + FROM actor_notifications + LEFT JOIN objects ON objects.id=actor_notifications.object_id + WHERE actor_id=? + ORDER BY actor_notifications.cdate DESC + LIMIT 20 + ` + + const stmt = db.prepare(query).bind(actor.id.toString()) + const { results, success, error } = await stmt.all() + if (!success) { + throw new Error('SQL error: ' + error) + } + + const out: Array = [] + if (!results || results.length === 0) { + return [] + } + + for (let i = 0, len = results.length; i < len; i++) { + const result = results[i] as any + const properties = JSON.parse(result.properties) + const notifFromActorId = new URL(result.notif_from_actor_id) + + const notifFromActor = await getPersonById(db, notifFromActorId) + if (!notifFromActor) { + console.warn('unknown actor') + continue + } + + const acct = urlToHandle(notifFromActorId) + const notifFromAccount = await loadExternalMastodonAccount(acct, notifFromActor) + + const notif: Notification = { + id: result.notif_id.toString(), + type: result.type, + created_at: new Date(result.notif_cdate).toISOString(), + account: notifFromAccount, + } + + if (result.type === 'mention' || result.type === 'favourite') { + const actorId = new URL(result.original_actor_id) + const actor = await actors.getAndCache(actorId, db) + + const acct = urlToHandle(actorId) + const account = await loadExternalMastodonAccount(acct, actor) + + notif.status = { + id: result.mastodon_id, + content: properties.content, + uri: result.id, + created_at: new Date(result.cdate).toISOString(), + + emojis: [], + media_attachments: [], + tags: [], + mentions: [], + + account, + + // TODO: stub values + visibility: 'public', + spoiler_text: '', + } + } + + out.push(notif) + } + + return out +} + +export async function pregenerateNotifications(db: D1Database, cache: KVNamespace, actor: Actor) { + const notifications = await getNotifications(db, actor) + await cache.put(actor.id + '/notifications', JSON.stringify(notifications)) +} diff --git a/backend/src/mastodon/reblog.ts b/backend/src/mastodon/reblog.ts new file mode 100644 index 0000000..f89b95c --- /dev/null +++ b/backend/src/mastodon/reblog.ts @@ -0,0 +1,34 @@ +// Also known as boost. + +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' + +export async function insertReblog(db: D1Database, actor: Actor, obj: Object) { + const id = crypto.randomUUID() + + const query = ` + INSERT INTO actor_reblogs (id, actor_id, object_id) + VALUES (?, ?, ?) +` + const out = await db.prepare(query).bind(id, actor.id.toString(), obj.id.toString()).run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } +} + +export async function getReblogs(db: D1Database, obj: Object): Promise> { + const query = ` + SELECT actor_id FROM actor_reblogs WHERE object_id=? + ` + + const out: any = await db.prepare(query).bind(obj.id.toString()).all() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + if (out.results !== null) { + return out.results.map((x: any) => x.actor_id) + } else { + return [] + } +} diff --git a/backend/src/mastodon/reply.ts b/backend/src/mastodon/reply.ts new file mode 100644 index 0000000..367f7b5 --- /dev/null +++ b/backend/src/mastodon/reply.ts @@ -0,0 +1,58 @@ +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import { toMastodonStatusFromRow } from './status' +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { MastodonStatus } from 'wildebeest/backend/src/types/status' + +export async function insertReply(db: D1Database, actor: Actor, obj: Object, inReplyToObj: Object) { + const id = crypto.randomUUID() + const query = ` + INSERT INTO actor_replies (id, actor_id, object_id, in_reply_to_object_id) + VALUES (?, ?, ?, ?) + ` + const { success, error } = await db + .prepare(query) + .bind(id, actor.id.toString(), obj.id.toString(), inReplyToObj.id.toString()) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } +} + +export async function getReplies(domain: string, db: D1Database, obj: Object): Promise> { + const QUERY = ` +SELECT objects.*, + actors.id as actor_id, + actors.cdate as actor_cdate, + actors.properties as actor_properties, + actor_replies.actor_id as publisher_actor_id, + (SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count, + (SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count, + (SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count +FROM actor_replies +INNER JOIN objects ON objects.id=actor_replies.object_id +INNER JOIN actors ON actors.id=actor_replies.actor_id +WHERE actor_replies.in_reply_to_object_id=? +ORDER by actor_replies.cdate DESC +LIMIT ? +` + const DEFAULT_LIMIT = 20 + + const { success, error, results } = await db.prepare(QUERY).bind(obj.id.toString(), DEFAULT_LIMIT).all() + if (!success) { + throw new Error('SQL error: ' + error) + } + if (!results) { + return [] + } + + const out: Array = [] + + for (let i = 0, len = results.length; i < len; i++) { + const status = await toMastodonStatusFromRow(domain, db, results[i]) + if (status !== null) { + out.push(status) + } + } + + return out +} diff --git a/backend/src/mastodon/status.ts b/backend/src/mastodon/status.ts new file mode 100644 index 0000000..c594c0e --- /dev/null +++ b/backend/src/mastodon/status.ts @@ -0,0 +1,176 @@ +import type { Handle } from '../utils/parse' +import type { MediaAttachment } from 'wildebeest/backend/src/types/media' +import type { UUID } from 'wildebeest/backend/src/types' +import { getObjectByMastodonId, getObjectById } from 'wildebeest/backend/src/activitypub/objects' +import type { Object } from 'wildebeest/backend/src/activitypub/objects' +import type { Note } from 'wildebeest/backend/src/activitypub/objects/note' +import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' +import * as actors from 'wildebeest/backend/src/activitypub/actors' +import * as objects from 'wildebeest/backend/src/activitypub/objects' +import * as media from 'wildebeest/backend/src/media/' +import type { MastodonStatus } from 'wildebeest/backend/src/types' +import { parseHandle } from '../utils/parse' +import { urlToHandle } from '../utils/handle' +import { getLikes } from './like' +import { getReblogs } from './reblog' + +export function getMentions(input: string): Array { + const mentions: Array = [] + + for (let i = 0, len = input.length; i < len; i++) { + if (input[i] === '@') { + i++ + let buffer = '' + while (i < len && /[^\s<]/.test(input[i])) { + buffer += input[i] + i++ + } + + mentions.push(parseHandle(buffer)) + } + } + + return mentions +} + +export async function toMastodonStatusFromObject(db: D1Database, obj: Note): Promise { + if (obj.originalActorId === undefined) { + console.warn('missing `obj.originalActorId`') + return null + } + + const actorId = new URL(obj.originalActorId) + const actor = await actors.getAndCache(actorId, db) + + const acct = urlToHandle(actorId) + const account = await loadExternalMastodonAccount(acct, actor) + + const favourites = await getLikes(db, obj) + const reblogs = await getReblogs(db, obj) + + const mediaAttachments: Array = [] + + if (Array.isArray(obj.attachment)) { + for (let i = 0, len = obj.attachment.length; i < len; i++) { + const document = await getObjectById(db, obj.attachment[i].id) + if (document === null) { + console.warn('missing attachment object: ' + obj.attachment[i].id) + continue + } + + mediaAttachments.push(media.fromObject(document)) + } + } + + return { + // Default values + emojis: [], + tags: [], + mentions: [], + + // TODO: stub values + visibility: 'public', + spoiler_text: '', + + media_attachments: mediaAttachments, + content: obj.content || '', + id: obj.mastodonId || '', + uri: obj.url, + created_at: obj.published || '', + account, + + favourites_count: favourites.length, + reblogs_count: reblogs.length, + } +} + +// toMastodonStatusFromRow makes assumption about what field are available on +// the `row` object. This funciton is only used for timelines, which is optimized +// SQL. Otherwise don't use this function. +export async function toMastodonStatusFromRow( + domain: string, + db: D1Database, + row: any +): Promise { + if (row.publisher_actor_id === undefined) { + console.warn('missing `row.publisher_actor_id`') + return null + } + const properties = JSON.parse(row.properties) + const actorId = new URL(row.publisher_actor_id) + + const author = actors.personFromRow({ + id: row.actor_id, + cdate: row.actor_cdate, + properties: row.actor_properties, + }) + + const acct = urlToHandle(actorId) + const account = await loadExternalMastodonAccount(acct, author) + + if (row.favourites_count === undefined || row.reblogs_count === undefined || row.replies_count === undefined) { + throw new Error('logic error; missing fields.') + } + + const mediaAttachments: Array = [] + + if (Array.isArray(properties.attachment)) { + for (let i = 0, len = properties.attachment.length; i < len; i++) { + const document = properties.attachment[i] + mediaAttachments.push(media.fromObject(document)) + } + } + + const status: MastodonStatus = { + id: row.mastodon_id, + uri: row.id, + created_at: new Date(row.cdate).toISOString(), + emojis: [], + media_attachments: mediaAttachments, + tags: [], + mentions: [], + account, + + // TODO: stub values + visibility: 'public', + spoiler_text: '', + + content: properties.content, + favourites_count: row.favourites_count, + reblogs_count: row.reblogs_count, + replies_count: row.replies_count, + reblogged: row.reblogged === 1, + favourited: row.favourited === 1, + } + + if (properties.updated) { + status.edited_at = new Date(properties.updated).toISOString() + } + + // FIXME: add unit tests for reblog + if (properties.attributedTo && properties.attributedTo !== row.publisher_actor_id) { + // The actor that introduced the Object in the instance isn't the same + // as the object has been attributed to. Likely means it's a reblog. + + const actorId = new URL(properties.attributedTo) + const acct = urlToHandle(actorId) + const author = await actors.getAndCache(actorId, db) + const account = await loadExternalMastodonAccount(acct, author) + + // Restore reblogged status + status.reblog = { + ...status, + account, + } + } + + return status +} + +export async function getMastodonStatusById(db: D1Database, id: UUID): Promise { + const obj = await getObjectByMastodonId(db, id) + if (obj === null) { + return null + } + return toMastodonStatusFromObject(db, obj as Note) +} diff --git a/backend/src/mastodon/subscription.ts b/backend/src/mastodon/subscription.ts new file mode 100644 index 0000000..6c4cf5e --- /dev/null +++ b/backend/src/mastodon/subscription.ts @@ -0,0 +1,138 @@ +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import type { JWK } from 'wildebeest/backend/src/webpush/jwk' +import { b64ToUrlEncoded, exportPublicKeyPair } from 'wildebeest/backend/src/webpush/util' +import { Client } from './client' + +export type PushSubscription = { + endpoint: string + keys: { + p256dh: string + auth: string + } +} + +export interface CreateRequest { + subscription: PushSubscription + data: { + alerts: { + mention?: boolean + status?: boolean + reblog?: boolean + follow?: boolean + follow_request?: boolean + favourite?: boolean + poll?: boolean + update?: boolean + admin_sign_up?: boolean + admin_report?: boolean + } + policy: string + } +} + +export type Subscription = { + id: string + gateway: PushSubscription +} + +export async function createSubscription( + db: D1Database, + actor: Actor, + client: Client, + req: CreateRequest +): Promise { + const id = crypto.randomUUID() + + const query = ` + INSERT INTO subscriptions (id, actor_id, client_id, endpoint, key_p256dh, key_auth, alert_mention, alert_status, alert_reblog, alert_follow, alert_follow_request, alert_favourite, alert_poll, alert_update, alert_admin_sign_up, alert_admin_report, policy) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` + const out = await db + .prepare(query) + .bind( + id, + actor.id.toString(), + client.id, + req.subscription.endpoint, + req.subscription.keys.p256dh, + req.subscription.keys.auth, + req.data.alerts.mention ? 1 : 0, + req.data.alerts.status ? 1 : 0, + req.data.alerts.reblog ? 1 : 0, + req.data.alerts.follow ? 1 : 0, + req.data.alerts.follow_request ? 1 : 0, + req.data.alerts.favourite ? 1 : 0, + req.data.alerts.poll ? 1 : 0, + req.data.alerts.update ? 1 : 0, + req.data.alerts.admin_sign_up ? 1 : 0, + req.data.alerts.admin_report ? 1 : 0, + req.data.policy + ) + .run() + if (!out.success) { + throw new Error('SQL error: ' + out.error) + } + + return { id, gateway: req.subscription } +} + +export async function getSubscription(db: D1Database, actor: Actor, client: Client): Promise { + const query = ` + SELECT * FROM subscriptions WHERE actor_id=? AND client_id=? + ` + + const { success, error, results } = await db.prepare(query).bind(actor.id.toString(), client.id).all() + if (!success) { + throw new Error('SQL error: ' + error) + } + + if (!results || results.length === 0) { + return null + } + + const row: any = results[0] + return subscriptionFromRow(row) +} + +export async function getSubscriptionForAllClients(db: D1Database, actor: Actor): Promise> { + const query = ` + SELECT * FROM subscriptions WHERE actor_id=? ORDER BY cdate DESC LIMIT 5 + ` + + const { success, error, results } = await db.prepare(query).bind(actor.id.toString()).all() + if (!success) { + throw new Error('SQL error: ' + error) + } + + if (!results) { + return [] + } + + return results.map(subscriptionFromRow) +} + +function subscriptionFromRow(row: any): Subscription { + return { + id: row.id, + gateway: { + endpoint: row.endpoint, + keys: { + p256dh: row.key_p256dh, + auth: row.key_auth, + }, + }, + } +} + +export async function getVAPIDKeys(db: D1Database): Promise { + const row: any = await db.prepare("SELECT value FROM instance_config WHERE key = 'vapid_jwk'").first() + if (!row) { + throw new Error('missing VAPID keys') + } + const value = JSON.parse(row.value) + return value +} + +export function VAPIDPublicKey(keys: JWK): string { + return b64ToUrlEncoded(exportPublicKeyPair(keys)) +} diff --git a/backend/src/mastodon/timeline.ts b/backend/src/mastodon/timeline.ts new file mode 100644 index 0000000..794831f --- /dev/null +++ b/backend/src/mastodon/timeline.ts @@ -0,0 +1,124 @@ +import type { MastodonStatus } from 'wildebeest/backend/src/types/status' +import { getFollowingId } from 'wildebeest/backend/src/mastodon/follow' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors/' +import { toMastodonStatusFromRow } from './status' +import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors/' + +export async function pregenerateTimelines(domain: string, db: D1Database, cache: KVNamespace, actor: Actor) { + const timeline = await getHomeTimeline(domain, db, actor) + await cache.put(actor.id + '/timeline/home', JSON.stringify(timeline)) +} + +export async function getHomeTimeline(domain: string, db: D1Database, actor: Actor): Promise> { + const following = await getFollowingId(db, actor) + // follow ourself to see our statuses in the our home timeline + following.push(actor.id.toString()) + + const QUERY = ` +SELECT objects.*, + actors.id as actor_id, + actors.cdate as actor_cdate, + actors.properties as actor_properties, + outbox_objects.actor_id as publisher_actor_id, + (SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count, + (SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count, + (SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count, + (SELECT count(*) > 0 FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id AND actor_reblogs.actor_id=?) as reblogged, + (SELECT count(*) > 0 FROM actor_favourites WHERE actor_favourites.object_id=objects.id AND actor_favourites.actor_id=?) as favourited +FROM outbox_objects +INNER JOIN objects ON objects.id = outbox_objects.object_id +INNER JOIN actors ON actors.id = outbox_objects.actor_id +WHERE + objects.type = 'Note' + AND outbox_objects.actor_id IN (SELECT value FROM json_each(?)) + AND json_extract(objects.properties, '$.inReplyTo') IS NULL +ORDER by outbox_objects.published_date DESC +LIMIT ? +` + const DEFAULT_LIMIT = 20 + + const { success, error, results } = await db + .prepare(QUERY) + .bind(actor.id.toString(), actor.id.toString(), JSON.stringify(following), DEFAULT_LIMIT) + .all() + if (!success) { + throw new Error('SQL error: ' + error) + } + if (!results) { + return [] + } + + const out: Array = [] + + for (let i = 0, len = results.length; i < len; i++) { + const status = await toMastodonStatusFromRow(domain, db, results[i]) + if (status !== null) { + out.push(status) + } + } + + return out +} + +export enum LocalPreference { + NotSet, + OnlyLocal, + OnlyRemote, +} + +function localPreferenceQuery(preference: LocalPreference): string { + switch (preference) { + case LocalPreference.NotSet: + return '1' + case LocalPreference.OnlyLocal: + return 'objects.local = 1' + case LocalPreference.OnlyRemote: + return 'objects.local = 0' + } +} + +export async function getPublicTimeline( + domain: string, + db: D1Database, + localPreference: LocalPreference, + offset: number = 0 +): Promise> { + const QUERY = ` +SELECT objects.*, + actors.id as actor_id, + actors.cdate as actor_cdate, + actors.properties as actor_properties, + outbox_objects.actor_id as publisher_actor_id, + (SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count, + (SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count, + (SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count +FROM outbox_objects +INNER JOIN objects ON objects.id=outbox_objects.object_id +INNER JOIN actors ON actors.id=outbox_objects.actor_id +WHERE objects.type='Note' + AND ${localPreferenceQuery(localPreference)} + AND json_extract(objects.properties, '$.inReplyTo') IS NULL +ORDER by outbox_objects.published_date DESC +LIMIT ?1 OFFSET ?2 +` + const DEFAULT_LIMIT = 20 + + const { success, error, results } = await db.prepare(QUERY).bind(DEFAULT_LIMIT, offset).all() + if (!success) { + throw new Error('SQL error: ' + error) + } + if (!results) { + return [] + } + + const out: Array = [] + + for (let i = 0, len = results.length; i < len; i++) { + const status = await toMastodonStatusFromRow(domain, db, results[i]) + if (status !== null) { + out.push(status) + } + } + + return out +} diff --git a/backend/src/media/image.ts b/backend/src/media/image.ts new file mode 100644 index 0000000..58bdd3e --- /dev/null +++ b/backend/src/media/image.ts @@ -0,0 +1,51 @@ +import type { MediaAttachment } from 'wildebeest/backend/src/types/media' + +export type Config = { + accountId: string + apiToken: string +} + +type APIResult = { + success: boolean + errors: Array + messages: Array + result: T +} + +type UploadResult = { + id: string + filename: string + metadata: object + requireSignedURLs: boolean + variants: Array + uploaded: string +} + +export async function uploadImage(file: File, config: Config): Promise { + const formData = new FormData() + const url = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1` + + formData.set('file', file) + + const res = await fetch(url, { + method: 'POST', + body: formData, + headers: { + authorization: 'Bearer ' + config.apiToken, + }, + }) + if (!res.ok) { + const body = await res.text() + throw new Error(`Cloudflare Images returned ${res.status}: ${body}`) + } + + const data = await res.json>() + if (!data.success) { + const body = await res.text() + throw new Error(`Cloudflare Images returned ${res.status}: ${body}`) + } + + // We assume there's only one variant for now. + const variant = data.result.variants[0] + return new URL(variant) +} diff --git a/backend/src/media/index.ts b/backend/src/media/index.ts new file mode 100644 index 0000000..a749d20 --- /dev/null +++ b/backend/src/media/index.ts @@ -0,0 +1,81 @@ +import type { MediaAttachment } from 'wildebeest/backend/src/types/media' +import type { Document } from 'wildebeest/backend/src/activitypub/objects' +import { IMAGE } from 'wildebeest/backend/src/activitypub/objects/image' +import type { Object } from 'wildebeest/backend/src/activitypub/objects' + +export function fromObject(obj: Object): MediaAttachment { + if (obj.type === IMAGE) { + return fromObjectImage(obj) + } else if (obj.type === 'Document') { + if (obj.mediaType === 'image/jpeg' || obj.mediaType === 'image/png') { + return fromObjectImage(obj) + } else if (obj.mediaType === 'video/mp4') { + return { + url: new URL(obj.url), + preview_url: new URL(obj.url), + id: obj.url.toString(), + type: 'video', + meta: { + length: '0:01:28.65', + duration: 88.65, + fps: 24, + size: '1280x720', + width: 1280, + height: 720, + aspect: 1.7777777777777777, + audio_encode: 'aac (LC) (mp4a / 0x6134706D)', + audio_bitrate: '44100 Hz', + audio_channels: 'stereo', + original: { + width: 1280, + height: 720, + frame_rate: '6159375/249269', + duration: 88.654, + bitrate: 862056, + }, + small: { + width: 400, + height: 225, + size: '400x225', + aspect: 1.7777777777777777, + }, + }, + description: 'test media description', + blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}', + } + } else { + throw new Error(`unsupported media type ${obj.type}: ${JSON.stringify(obj)}`) + } + } else { + throw new Error(`unsupported media type ${obj.type}: ${JSON.stringify(obj)}`) + } +} + +function fromObjectImage(obj: Object): MediaAttachment { + return { + url: new URL(obj.url), + id: obj.mastodonId || obj.url.toString(), + preview_url: new URL(obj.url), + type: 'image', + meta: { + original: { + width: 640, + height: 480, + size: '640x480', + aspect: 1.3333333333333333, + }, + small: { + width: 461, + height: 346, + size: '461x346', + aspect: 1.3323699421965318, + }, + focus: { + x: -0.27, + y: 0.51, + }, + }, + description: 'test media description', + blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}', + } +} diff --git a/backend/src/middleware/error.ts b/backend/src/middleware/error.ts new file mode 100644 index 0000000..80b1f27 --- /dev/null +++ b/backend/src/middleware/error.ts @@ -0,0 +1,11 @@ +/** + * A Pages middleware function that logs errors to the console and responds with 500 errors and stack-traces. + */ +export async function errorHandling(context: EventContext) { + try { + return await context.next() + } catch (err: any) { + console.log(err.stack) + return new Response(`${err.message}\n${err.stack}`, { status: 500 }) + } +} diff --git a/backend/src/middleware/logger.ts b/backend/src/middleware/logger.ts new file mode 100644 index 0000000..ae2e389 --- /dev/null +++ b/backend/src/middleware/logger.ts @@ -0,0 +1,15 @@ +/** + * A Pages middleware function that logs requests/responses to the console. + */ +export async function logger(context: EventContext) { + const { method, url } = context.request + console.log(`-> ${method} ${url} `) + const res = await context.next() + if (context.data.connectedActor) { + console.log(`<- ${res.status} (${context.data.connectedActor.id})`) + } else { + console.log(`<- ${res.status}`) + } + + return res +} diff --git a/backend/src/middleware/main.ts b/backend/src/middleware/main.ts new file mode 100644 index 0000000..70c0a7a --- /dev/null +++ b/backend/src/middleware/main.ts @@ -0,0 +1,120 @@ +import * as access from 'wildebeest/backend/src/access' +import * as actors from 'wildebeest/backend/src/activitypub/actors' +import type { Env } from 'wildebeest/backend/src/types/env' +import type { Identity, ContextData } from 'wildebeest/backend/src/types/context' +import * as errors from 'wildebeest/backend/src/errors' +import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' + +async function loadContextData(db: D1Database, clientId: string, email: string, ctx: any): Promise { + const query = ` + SELECT + actors.*, + (SELECT value FROM instance_config WHERE key='accessAud') as accessAud, + (SELECT value FROM instance_config WHERE key='accessDomain') as accessDomain + FROM actors + WHERE email=? AND type='Person' + ` + const { results, success, error } = await db.prepare(query).bind(email).all() + if (!success) { + throw new Error('SQL error: ' + error) + } + + if (!results || results.length === 0) { + console.warn('no results') + return false + } + + const row: any = results[0] + + if (!row.id) { + console.warn('person not found') + return false + } + if (!row.accessDomain || !row.accessAud) { + console.warn('access configuration not found') + return false + } + + const person = actors.personFromRow(row) + + ctx.data.connectedActor = person + ctx.data.identity = { email } + ctx.data.clientId = clientId + ctx.data.accessDomain = row.accessDomain + ctx.data.accessAud = row.accessAud + + return true +} + +export async function main(context: EventContext) { + if (context.request.method === 'OPTIONS') { + const headers = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'content-type, authorization', + 'Access-Control-Allow-Methods': 'GET, PUT, POST', + 'content-type': 'application/json', + } + return new Response('', { headers }) + } + + const url = new URL(context.request.url) + if ( + url.pathname === '/oauth/token' || + url.pathname === '/oauth/authorize' || // Cloudflare Access runs on /oauth/authorize + url.pathname === '/api/v1/instance' || + url.pathname === '/api/v2/instance' || + url.pathname === '/api/v1/apps' || + url.pathname === '/api/v1/timelines/public' || + url.pathname === '/api/v1/custom_emojis' || + url.pathname === '/.well-known/webfinger' || + url.pathname === '/start-instance' || // Access is required by the handler + url.pathname === '/start-instance-test-access' || // Access is required by the handler + url.pathname.startsWith('/ap/') // all ActivityPub endpoints + ) { + return context.next() + } else { + try { + const authorization = context.request.headers.get('Authorization') || '' + const token = authorization.replace('Bearer ', '') + + if (token === '') { + return errors.notAuthorized('missing authorization') + } + + const parts = token.split('.') + const [clientId, ...jwtParts] = parts + + const jwt = jwtParts.join('.') + + const payload = access.getPayload(jwt) + if (!payload.email) { + return errors.notAuthorized('missing email') + } + + // Load the user associated with the email in the payload *before* + // verifying the JWT validity. + // This is because loading the context will also load the access + // configuration, which are used to verify the JWT. + if (!(await loadContextData(context.env.DATABASE, clientId, payload.email, context))) { + return errors.notAuthorized('failed to load context data') + } + + const validatate = access.generateValidator({ + jwt, + domain: context.data.accessDomain, + aud: context.data.accessAud, + }) + await validatate(context.request) + + const identity = await access.getIdentity({ jwt, domain: context.data.accessDomain }) + if (!identity) { + return errors.notAuthorized('failed to load identity') + } + + return context.next() + } catch (err: any) { + console.warn(err.stack) + return errors.notAuthorized('unknown error occurred') + } + } +} diff --git a/backend/src/types/account.ts b/backend/src/types/account.ts new file mode 100644 index 0000000..0b59718 --- /dev/null +++ b/backend/src/types/account.ts @@ -0,0 +1,70 @@ +// https://docs.joinmastodon.org/entities/Account/ +// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Account.java +export interface MastodonAccount { + id: string + username: string + acct: string + url: string + display_name: string + note: string + + avatar: string + avatar_static: string + + header: string + header_static: string + + created_at: string + + locked?: boolean + bot?: boolean + discoverable?: boolean + group?: boolean + + followers_count?: number + following_count?: number + statuses_count?: number + + emojis: Array + fields: Array +} + +// https://docs.joinmastodon.org/entities/Relationship/ +// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java +export type Relationship = { + id: string +} + +export type Privacy = 'public' | 'unlisted' | 'private' | 'direct' + +// https://docs.joinmastodon.org/entities/Account/#CredentialAccount +export interface CredentialAccount extends MastodonAccount { + source: { + note: string + fields: Array + privacy: Privacy + sensitive: boolean + language: string + follow_requests_count: number + } + role: Role +} + +// https://docs.joinmastodon.org/entities/Role/ +export type Role = { + id: string + name: string + color: string + position: number + // https://docs.joinmastodon.org/entities/Role/#permission-flags + permissions: number + highlighted: boolean + created_at: string + updated_at: string +} + +export type Field = { + name: string + value: string + verified_at?: string +} diff --git a/backend/src/types/configs.ts b/backend/src/types/configs.ts new file mode 100644 index 0000000..2e414d0 --- /dev/null +++ b/backend/src/types/configs.ts @@ -0,0 +1,21 @@ +// https://docs.joinmastodon.org/entities/Instance/ +export type InstanceConfig = { + uri: string + title: string + languages: Array + email: string + description: string + short_description?: string + rules: Array +} + +// https://docs.joinmastodon.org/entities/Rule/ +export type Rule = { + id: string + text: string +} + +export type DefaultImages = { + avatar: string + header: string +} diff --git a/backend/src/types/context.ts b/backend/src/types/context.ts new file mode 100644 index 0000000..a1ff400 --- /dev/null +++ b/backend/src/types/context.ts @@ -0,0 +1,21 @@ +import type { MastodonAccount } from 'wildebeest/backend/src/types/account' +import type { Person } from 'wildebeest/backend/src/activitypub/actors' + +export type Identity = { + email: string +} + +export type ContextData = { + // ActivityPub Person object of the logged in user + connectedActor: Person + + // Configure for Cloudflare Access + accessDomain: string + accessAud: string + + // Object returned by Cloudflare Access' provider + identity: Identity + + // Client or app identifier + clientId: string +} diff --git a/backend/src/types/env.ts b/backend/src/types/env.ts new file mode 100644 index 0000000..172fa90 --- /dev/null +++ b/backend/src/types/env.ts @@ -0,0 +1,7 @@ +export interface Env { + DATABASE: D1Database + KV_CACHE: KVNamespace + userKEK: string + CF_ACCOUNT_ID: string + CF_API_TOKEN: string +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts new file mode 100644 index 0000000..c6aba2c --- /dev/null +++ b/backend/src/types/index.ts @@ -0,0 +1,4 @@ +export * from './status' +export * from './account' + +export type UUID = string diff --git a/backend/src/types/media.ts b/backend/src/types/media.ts new file mode 100644 index 0000000..fb91158 --- /dev/null +++ b/backend/src/types/media.ts @@ -0,0 +1,11 @@ +export type MediaType = 'unknown' | 'image' | 'gifv' | 'video' | 'audio' + +export type MediaAttachment = { + id: string + type: MediaType + url: URL + preview_url: URL + meta: any + description: string + blurhash: string +} diff --git a/backend/src/types/notification.ts b/backend/src/types/notification.ts new file mode 100644 index 0000000..ab2959b --- /dev/null +++ b/backend/src/types/notification.ts @@ -0,0 +1,22 @@ +import type { MastodonAccount } from 'wildebeest/backend/src/types/account' +import type { MastodonStatus } from 'wildebeest/backend/src/types/status' + +export type NotificationType = + | 'mention' + | 'status' + | 'reblog' + | 'follow' + | 'follow_request' + | 'favourite' + | 'poll' + | 'update' + | 'admin.sign_up' + | 'admin.report' + +export type Notification = { + id: string + type: NotificationType + created_at: string + account: MastodonAccount + status?: MastodonStatus +} diff --git a/backend/src/types/status.ts b/backend/src/types/status.ts new file mode 100644 index 0000000..e10d361 --- /dev/null +++ b/backend/src/types/status.ts @@ -0,0 +1,34 @@ +import type { MastodonAccount } from './account' +import type { MediaAttachment } from './media' +import type { UUID } from 'wildebeest/backend/src/types' + +type Visibility = 'public' | 'unlisted' | 'private' | 'direct' + +// https://docs.joinmastodon.org/entities/Status/ +// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +export type MastodonStatus = { + id: UUID + uri: URL + created_at: string + account: MastodonAccount + content: string + visibility: Visibility + spoiler_text: string + emojis: Array + media_attachments: Array + mentions: Array + tags: Array + favourites_count?: number + reblogs_count?: number + reblog?: MastodonStatus + edited_at?: string + replies_count?: number + reblogged?: boolean + favourited?: boolean +} + +// https://docs.joinmastodon.org/entities/Context/ +export type Context = { + ancestors: Array + descendants: Array +} diff --git a/backend/src/utils/handle.ts b/backend/src/utils/handle.ts new file mode 100644 index 0000000..a9ad686 --- /dev/null +++ b/backend/src/utils/handle.ts @@ -0,0 +1,10 @@ +// Naive way of transforming an Actor ObjectID into a handle like WebFinger uses +export function urlToHandle(input: URL): string { + const { pathname, host } = input + const parts = pathname.split('/') + if (parts.length === 0) { + throw new Error('malformed URL') + } + const localPart = parts[parts.length - 1] + return `${localPart}@${host}` +} diff --git a/backend/src/utils/http-signing-cavage.ts b/backend/src/utils/http-signing-cavage.ts new file mode 100644 index 0000000..827f6f0 --- /dev/null +++ b/backend/src/utils/http-signing-cavage.ts @@ -0,0 +1,170 @@ +// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3.1 +export type Parameter = 'created' | 'expires' | 'nonce' | 'alg' | 'keyid' | string + +export type Component = + | '@method' + | '@target-uri' + | '@authority' + | '@scheme' + | '@request-target' + | '@path' + | '@query' + | '@query-params' + | string + +export type ResponseComponent = '@status' | '@request-response' | Component + +export type Parameters = { [name: Parameter]: string | number | Date | { [Symbol.toStringTag]: () => string } } + +export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512' + +export interface Signer { + (data: string): Promise + alg: Algorithm +} + +export type SignOptions = { + components?: Component[] + parameters?: Parameters + keyId: string + signer: Signer +} + +export const defaultSigningComponents: Component[] = ['@request-target', 'content-type', 'digest', 'content-digest'] + +const ALG_MAP: { [name: string]: string } = { + 'rsa-v1_5-sha256': 'rsa-sha256', +} + +export function extractHeader({ headers }: Request, header: string): string { + const lcHeader = header.toLowerCase() + const key = Array.from(headers.keys()).find((name) => name.toLowerCase() === lcHeader) + if (!key) { + throw new Error(`Unable to extract header "${header}" from message`) + } + let val = key ? headers.get(key) ?? '' : '' + if (Array.isArray(val)) { + val = val.join(', ') + } + return val.toString().replace(/\s+/g, ' ') +} + +// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3 +export function extractComponent(message: Request, component: string): string { + switch (component) { + case '@request-target': { + const { pathname, search } = new URL(message.url) + return `${message.method.toLowerCase()} ${pathname}${search}` + } + default: + throw new Error(`Unknown specialty component ${component}`) + } +} + +export function buildSignedData(request: Request, components: Component[], params: Parameters): string { + const payloadParts: Parameters = {} + const paramNames = Object.keys(params) + if (components.includes('@request-target')) { + Object.assign(payloadParts, { + '(request-target)': extractComponent(request, '@request-target'), + }) + } + if (paramNames.includes('created')) { + Object.assign(payloadParts, { + '(created)': params.created, + }) + } + if (paramNames.includes('expires')) { + Object.assign(payloadParts, { + '(expires)': params.expires, + }) + } + components.forEach((name) => { + if (!name.startsWith('@')) { + Object.assign(payloadParts, { + [name.toLowerCase()]: extractHeader(request, name), + }) + } + }) + return Object.entries(payloadParts) + .map(([name, value]) => { + if (value instanceof Date) { + return `${name}: ${Math.floor(value.getTime() / 1000)}` + } else { + return `${name}: ${value.toString()}` + } + }) + .join('\n') +} + +export function buildSignatureInputString(componentNames: Component[], parameters: Parameters): string { + const params: Parameters = Object.entries(parameters).reduce((normalised, [name, value]) => { + switch (name.toLowerCase()) { + case 'keyid': + return Object.assign(normalised, { + keyId: value, + }) + case 'alg': + return Object.assign(normalised, { + algorithm: ALG_MAP[value as string] ?? value, + }) + default: + return Object.assign(normalised, { + [name]: value, + }) + } + }, {}) + const headers = [] + const paramNames = Object.keys(params) + if (componentNames.includes('@request-target')) { + headers.push('(request-target)') + } + if (paramNames.includes('created')) { + headers.push('(created)') + } + if (paramNames.includes('expires')) { + headers.push('(expires)') + } + componentNames.forEach((name) => { + if (!name.startsWith('@')) { + headers.push(name.toLowerCase()) + } + }) + return `${Object.entries(params) + .map(([name, value]) => { + if (typeof value === 'number') { + return `${name}=${value}` + } else if (value instanceof Date) { + return `${name}=${Math.floor(value.getTime() / 1000)}` + } else { + return `${name}="${value.toString()}"` + } + }) + .join(',')},headers="${headers.join(' ')}"` +} + +function uint8ArrayToBase64(a: Uint8Array): string { + const a_s = Array.prototype.map.call(a, (c) => String.fromCharCode(c)).join(String()) + return btoa(a_s) +} + +export async function generateDigestHeader(body: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(body) + const hash = uint8ArrayToBase64(new Uint8Array(await crypto.subtle.digest('SHA-256', data))) + return `SHA-256=${hash}` +} + +export async function sign(request: Request, opts: SignOptions): Promise { + const signingComponents: Component[] = opts.components ?? defaultSigningComponents + const signingParams: Parameters = { + ...opts.parameters, + keyid: opts.keyId, + alg: opts.signer.alg, + } + const signatureInputString = buildSignatureInputString(signingComponents, signingParams) + const dataToSign = buildSignedData(request, signingComponents, signingParams) + const signature = await opts.signer(dataToSign) + const sigBase64 = uint8ArrayToBase64(signature) + request.headers.set('Signature', `${signatureInputString},signature="${sigBase64}"`) +} diff --git a/backend/src/utils/http-signing.ts b/backend/src/utils/http-signing.ts new file mode 100644 index 0000000..b594815 --- /dev/null +++ b/backend/src/utils/http-signing.ts @@ -0,0 +1,36 @@ +import { Algorithm, sign } from './http-signing-cavage' +import { str2ab } from './key-ops' + +export async function signRequest(request: Request, key: CryptoKey, keyId: URL): Promise { + const mySigner = async (data: string) => + new Uint8Array( + await crypto.subtle.sign( + { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }, + key, + str2ab(data as string) + ) + ) + mySigner.alg = 'hs2019' as Algorithm + + if (!request.headers.has('Host')) { + const url = new URL(request.url) + request.headers.set('Host', url.host) + } + + let components = ['@request-target', 'host'] + if (request.method == 'POST') { + components.push('digest') + } + + await sign(request, { + components: components, + parameters: { + created: Math.floor(Date.now() / 1000), + }, + keyId: keyId.toString(), + signer: mySigner, + }) +} diff --git a/backend/src/utils/httpsigjs/LICENSE b/backend/src/utils/httpsigjs/LICENSE new file mode 100644 index 0000000..f6d947d --- /dev/null +++ b/backend/src/utils/httpsigjs/LICENSE @@ -0,0 +1,18 @@ +Copyright Joyent, Inc. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/backend/src/utils/httpsigjs/parser.ts b/backend/src/utils/httpsigjs/parser.ts new file mode 100644 index 0000000..7f95ba8 --- /dev/null +++ b/backend/src/utils/httpsigjs/parser.ts @@ -0,0 +1,365 @@ +// @ts-nocheck +// Copyright 2012 Joyent, Inc. All rights reserved. + +import { HEADER, HttpSignatureError, InvalidAlgorithmError, validateAlgorithm } from './utils' + +///--- Globals + +let State = { + New: 0, + Params: 1, +} + +let ParamsState = { + Name: 0, + Quote: 1, + Value: 2, + Comma: 3, + Number: 4, +} + +///--- Specific Errors + +class ExpiredRequestError extends HttpSignatureError { + constructor(message: string) { + super(message, ExpiredRequestError) + } +} + +class InvalidHeaderError extends HttpSignatureError { + constructor(message: string) { + super(message, InvalidHeaderError) + } +} + +class InvalidParamsError extends HttpSignatureError { + constructor(message: string) { + super(message, InvalidParamsError) + } +} + +class MissingHeaderError extends HttpSignatureError { + constructor(message: string) { + super(message, MissingHeaderError) + } +} + +class StrictParsingError extends HttpSignatureError { + constructor(message: string) { + super(message, StrictParsingError) + } +} + +type Options = { + clockSkew: number + headers: string[] + strict: boolean +} + +export type ParsedSignature = { + signature: string + keyId: string + signingString: string + algorithm: string +} + +///--- Exported API + +/** + * Parses the 'Authorization' header out of an http.ServerRequest object. + * + * Note that this API will fully validate the Authorization header, and throw + * on any error. It will not however check the signature, or the keyId format + * as those are specific to your environment. You can use the options object + * to pass in extra constraints. + * + * As a response object you can expect this: + * + * { + * "scheme": "Signature", + * "params": { + * "keyId": "foo", + * "algorithm": "rsa-sha256", + * "headers": [ + * "date" or "x-date", + * "digest" + * ], + * "signature": "base64" + * }, + * "signingString": "ready to be passed to crypto.verify()" + * } + * + * @param {Object} request an http.ServerRequest. + * @param {Object} options an optional options object with: + * - clockSkew: allowed clock skew in seconds (default 300). + * - headers: required header names (def: date or x-date) + * - strict: should enforce latest spec parsing + * (default: false). + * @return {Object} parsed out object (see above). + * @throws {TypeError} on invalid input. + * @throws {InvalidHeaderError} on an invalid Authorization header error. + * @throws {InvalidParamsError} if the params in the scheme are invalid. + * @throws {MissingHeaderError} if the params indicate a header not present, + * either in the request headers from the params, + * or not in the params from a required header + * in options. + * @throws {StrictParsingError} if old attributes are used in strict parsing + * mode. + * @throws {ExpiredRequestError} if the value of date or x-date exceeds skew. + */ +export function parseRequest(request: Request, options?: Options): ParsedSignature { + if (options === undefined) { + options = { + clockSkew: 300, + headers: ['host', '(request-target)'], + strict: false, + } + } + + if (request.method == 'POST') { + options.headers.push('digest') + } + + let headers = [request.headers.has('x-date') ? 'x-date' : 'date'] + if (options.headers !== undefined) { + headers = options.headers + } + + let authz = request.headers.get(HEADER.AUTH) || request.headers.get(HEADER.SIG) + + if (!authz) { + let errHeader = HEADER.AUTH + ' or ' + HEADER.SIG + + throw new MissingHeaderError('no ' + errHeader + ' header ' + 'present in the request') + } + + options.clockSkew = options.clockSkew || 300 + + let i = 0 + let state = authz === request.headers.get(HEADER.SIG) ? State.Params : State.New + let substate = ParamsState.Name + let tmpName = '' + let tmpValue = '' + + let parsed = { + scheme: authz === request.headers.get(HEADER.SIG) ? 'Signature' : '', + params: {}, + signingString: '', + } + + for (i = 0; i < authz.length; i++) { + let c = authz.charAt(i) + let code = c.charCodeAt(0) + + switch (Number(state)) { + case State.New: + if (c !== ' ') parsed.scheme += c + else state = State.Params + break + + case State.Params: + switch (Number(substate)) { + case ParamsState.Name: + // restricted name of A-Z / a-z + if ( + (code >= 0x41 && code <= 0x5a) || // A-Z + (code >= 0x61 && code <= 0x7a) + ) { + // a-z + tmpName += c + } else if (c === '=') { + if (tmpName.length === 0) throw new InvalidHeaderError('bad param format') + substate = ParamsState.Quote + } else { + throw new InvalidHeaderError('bad param format') + } + break + + case ParamsState.Quote: + if (c === '"') { + tmpValue = '' + substate = ParamsState.Value + } else { + //number + substate = ParamsState.Number + code = c.charCodeAt(0) + if (code < 0x30 || code > 0x39) { + //character not in 0-9 + throw new InvalidHeaderError('bad param format') + } + tmpValue = c + } + break + + case ParamsState.Value: + if (c === '"') { + parsed.params[tmpName] = tmpValue + substate = ParamsState.Comma + } else { + tmpValue += c + } + break + + case ParamsState.Number: + if (c === ',') { + parsed.params[tmpName] = parseInt(tmpValue, 10) + tmpName = '' + substate = ParamsState.Name + } else { + code = c.charCodeAt(0) + if (code < 0x30 || code > 0x39) { + //character not in 0-9 + throw new InvalidHeaderError('bad param format') + } + tmpValue += c + } + break + + case ParamsState.Comma: + if (c === ',') { + tmpName = '' + substate = ParamsState.Name + } else { + throw new InvalidHeaderError('bad param format') + } + break + + default: + throw new Error('Invalid substate') + } + break + + default: + throw new Error('Invalid substate') + } + } + + if (!parsed.params.headers || parsed.params.headers === '') { + if (request.headers.has('x-date')) { + parsed.params.headers = ['x-date'] + } else { + parsed.params.headers = ['date'] + } + } else { + parsed.params.headers = parsed.params.headers.split(' ') + } + + // Minimally validate the parsed object + if (!parsed.scheme || parsed.scheme !== 'Signature') throw new InvalidHeaderError('scheme was not "Signature"') + + if (!parsed.params.keyId) throw new InvalidHeaderError('keyId was not specified') + + if (!parsed.params.algorithm) throw new InvalidHeaderError('algorithm was not specified') + + if (!parsed.params.signature) throw new InvalidHeaderError('signature was not specified') + + if (['date', 'x-date', '(created)'].every((hdr) => parsed.params.headers.indexOf(hdr) < 0)) { + throw new MissingHeaderError('no signed date header') + } + + // Check the algorithm against the official list + try { + validateAlgorithm(parsed.params.algorithm, 'rsa') + } catch (e) { + if (e instanceof InvalidAlgorithmError) + throw new InvalidParamsError(parsed.params.algorithm + ' is not ' + 'supported') + else throw e + } + + // Build the signingString + for (i = 0; i < parsed.params.headers.length; i++) { + let h = parsed.params.headers[i].toLowerCase() + parsed.params.headers[i] = h + + if (h === 'request-line') { + if (!options.strict) { + /* + * We allow headers from the older spec drafts if strict parsing isn't + * specified in options. + */ + parsed.signingString += request.method + ' ' + request.url + ' ' + request.cf?.httpProtocol + } else { + /* Strict parsing doesn't allow older draft headers. */ + throw new StrictParsingError('request-line is not a valid header ' + 'with strict parsing enabled.') + } + } else if (h === '(request-target)') { + const { pathname, search } = new URL(request.url) + parsed.signingString += '(request-target): ' + `${request.method.toLowerCase()} ${pathname}${search}` + } else if (h === '(keyid)') { + parsed.signingString += '(keyid): ' + parsed.params.keyId + } else if (h === '(algorithm)') { + parsed.signingString += '(algorithm): ' + parsed.params.algorithm + } else if (h === '(opaque)') { + let opaque = parsed.params.opaque + if (opaque === undefined) { + throw new MissingHeaderError('opaque param was not in the ' + authzHeaderName + ' header') + } + parsed.signingString += '(opaque): ' + opaque + } else if (h === '(created)') { + parsed.signingString += '(created): ' + parsed.params.created + } else if (h === '(expires)') { + parsed.signingString += '(expires): ' + parsed.params.expires + } else { + let value = request.headers.get(h) + if (value === null) throw new MissingHeaderError(h + ' was not in the request') + parsed.signingString += h + ': ' + value + } + + if (i + 1 < parsed.params.headers.length) parsed.signingString += '\n' + } + + // Check against the constraints + let date + let skew + if (request.headers.date || request.headers.has('x-date')) { + if (request.headers.has('x-date')) { + date = new Date(request.headers.get('x-date') as string) + } else { + date = new Date(request.headers.date) + } + let now = new Date() + skew = Math.abs(now.getTime() - date.getTime()) + + if (skew > options.clockSkew * 1000) { + throw new ExpiredRequestError('clock skew of ' + skew / 1000 + 's was greater than ' + options.clockSkew + 's') + } + } + + if (parsed.params.created) { + skew = parsed.params.created - Math.floor(Date.now() / 1000) + if (skew > options.clockSkew) { + throw new ExpiredRequestError( + 'Created lies in the future (with ' + 'skew ' + skew + 's greater than allowed ' + options.clockSkew + 's' + ) + } + + if (Math.abs(skew) > options.clockSkew) { + throw new ExpiredRequestError( + 'clock skew of ' + Math.abs(skew) + 's greater than allowed ' + options.clockSkew + 's' + ) + } + } + + if (parsed.params.expires) { + let expiredSince = Math.floor(Date.now() / 1000) - parsed.params.expires + if (expiredSince > options.clockSkew) { + throw new ExpiredRequestError( + 'Request expired with skew ' + expiredSince + 's greater than allowed ' + options.clockSkew + 's' + ) + } + } + + headers.forEach(function (hdr) { + // Remember that we already checked any headers in the params + // were in the request, so if this passes we're good. + if (parsed.params.headers.indexOf(hdr.toLowerCase()) < 0) + throw new MissingHeaderError(hdr + ' was not a signed header') + }) + + parsed.params.algorithm = parsed.params.algorithm.toLowerCase() + parsed.algorithm = parsed.params.algorithm.toUpperCase() + parsed.keyId = parsed.params.keyId + parsed.opaque = parsed.params.opaque + parsed.signature = parsed.params.signature + return parsed +} diff --git a/backend/src/utils/httpsigjs/utils.ts b/backend/src/utils/httpsigjs/utils.ts new file mode 100644 index 0000000..3493a9b --- /dev/null +++ b/backend/src/utils/httpsigjs/utils.ts @@ -0,0 +1,53 @@ +// Copyright 2012 Joyent, Inc. All rights reserved. +export const HASH_ALGOS = new Set(['sha1', 'sha256', 'sha512']) + +export const PK_ALGOS = new Set(['rsa', 'dsa', 'ecdsa']) + +export const HEADER = { + AUTH: 'authorization', + SIG: 'signature', +} + +export class HttpSignatureError extends Error { + constructor(message: string, caller: any) { + super(message) + if (Error.captureStackTrace) Error.captureStackTrace(this, caller || HttpSignatureError) + + this.message = message + this.name = caller.name + } +} + +export class InvalidAlgorithmError extends HttpSignatureError { + constructor(message: string) { + super(message, InvalidAlgorithmError) + } +} + +/** + * @param algorithm {String} the algorithm of the signature + * @param publicKeyType {String?} fallback algorithm (public key type) for + * hs2019 + * @returns {[string, string]} + */ +export function validateAlgorithm(algorithm: string, publicKeyType?: string): [string, string] { + var alg = algorithm.toLowerCase().split('-') + + if (alg[0] === 'hs2019') { + return publicKeyType !== undefined ? validateAlgorithm(publicKeyType + '-sha256') : ['hs2019', 'sha256'] + } + + if (alg.length !== 2) { + throw new InvalidAlgorithmError(alg[0].toUpperCase() + ' is not a ' + 'valid algorithm') + } + + if (alg[0] !== 'hmac' && !PK_ALGOS.has(alg[0])) { + throw new InvalidAlgorithmError(alg[0].toUpperCase() + ' type keys ' + 'are not supported') + } + + if (!HASH_ALGOS.has(alg[1])) { + throw new InvalidAlgorithmError(alg[1].toUpperCase() + ' is not a ' + 'supported hash algorithm') + } + + return alg as [string, string] +} diff --git a/backend/src/utils/httpsigjs/verifier.ts b/backend/src/utils/httpsigjs/verifier.ts new file mode 100644 index 0000000..f920bb4 --- /dev/null +++ b/backend/src/utils/httpsigjs/verifier.ts @@ -0,0 +1,29 @@ +import { importPublicKey, str2ab } from '../key-ops' +import { ParsedSignature } from './parser' + +interface Profile { + publicKey: { + id: string + owner: string + publicKeyPem: string + } +} + +export async function verifySignature(parsedSignature: ParsedSignature, key: CryptoKey): Promise { + return crypto.subtle.verify( + 'RSASSA-PKCS1-v1_5', + key, + str2ab(atob(parsedSignature.signature)), + str2ab(parsedSignature.signingString) + ) +} + +export async function fetchKey(parsedSignature: ParsedSignature): Promise { + const response = await fetch(parsedSignature.keyId, { + method: 'GET', + headers: { Accept: 'application/activity+json' }, + }) + + const parsedResponse = (await response.json()) as Profile + return importPublicKey(parsedResponse.publicKey.publicKeyPem) +} diff --git a/backend/src/utils/key-ops.ts b/backend/src/utils/key-ops.ts new file mode 100644 index 0000000..cc34302 --- /dev/null +++ b/backend/src/utils/key-ops.ts @@ -0,0 +1,163 @@ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + let binary = '' + const bytes = new Uint8Array(buffer) + const len = bytes.byteLength + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary) +} + +// from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String +export function str2ab(str: string): ArrayBuffer { + const buf = new ArrayBuffer(str.length) + const bufView = new Uint8Array(buf) + for (let i = 0, strLen = str.length; i < strLen; i++) { + bufView[i] = str.charCodeAt(i) + } + return buf +} + +/* +Get some key material to use as input to the deriveKey method. +The key material is a password not stored in the DB. +*/ +function getKeyMaterial(password: string): Promise { + const enc = new TextEncoder() + return crypto.subtle.importKey('raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveBits', 'deriveKey']) +} + +/* +Given some key material and some random salt +derive an AES-KW key using PBKDF2. +*/ +function getKey(keyMaterial: CryptoKey, salt: ArrayBuffer): Promise { + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations: 10000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] + ) +} + +/* +Wrap the given key. +*/ +async function wrapCryptoKey( + keyToWrap: CryptoKey, + userKEK: string +): Promise<{ wrappedPrivKey: ArrayBuffer; salt: Uint8Array }> { + // get the key encryption key + const keyMaterial = await getKeyMaterial(userKEK) + const salt = crypto.getRandomValues(new Uint8Array(16)) + const wrappingKey = await getKey(keyMaterial, salt) + const bytesToWrap = await crypto.subtle.exportKey('pkcs8', keyToWrap) + const wrappedPrivKey = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: salt, + }, + wrappingKey, + bytesToWrap as ArrayBuffer + ) + + return { wrappedPrivKey, salt } +} + +/* +Generate a new wrapped user key +*/ +export async function generateUserKey( + userKEK: string +): Promise<{ wrappedPrivKey: ArrayBuffer; salt: Uint8Array; pubKey: string }> { + const keyPair = await crypto.subtle.generateKey( + { + name: 'RSASSA-PKCS1-v1_5', + modulusLength: 4096, + publicExponent: new Uint8Array([1, 0, 1]), + hash: 'SHA-256', + }, + true, + ['sign', 'verify'] + ) + + const { wrappedPrivKey, salt } = await wrapCryptoKey((keyPair as CryptoKeyPair).privateKey, userKEK) + const pubKeyBuf = (await crypto.subtle.exportKey('spki', (keyPair as CryptoKeyPair).publicKey)) as ArrayBuffer + const pubKeyAsBase64 = arrayBufferToBase64(pubKeyBuf) + const pubKey = `-----BEGIN PUBLIC KEY-----\n${pubKeyAsBase64}\n-----END PUBLIC KEY-----` + + return { wrappedPrivKey, salt, pubKey } +} + +/* +Unwrap and import private key +*/ +export async function unwrapPrivateKey( + userKEK: string, + wrappedPrivKey: ArrayBuffer, + salt: Uint8Array +): Promise { + const keyMaterial = await getKeyMaterial(userKEK) + const wrappingKey = await getKey(keyMaterial, salt) + const keyBytes = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: salt, + }, + wrappingKey, + wrappedPrivKey + ) + return await crypto.subtle.importKey( + 'pkcs8', + keyBytes, + { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }, + true, + ['sign'] + ) +} + +/* +Import public key +*/ +export async function importPublicKey(exportedKey: string): Promise { + // fetch the part of the PEM string between header and footer + const trimmed = exportedKey.trim() + const pemHeader = '-----BEGIN PUBLIC KEY-----' + const pemFooter = '-----END PUBLIC KEY-----' + const pemContents = trimmed.substring(pemHeader.length, trimmed.length - pemFooter.length) + + // base64 decode the string to get the binary data + const binaryDerString = atob(pemContents) + + // convert from a binary string to an ArrayBuffer + const binaryDer = str2ab(binaryDerString) + + return crypto.subtle.importKey( + 'spki', + binaryDer, + { + name: 'RSASSA-PKCS1-v1_5', + hash: 'SHA-256', + }, + true, + ['verify'] + ) +} + +const DEC = { + '-': '+', + _: '/', + '.': '=', +} +export function urlsafeBase64Decode(v: string) { + return atob(v.replace(/[-_.]/g, (m: string) => (DEC as any)[m])) +} diff --git a/backend/src/utils/parse.ts b/backend/src/utils/parse.ts new file mode 100644 index 0000000..fc403ff --- /dev/null +++ b/backend/src/utils/parse.ts @@ -0,0 +1,21 @@ +export type Handle = { + localPart: string + domain: string | null +} + +export function parseHandle(query: string): Handle { + // Remove the leading @, if there's one. + if (query.startsWith('@')) { + query = query.substring(1) + } + + // In case the handle has been URL encoded + query = decodeURIComponent(query) + + const parts = query.split('@') + if (parts.length > 1) { + return { localPart: parts[0], domain: parts[1] } + } else { + return { localPart: parts[0], domain: null } + } +} diff --git a/backend/src/webfinger/index.ts b/backend/src/webfinger/index.ts new file mode 100644 index 0000000..7a2fdd8 --- /dev/null +++ b/backend/src/webfinger/index.ts @@ -0,0 +1,48 @@ +import { MastodonAccount } from '../types/account' +import * as config from 'wildebeest/backend/src/config' +import * as actors from '../activitypub/actors' +import type { Actor } from '../activitypub/actors' + +export type WebFingerResponse = { + subject: string + aliases: Array + links: Array +} + +const headers = { + accept: 'application/jrd+json', +} + +export async function queryAcct(domain: string, acct: string): Promise { + const url = await queryAcctLink(domain, acct) + if (url === null) { + return null + } + return actors.get(url) +} + +export async function queryAcctLink(domain: string, acct: string): Promise { + const params = new URLSearchParams({ resource: `acct:${acct}` }) + let res + try { + const url = new URL('/.well-known/webfinger?' + params, 'https://' + domain) + console.log('query', url.href) + res = await fetch(url, { headers }) + if (!res.ok) { + throw new Error(`WebFinger API returned: ${res.status}`) + } + } catch (err) { + console.warn('failed to query WebFinger:', err) + return null + } + + const data = await res.json() + for (let i = 0, len = data.links.length; i < len; i++) { + const link = data.links[i] + if (link.rel === 'self' && link.type === 'application/activity+json') { + return new URL(link.href) + } + } + + return null +} diff --git a/backend/src/webpush/hkdf.ts b/backend/src/webpush/hkdf.ts new file mode 100644 index 0000000..758ac2e --- /dev/null +++ b/backend/src/webpush/hkdf.ts @@ -0,0 +1,18 @@ +export async function hmacSign(ikm: Uint8Array | ArrayBuffer, input: ArrayBuffer): Promise { + const key = await crypto.subtle.importKey('raw', ikm, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + return await crypto.subtle.sign('HMAC', key, input) +} + +export async function hkdfGenerate( + ikm: ArrayBuffer, + salt: Uint8Array, + info: Uint8Array, + byteLength: number +): Promise { + const fullInfoBuffer = new Uint8Array(info.byteLength + 1) + fullInfoBuffer.set(info, 0) + fullInfoBuffer.set(new Uint8Array(1).fill(1), info.byteLength) + const prk = await hmacSign(salt, ikm) + const nextPrk = await hmacSign(prk, fullInfoBuffer) + return nextPrk.slice(0, byteLength) +} diff --git a/backend/src/webpush/index.ts b/backend/src/webpush/index.ts new file mode 100644 index 0000000..a861e9b --- /dev/null +++ b/backend/src/webpush/index.ts @@ -0,0 +1,48 @@ +import type { JWK } from './jwk' +import { WebPushInfos, WebPushMessage, WebPushResult } from './webpushinfos' +import { generateAESGCMEncryptedMessage } from './message' +import { generateV1Headers } from './vapid' + +export async function generateWebPushMessage( + message: WebPushMessage, + deviceData: WebPushInfos, + applicationServerKeys: JWK +): Promise { + const [authHeaders, encryptedPayloadDetails] = await Promise.all([ + generateV1Headers(deviceData.endpoint, applicationServerKeys, message.sub), + generateAESGCMEncryptedMessage(message.data, deviceData), + ]) + + const headers: { [headerName: string]: string } = { ...authHeaders } + headers['Encryption'] = `salt=${encryptedPayloadDetails.salt}` + headers['Crypto-Key'] = `dh=${encryptedPayloadDetails.publicServerKey};${headers['Crypto-Key']}` + + headers['Content-Encoding'] = 'aesgcm' + headers['Content-Type'] = 'application/octet-stream' + + // setup message headers + headers['TTL'] = `${message.ttl}` + headers['Urgency'] = `${message.urgency}` + + const res = await fetch(deviceData.endpoint, { + method: 'POST', + headers, + body: encryptedPayloadDetails.cipherText, + }) + + switch (res.status) { + case 200: // http ok + case 201: // http created + case 204: // http no content + return WebPushResult.Success + + case 400: // http bad request + case 401: // http unauthorized + case 404: // http not found + case 410: // http gone + return WebPushResult.NotSubscribed + } + + console.warn(`WebPush res: ${res.status} body: ${await res.text()}`) + return WebPushResult.Error +} diff --git a/backend/src/webpush/jwk.ts b/backend/src/webpush/jwk.ts new file mode 100644 index 0000000..bcb645f --- /dev/null +++ b/backend/src/webpush/jwk.ts @@ -0,0 +1,9 @@ +export interface JWK { + crv: string + kty: string + key_ops: string[] + ext: boolean + d: string + x: string + y: string +} diff --git a/backend/src/webpush/message.ts b/backend/src/webpush/message.ts new file mode 100644 index 0000000..a49cbdf --- /dev/null +++ b/backend/src/webpush/message.ts @@ -0,0 +1,174 @@ +import type { JWK } from './jwk' +import type { WebPushInfos } from './webpushinfos' +import { + b64ToUrlEncoded, + cryptoKeysToUint8Array, + exportPublicKeyPair, + joinUint8Arrays, + stringToU8Array, + u8ToString, +} from './util' +import { hkdfGenerate } from './hkdf' +import { urlsafeBase64Decode } from 'wildebeest/backend/src/utils/key-ops' + +const encoder = new TextEncoder() + +type mKeyPair = { + publicKey: CryptoKey + privateKey: CryptoKey +} + +async function generateSalt(): Promise { + return crypto.getRandomValues(new Uint8Array(16)) +} + +async function getSubKeyAsCryptoKey(subscription: WebPushInfos): Promise { + const key = urlsafeBase64Decode(subscription.key) + const publicKey = await crypto.subtle.importKey( + 'jwk', + { + kty: 'EC', + crv: 'P-256', + x: b64ToUrlEncoded(btoa(key.slice(1, 33))), + y: b64ToUrlEncoded(btoa(key.slice(33, 65))), + ext: true, + }, + { + name: 'ECDH', + namedCurve: 'P-256', + }, + true, + [] + ) + return publicKey +} + +async function getSharedSecret(subscription: WebPushInfos, serverKeys: mKeyPair): Promise { + const publicKey = await getSubKeyAsCryptoKey(subscription) + const algorithm = { + name: 'ECDH', + namedCurve: 'P-256', + public: publicKey, + } + return await crypto.subtle.deriveBits(algorithm, serverKeys.privateKey, 256) +} + +export async function generateContext(subscription: WebPushInfos, serverKeys: mKeyPair): Promise { + const subKey = await getSubKeyAsCryptoKey(subscription) + + const [clientPublicKey, serverPublicKey] = await Promise.all([ + cryptoKeysToUint8Array(subKey).then((key) => key.publicKey), + cryptoKeysToUint8Array(serverKeys.publicKey).then((key) => key.publicKey), + ]) + + const labelUnit8Array = stringToU8Array('P-256\x00') + + const clientPublicKeyLengthUnit8Array = new Uint8Array(2) + clientPublicKeyLengthUnit8Array[0] = 0x00 + clientPublicKeyLengthUnit8Array[1] = clientPublicKey.byteLength + + const serverPublicKeyLengthBuffer = new Uint8Array(2) + serverPublicKeyLengthBuffer[0] = 0x00 + serverPublicKeyLengthBuffer[1] = serverPublicKey.byteLength + + return joinUint8Arrays([ + labelUnit8Array, + clientPublicKeyLengthUnit8Array, + clientPublicKey, + serverPublicKeyLengthBuffer, + serverPublicKey, + ]) +} + +async function generatePRK(subscription: WebPushInfos, serverKeys: mKeyPair): Promise { + const sharedSecret = await getSharedSecret(subscription, serverKeys) + const token = 'Content-Encoding: auth\x00' + const authInfoUint8Array = stringToU8Array(token) + return await hkdfGenerate( + sharedSecret, + stringToU8Array(urlsafeBase64Decode(subscription.auth)), + authInfoUint8Array, + 32 + ) +} + +async function generateCEKInfo(subscription: WebPushInfos, serverKeys: mKeyPair): Promise { + const token = 'Content-Encoding: aesgcm\x00' + const contentEncoding8Array = stringToU8Array(token) + const contextBuffer = await generateContext(subscription, serverKeys) + return joinUint8Arrays([contentEncoding8Array, contextBuffer]) +} + +async function generateNonceInfo(subscription: WebPushInfos, serverKeys: mKeyPair): Promise { + const token = 'Content-Encoding: nonce\x00' + const contentEncoding8Array = stringToU8Array(token) + const contextBuffer = await generateContext(subscription, serverKeys) + return joinUint8Arrays([contentEncoding8Array, contextBuffer]) +} + +export async function generateEncryptionKeys( + subscription: WebPushInfos, + salt: Uint8Array, + serverKeys: mKeyPair +): Promise<{ contentEncryptionKey: ArrayBuffer; nonce: ArrayBuffer }> { + const [prk, cekInfo, nonceInfo] = await Promise.all([ + generatePRK(subscription, serverKeys), + generateCEKInfo(subscription, serverKeys), + generateNonceInfo(subscription, serverKeys), + ]) + const [contentEncryptionKey, nonce] = await Promise.all([ + hkdfGenerate(prk, salt, cekInfo, 16), + hkdfGenerate(prk, salt, nonceInfo, 12), + ]) + return { contentEncryptionKey, nonce } +} + +async function generateServerKey(): Promise { + return (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ])) as unknown as mKeyPair +} + +export async function generateAESGCMEncryptedMessage( + payloadText: string, + subscription: WebPushInfos +): Promise<{ + cipherText: ArrayBuffer + salt: string + publicServerKey: string +}> { + const salt = await generateSalt() + const serverKeys = await generateServerKey() + const exportedServerKey = (await crypto.subtle.exportKey('jwk', serverKeys.publicKey)) as unknown as JWK + const encryptionKeys = await generateEncryptionKeys(subscription, salt, serverKeys) + const contentEncryptionCryptoKey = await crypto.subtle.importKey( + 'raw', + encryptionKeys.contentEncryptionKey, + 'AES-GCM', + true, + ['decrypt', 'encrypt'] + ) + + const paddingBytes = 0 + const paddingUnit8Array = new Uint8Array(2 + paddingBytes) + const payloadUint8Array = encoder.encode(payloadText) + const recordUint8Array = new Uint8Array(paddingUnit8Array.byteLength + payloadUint8Array.byteLength) + recordUint8Array.set(paddingUnit8Array, 0) + recordUint8Array.set(payloadUint8Array, paddingUnit8Array.byteLength) + + const encryptedPayloadArrayBuffer = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + tagLength: 128, + iv: encryptionKeys.nonce, + }, + contentEncryptionCryptoKey, + recordUint8Array + ) + + return { + cipherText: encryptedPayloadArrayBuffer, + salt: b64ToUrlEncoded(btoa(u8ToString(salt))), + publicServerKey: b64ToUrlEncoded(exportPublicKeyPair(exportedServerKey)), + } +} diff --git a/backend/src/webpush/util.ts b/backend/src/webpush/util.ts new file mode 100644 index 0000000..25378af --- /dev/null +++ b/backend/src/webpush/util.ts @@ -0,0 +1,81 @@ +// note: +// all util functions return normal b64 NOT URL safe b64 +// use b64ToUrlEncoded to convert to URL safe b64 + +function ArrayToHex(byteArray: Uint8Array): string { + return Array.prototype.map + .call(byteArray, (byte: number) => { + return ('0' + (byte & 0xff).toString(16)).slice(-2) + }) + .join('') +} + +export function generateRandomId(size = 16): string { + const buffer = new Uint8Array(size) + crypto.getRandomValues(buffer) + return ArrayToHex(buffer) +} + +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + let bin = '' + const uint8 = new Uint8Array(buffer) + uint8.forEach((code: number) => { + bin += String.fromCharCode(code) + }) + return btoa(bin) +} + +export function b64ToUrlEncoded(str: string): string { + return str.replaceAll(/\+/g, '-').replaceAll(/\//g, '_').replace(/=+/g, '') +} + +export function urlEncodedToB64(str: string): string { + const padding = '='.repeat((4 - (str.length % 4)) % 4) + return str.replaceAll(/-/g, '+').replaceAll(/_/g, '/') + padding +} + +export function stringToU8Array(str: string): Uint8Array { + return new Uint8Array(str.split('').map((c) => c.charCodeAt(0))) +} + +export function u8ToString(u8: Uint8Array): string { + return String.fromCharCode.apply(null, u8 as unknown as number[]) +} + +export function exportPublicKeyPair(key: T): string { + return btoa('\x04' + atob(urlEncodedToB64(key.x)) + atob(urlEncodedToB64(key.y))) +} + +export function joinUint8Arrays(allUint8Arrays: Array): Uint8Array { + return allUint8Arrays.reduce(function (cumulativeValue, nextValue) { + const joinedArray = new Uint8Array(cumulativeValue.byteLength + nextValue.byteLength) + joinedArray.set(cumulativeValue, 0) + joinedArray.set(nextValue, cumulativeValue.byteLength) + return joinedArray + }, new Uint8Array()) +} + +function base64UrlToUint8Array(base64UrlData: string): Uint8Array { + const base64 = urlEncodedToB64(base64UrlData) + const rawData = atob(base64) + return stringToU8Array(rawData) +} + +export async function cryptoKeysToUint8Array( + pubKey: CryptoKey, + privKey?: CryptoKey +): Promise<{ publicKey: Uint8Array; privateKey?: Uint8Array }> { + const jwk: any = await crypto.subtle.exportKey('jwk', pubKey) + const x = base64UrlToUint8Array(jwk.x as string) + const y = base64UrlToUint8Array(jwk.y as string) + const publicKey = new Uint8Array(65) + publicKey.set([0x04], 0) + publicKey.set(x, 1) + publicKey.set(y, 33) + if (privKey) { + const jwk: any = await crypto.subtle.exportKey('jwk', privKey) + const privateKey = base64UrlToUint8Array(jwk.d as string) + return { publicKey, privateKey } + } + return { publicKey } +} diff --git a/backend/src/webpush/vapid.ts b/backend/src/webpush/vapid.ts new file mode 100644 index 0000000..746e8cd --- /dev/null +++ b/backend/src/webpush/vapid.ts @@ -0,0 +1,48 @@ +import type { JWK } from './jwk' +import { arrayBufferToBase64, b64ToUrlEncoded, exportPublicKeyPair, stringToU8Array } from './util' + +const objToUrlB64 = (obj: { [key: string]: string | number | null }) => b64ToUrlEncoded(btoa(JSON.stringify(obj))) + +async function signData(token: string, applicationKeys: JWK): Promise { + const key = await crypto.subtle.importKey('jwk', applicationKeys, { name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + ]) + + const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, key, stringToU8Array(token)) + + return b64ToUrlEncoded(arrayBufferToBase64(sig)) +} + +async function generateHeaders( + endpoint: string, + applicationServerKeys: JWK, + sub: string +): Promise<{ token: string; serverKey: string }> { + const serverKey = b64ToUrlEncoded(exportPublicKeyPair(applicationServerKeys)) + const pushService = new URL(endpoint) + + const header = { + typ: 'JWT', + alg: 'ES256', + } + + const body = { + aud: `${pushService.protocol}//${pushService.host}`, + exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60, + sub: 'mailto:' + sub, + } + + const unsignedToken = objToUrlB64(header) + '.' + objToUrlB64(body) + const signature = await signData(unsignedToken, applicationServerKeys) + const token = `${unsignedToken}.${signature}` + return { token, serverKey } +} + +export async function generateV1Headers( + endpoint: string, + applicationServerKeys: JWK, + sub: string +): Promise<{ [headerName in 'Crypto-Key' | 'Authorization']: string }> { + const headers = await generateHeaders(endpoint, applicationServerKeys, sub) + return { Authorization: `WebPush ${headers.token}`, 'Crypto-Key': `p256ecdsa=${headers.serverKey}` } +} diff --git a/backend/src/webpush/webpushinfos.ts b/backend/src/webpush/webpushinfos.ts new file mode 100644 index 0000000..5c1145f --- /dev/null +++ b/backend/src/webpush/webpushinfos.ts @@ -0,0 +1,22 @@ +export interface WebPushInfos { + endpoint: string + key: string + auth: string + + // supportedAlgorithms: string[]; // this will be used in future +} + +type Urgency = 'very-low' | 'low' | 'normal' | 'high' + +export interface WebPushMessage { + data: string + urgency: Urgency + sub: string + ttl: number +} + +export enum WebPushResult { + Success = 0, + Error = 1, + NotSubscribed = 2, +} diff --git a/backend/test/activitypub.spec.ts b/backend/test/activitypub.spec.ts new file mode 100644 index 0000000..ccd198d --- /dev/null +++ b/backend/test/activitypub.spec.ts @@ -0,0 +1,345 @@ +import { makeDB, assertCache, isUrlValid } from './utils' +import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' +import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' +import { strict as assert } from 'node:assert/strict' +import { cacheObject, createObject } from 'wildebeest/backend/src/activitypub/objects/' + +import * as ap_users from 'wildebeest/functions/ap/users/[id]' +import * as ap_outbox from 'wildebeest/functions/ap/users/[id]/outbox' +import * as ap_outbox_page from 'wildebeest/functions/ap/users/[id]/outbox/page' + +const userKEK = 'test_kek5' +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const domain = 'cloudflare.com' + +describe('ActivityPub', () => { + test('fetch non-existant user by id', async () => { + const db = await makeDB() + + const res = await ap_users.handleRequest(domain, db, 'nonexisting') + assert.equal(res.status, 404) + }) + + test('fetch user by id', async () => { + const db = await makeDB() + const properties = { summary: 'test summary' } + const pubKey = + '-----BEGIN PUBLIC KEY-----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApnI8FHJQXqqAdM87YwVseRUqbNLiw8nQ0zHBUyLylzaORhI4LfW4ozguiw8cWYgMbCufXMoITVmdyeTMGbQ3Q1sfQEcEjOZZXEeCCocmnYjK6MFSspjFyNw6GP0a5A/tt1tAcSlgALv8sg1RqMhSE5Kv+6lSblAYXcIzff7T2jh9EASnimaoAAJMaRH37+HqSNrouCxEArcOFhmFETadXsv+bHZMozEFmwYSTugadr4WD3tZd+ONNeimX7XZ3+QinMzFGOW19ioVHyjt3yCDU1cPvZIDR17dyEjByNvx/4N4Zly7puwBn6Ixy/GkIh5BWtL5VOFDJm/S+zcf1G1WsOAXMwKL4Nc5UWKfTB7Wd6voId7vF7nI1QYcOnoyh0GqXWhTPMQrzie4nVnUrBedxW0s/0vRXeR63vTnh5JrTVu06JGiU2pq2kvwqoui5VU6rtdImITybJ8xRkAQ2jo4FbbkS6t49PORIuivxjS9wPl7vWYazZtDVa5g/5eL7PnxOG3HsdIJWbGEh1CsG83TU9burHIepxXuQ+JqaSiKdCVc8CUiO++acUqKp7lmbYR9E/wRmvxXDFkxCZzA0UL2mRoLLLOe4aHvRSTsqiHC5Wwxyew5bb+eseJz3wovid9ZSt/tfeMAkCDmaCxEK+LGEbJ9Ik8ihis8Esm21N0A54sCAwEAAQ==-----END PUBLIC KEY-----' + await db + .prepare('INSERT INTO actors (id, email, type, properties, pubkey) VALUES (?, ?, ?, ?, ?)') + .bind(`https://${domain}/ap/users/sven`, 'sven@cloudflare.com', 'Person', JSON.stringify(properties), pubKey) + .run() + + const res = await ap_users.handleRequest(domain, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.summary, 'test summary') + assert(data.discoverable) + assert(data['@context']) + assert(isUrlValid(data.id)) + assert(isUrlValid(data.url)) + assert(isUrlValid(data.inbox)) + assert(isUrlValid(data.outbox)) + assert(isUrlValid(data.following)) + assert(isUrlValid(data.followers)) + assert.equal(data.publicKey.publicKeyPem, pubKey) + }) + + describe('Accept', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + throw new Error('unexpected request to ' + input) + } + }) + + test('Accept follow request stores in db', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor, actor2, 'not needed') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Accept', + actor: { id: 'https://' + domain + '/ap/users/sven2' }, + object: { + type: 'Follow', + actor: actor.id, + object: 'https://' + domain + '/ap/users/sven2', + }, + } + + await activityHandler.handle(domain, activity, db, userKEK, 'inbox') + + const row = await db + .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) + .bind(actor.id.toString()) + .first() + assert(row) + assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') + assert.equal(row.state, 'accepted') + }) + + test('Object must be an object', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Accept', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), { + message: '`activity.object` must be of type object', + }) + }) + }) + + describe('Create', () => { + test('Object must be an object', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Create', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), { + message: '`activity.object` must be of type object', + }) + }) + }) + + describe('Update', () => { + test('Object must be an object', async () => { + const db = await makeDB() + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), { + message: '`activity.object` must be of type object', + }) + }) + + test('Object must exist', async () => { + const db = await makeDB() + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + }, + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), { + message: 'object https://example.com/note2 does not exist', + }) + }) + + test('Object must have the same origin', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const object = { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + } + + const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) + assert.notEqual(obj, null, 'could not create object') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: object, + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), { + message: 'actorid mismatch when updating object', + }) + }) + + test('Object is updated', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const object = { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + } + + const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) + assert.notEqual(obj, null, 'could not create object') + + const newObject = { + id: 'https://example.com/note2', + type: 'Note', + content: 'new test note', + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: actor.id, + object: newObject, + } + + await activityHandler.handle(domain, activity, db, userKEK, 'inbox') + + const updatedObject = await db.prepare('SELECT * FROM objects WHERE original_object_id=?').bind(object.id).first() + assert(updatedObject) + assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) + }) + }) + + describe('Outbox', () => { + test('return outbox', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + + await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my first status', actor)) + await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor)) + + const res = await ap_outbox.handleRequest(domain, db, 'sven', userKEK) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.type, 'OrderedCollection') + assert.equal(data.totalItems, 2) + }) + + test('return outbox page', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + + await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my first status', actor)) + await sleep(10) + await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor)) + + const res = await ap_outbox_page.handleRequest(domain, db, 'sven', userKEK) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.type, 'OrderedCollectionPage') + assert.equal(data.orderedItems.length, 2) + assert.equal(data.orderedItems[0].object.content, 'my second status') + assert.equal(data.orderedItems[1].object.content, 'my first status') + }) + }) + + describe('Announce', () => { + test('Announce objects are stored and added to the remote actors outbox', async () => { + const remoteActorId = 'https://example.com/actor' + const objectId = 'https://example.com/some-object' + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === remoteActorId) { + return new Response( + JSON.stringify({ + id: remoteActorId, + icon: { url: 'img.com' }, + type: 'Person', + }) + ) + } + + if (input.toString() === objectId) { + return new Response( + JSON.stringify({ + id: objectId, + type: 'Note', + content: 'foo', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + + const activity: any = { + type: 'Announce', + actor: remoteActorId, + to: [], + cc: [], + object: objectId, + } + await activityHandler.handle(domain, activity, db, userKEK, 'inbox') + + const object = await db.prepare('SELECT * FROM objects').bind(remoteActorId).first() + assert(object) + assert.equal(object.type, 'Note') + assert.equal(object.original_actor_id, remoteActorId) + + const outbox_object = await db + .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') + .bind(remoteActorId) + .first() + assert(outbox_object) + assert.equal(outbox_object.actor_id, remoteActorId) + }) + }) + + describe('Objects', () => { + test('cacheObject deduplicates object', async () => { + const db = await makeDB() + const properties = { type: 'Note', a: 1, b: 2 } + const actorId = new URL(await createPerson(domain, db, userKEK, 'a@cloudflare.com')) + const originalObjectId = new URL('https://example.com/object1') + + let result: any + + // Cache object once adds it to the database + const obj1: any = await cacheObject(domain, db, properties, actorId, originalObjectId, false) + assert.equal(obj1.a, 1) + assert.equal(obj1.b, 2) + + result = await db.prepare('SELECT count(*) as count from objects').first() + assert.equal(result.count, 1) + + // Cache object second time updates the first one + properties.a = 3 + const obj2: any = await cacheObject(domain, db, properties, actorId, originalObjectId, false) + // The creation date and properties don't change + assert.equal(obj1.a, obj2.a) + assert.equal(obj1.b, obj2.b) + assert.equal(obj1.published, obj2.published) + + result = await db.prepare('SELECT count(*) as count from objects').first() + assert.equal(result.count, 1) + }) + }) +}) diff --git a/backend/test/activitypub/follow.spec.ts b/backend/test/activitypub/follow.spec.ts new file mode 100644 index 0000000..494054d --- /dev/null +++ b/backend/test/activitypub/follow.spec.ts @@ -0,0 +1,183 @@ +import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' +import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' +import * as ap_followers_page from 'wildebeest/functions/ap/users/[id]/followers/page' +import * as ap_following_page from 'wildebeest/functions/ap/users/[id]/following/page' +import * as ap_followers from 'wildebeest/functions/ap/users/[id]/followers' +import * as ap_following from 'wildebeest/functions/ap/users/[id]/following' +import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' +import { strict as assert } from 'node:assert/strict' +import { makeDB, assertCache, isUrlValid } from '../utils' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' + +const userKEK = 'test_kek10' +const domain = 'cloudflare.com' + +describe('ActivityPub', () => { + describe('Follow', () => { + let receivedActivity: any = null + + beforeEach(() => { + receivedActivity = null + + globalThis.fetch = async (input: any) => { + if (input.url === `https://${domain}/ap/users/sven2/inbox`) { + assert.equal(input.method, 'POST') + const data = await input.json() + receivedActivity = data + console.log({ receivedActivity }) + return new Response('') + } + + throw new Error('unexpected request to ' + input.url) + } + }) + + test('Receive follow with Accept reply', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Follow', + actor: actor2.id.toString(), + object: actor.id.toString(), + } + + await activityHandler.handle(domain, activity, db, userKEK, 'inbox') + + const row = await db + .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) + .bind(actor2.id.toString()) + .first() + assert(row) + assert.equal(row.target_actor_id.toString(), actor.id.toString()) + assert.equal(row.state, 'accepted') + + assert(receivedActivity) + assert.equal(receivedActivity.type, 'Accept') + assert.equal(receivedActivity.actor.toString(), actor.id.toString()) + assert.equal(receivedActivity.object.actor, activity.actor) + assert.equal(receivedActivity.object.type, activity.type) + }) + + test('list actor following', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + const actor3: any = { + id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com'), + } + await addFollowing(db, actor, actor2, 'not needed') + await acceptFollowing(db, actor, actor2) + await addFollowing(db, actor, actor3, 'not needed') + await acceptFollowing(db, actor, actor3) + + const res = await ap_following.handleRequest(domain, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.type, 'OrderedCollection') + assert.equal(data.totalItems, 2) + }) + + test('list actor following page', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + const actor3: any = { + id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com'), + } + await addFollowing(db, actor, actor2, 'not needed') + await acceptFollowing(db, actor, actor2) + await addFollowing(db, actor, actor3, 'not needed') + await acceptFollowing(db, actor, actor3) + + const res = await ap_following_page.handleRequest(domain, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.type, 'OrderedCollectionPage') + assert.equal(data.orderedItems[0], `https://${domain}/ap/users/sven2`) + assert.equal(data.orderedItems[1], `https://${domain}/ap/users/sven3`) + }) + + test('list actor follower', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor2, actor, 'not needed') + await acceptFollowing(db, actor2, actor) + + const res = await ap_followers.handleRequest(domain, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.type, 'OrderedCollection') + assert.equal(data.totalItems, 1) + }) + + test('list actor follower page', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor2, actor, 'not needed') + await acceptFollowing(db, actor2, actor) + + const res = await ap_followers_page.handleRequest(domain, db, 'sven') + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.type, 'OrderedCollectionPage') + assert.equal(data.orderedItems[0], `https://${domain}/ap/users/sven2`) + }) + + test('creates a notification', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Follow', + actor: actor2.id, + object: actor.id, + } + + await activityHandler.handle(domain, activity, db, userKEK, 'inbox') + + const entry = await db.prepare('SELECT * FROM actor_notifications').first() + assert.equal(entry.type, 'follow') + assert.equal(entry.actor_id.toString(), actor.id.toString()) + assert.equal(entry.from_actor_id.toString(), actor2.id.toString()) + }) + }) +}) diff --git a/backend/test/activitypub/inbox.spec.ts b/backend/test/activitypub/inbox.spec.ts new file mode 100644 index 0000000..f876147 --- /dev/null +++ b/backend/test/activitypub/inbox.spec.ts @@ -0,0 +1,322 @@ +import { makeDB, assertCache, isUrlValid } from '../utils' +import { generateVAPIDKeys, configure } from 'wildebeest/backend/src/config' +import * as objects from 'wildebeest/backend/src/activitypub/objects' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import * as ap_inbox from 'wildebeest/functions/ap/users/[id]/inbox' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { strict as assert } from 'node:assert/strict' + +const userKEK = 'test_kek9' +const domain = 'cloudflare.com' + +const kv_cache: any = { + async put() {}, +} + +const waitUntil = async (p: Promise) => await p + +describe('ActivityPub', () => { + test('send Note to non existant user', async () => { + const db = await makeDB() + + const activity: any = {} + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + assert.equal(res.status, 404) + }) + + test('send Note to inbox stores in DB', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const activity: any = { + type: 'Create', + actor: actorId, + to: [actorId], + cc: [], + object: { + id: 'https://example.com/note1', + type: 'Note', + content: 'test note', + }, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db + .prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id') + .first() + const properties = JSON.parse(entry.properties) + assert.equal(properties.content, 'test note') + }) + + test("send Note adds in remote actor's outbox", async () => { + const remoteActorId = 'https://example.com/actor' + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === remoteActorId) { + return new Response( + JSON.stringify({ + id: remoteActorId, + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const activity: any = { + type: 'Create', + actor: remoteActorId, + to: [], + cc: [], + object: { + id: 'https://example.com/note1', + type: 'Note', + content: 'test note', + }, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM outbox_objects WHERE actor_id=?').bind(remoteActorId).first() + assert.equal(entry.actor_id, remoteActorId) + }) + + test('local actor sends Note with mention create notification', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') + + const activity: any = { + type: 'Create', + actor: actorB, + to: [actorA], + cc: [], + object: { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + }, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actor_notifications').first() + assert.equal(entry.type, 'mention') + assert.equal(entry.actor_id.toString(), actorA.toString()) + assert.equal(entry.from_actor_id.toString(), actorB.toString()) + }) + + test('remote actor sends Note with mention create notification and download actor', async () => { + const actorB = 'https://remote.com/actorb' + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === actorB) { + return new Response( + JSON.stringify({ + id: actorB, + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + + const activity: any = { + type: 'Create', + actor: actorB, + to: [actorA], + cc: [], + object: { + id: 'https://example.com/note3', + type: 'Note', + content: 'test note', + }, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actors WHERE id=?').bind(actorB).first() + assert.equal(entry.id, actorB) + }) + + test('send Note records reply', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + { + const activity: any = { + type: 'Create', + actor: actorId, + to: [actorId], + object: { + id: 'https://example.com/note1', + type: 'Note', + content: 'post', + }, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + } + + { + const activity: any = { + type: 'Create', + actor: actorId, + to: [actorId], + object: { + inReplyTo: 'https://example.com/note1', + id: 'https://example.com/note2', + type: 'Note', + content: 'reply', + }, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + } + + const entry = await db.prepare('SELECT * FROM actor_replies').first() + assert.equal(entry.actor_id, actorId.toString()) + + const obj: any = await objects.getObjectById(db, entry.object_id) + assert(obj) + assert.equal(obj.originalObjectId, 'https://example.com/note2') + + const inReplyTo: any = await objects.getObjectById(db, entry.in_reply_to_object_id) + assert(inReplyTo) + assert.equal(inReplyTo.originalObjectId, 'https://example.com/note1') + }) + + describe('Announce', () => { + test('records reblog in db', async () => { + const db = await makeDB() + await generateVAPIDKeys(db) + await configure(db, { title: 'title', description: 'a', email: 'email' }) + const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') } + const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Announce', + actor: actorB.id, + object: note.id, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actor_reblogs').first() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + + test('creates notification', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') } + const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Announce', + actor: actorB.id, + object: note.id, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actor_notifications').first() + assert(entry) + assert.equal(entry.type, 'reblog') + assert.equal(entry.actor_id.toString(), actorA.id.toString()) + assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) + }) + }) + + describe('Like', () => { + test('records like in db', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') } + const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actor_favourites').first() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + + test('creates notification', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') } + const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actor_notifications').first() + assert.equal(entry.type, 'favourite') + assert.equal(entry.actor_id.toString(), actorA.id.toString()) + assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) + }) + + test('records like in db', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') } + const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil) + assert.equal(res.status, 200) + + const entry = await db.prepare('SELECT * FROM actor_favourites').first() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + }) +}) diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts new file mode 100644 index 0000000..f9f0db0 --- /dev/null +++ b/backend/test/mastodon.spec.ts @@ -0,0 +1,246 @@ +import { strict as assert } from 'node:assert/strict' +import * as v1_instance from 'wildebeest/functions/api/v1/instance' +import * as v2_instance from 'wildebeest/functions/api/v2/instance' +import * as apps from 'wildebeest/functions/api/v1/apps' +import * as custom_emojis from 'wildebeest/functions/api/v1/custom_emojis' +import * as notifications from 'wildebeest/functions/api/v1/notifications' +import { defaultImages } from 'wildebeest/config/accounts' +import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache, streamToArrayBuffer, createTestClient } from './utils' +import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' +import { getSigningKey } from 'wildebeest/backend/src/mastodon/account' +import { Actor, createPerson, getPersonById } from 'wildebeest/backend/src/activitypub/actors' +import { createClient, getClientById } from '../src/mastodon/client' +import { createSubscription } from '../src/mastodon/subscription' +import * as subscription from 'wildebeest/functions/api/v1/push/subscription' +import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' + +const userKEK = 'test_kek' +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('instance', () => { + test('return the instance infos v1', async () => { + const db = await makeDB() + const data = { + title: 'title', + uri: 'uri', + email: 'email', + description: 'description', + accessAud: '1', + accessDomain: 'foo', + } + await configure(db, data) + + const res = await v1_instance.handleRequest(domain, db) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + assertCache(res, 180) + + { + const data = await res.json() + assert.equal(data.rules.length, 0) + assert.equal(data.uri, domain) + } + }) + + test('return the instance infos v2', async () => { + const db = await makeDB() + const data = { + title: 'title', + uri: 'uri', + email: 'email', + description: 'description', + accessAud: '1', + accessDomain: 'foo', + } + await configure(db, data) + + const res = await v2_instance.handleRequest(domain, db) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + assertCache(res, 180) + }) + + test('adds a short_description if missing', async () => { + const db = await makeDB() + const data = { + title: 'title', + uri: 'uri', + email: 'email', + description: 'description', + accessAud: '1', + accessDomain: 'foo', + } + await configure(db, data) + + const res = await v1_instance.handleRequest(domain, db) + assert.equal(res.status, 200) + + { + const data = await res.json() + assert.equal(data.short_description, 'description') + } + }) + }) + + describe('apps', () => { + test('return the app infos', async () => { + const db = await makeDB() + await generateVAPIDKeys(db) + const request = new Request('https://example.com', { + method: 'POST', + body: '{"redirect_uris":"mastodon://joinmastodon.org/oauth","website":"https://app.joinmastodon.org/ios","client_name":"Mastodon for iOS","scopes":"read write follow push"}', + }) + + const res = await apps.handleRequest(db, request) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const { name, website, redirect_uri, client_id, client_secret, vapid_key, ...rest } = await res.json< + Record + >() + + assert.equal(name, 'Mastodon for iOS') + assert.equal(website, 'https://app.joinmastodon.org/ios') + assert.equal(redirect_uri, 'mastodon://joinmastodon.org/oauth') + assert.deepEqual(rest, {}) + }) + + test('returns 404 for GET request', async () => { + const request = new Request('https://example.com') + const ctx: any = { + next: () => new Response(), + data: null, + env: {}, + request, + } + + const res = await apps.onRequest(ctx) + assert.equal(res.status, 400) + }) + }) + + describe('custom emojis', () => { + test('returns an empty array', async () => { + const res = await custom_emojis.onRequest() + assert.equal(res.status, 200) + assertJSON(res) + assertCORS(res) + assertCache(res, 300) + + const data = await res.json() + assert.equal(data.length, 0) + }) + }) + + describe('subscriptions', () => { + test('get non existing subscription', async () => { + const db = await makeDB() + const req = new Request('https://example.com') + const client = await createTestClient(db) + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const res = await subscription.handleGetRequest(db, req, connectedActor, client.id) + assert.equal(res.status, 404) + }) + + test('get existing subscription', async () => { + const db = await makeDB() + const req = new Request('https://example.com') + const client = await createTestClient(db) + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: {}, + policy: 'all', + }, + } + await createSubscription(db, connectedActor, client, data) + + const res = await subscription.handleGetRequest(db, req, connectedActor, client.id) + assert.equal(res.status, 200) + + const out = await res.json() + assert.equal(typeof out.id, 'number') + assert.equal(out.endpoint, data.subscription.endpoint) + }) + + test('create subscription', async () => { + const db = await makeDB() + const client = await createTestClient(db) + await generateVAPIDKeys(db) + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: {}, + policy: 'all', + }, + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(data), + }) + + const res = await subscription.handlePostRequest(db, req, connectedActor, client.id) + assert.equal(res.status, 200) + + const row: any = await db.prepare('SELECT * FROM subscriptions').first() + assert.equal(row.actor_id, connectedActor.id.toString()) + assert.equal(row.client_id, client.id) + assert.equal(row.endpoint, data.subscription.endpoint) + }) + + test('create subscriptions only creates one', async () => { + const db = await makeDB() + const client = await createTestClient(db) + await generateVAPIDKeys(db) + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const data: any = { + subscription: { + endpoint: 'https://endpoint.com', + keys: { + p256dh: 'p256dh', + auth: 'auth', + }, + }, + data: { + alerts: {}, + policy: 'all', + }, + } + await createSubscription(db, connectedActor, client, data) + + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(data), + }) + + const res = await subscription.handlePostRequest(db, req, connectedActor, client.id) + assert.equal(res.status, 200) + + const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first() + assert.equal(count, 1) + }) + }) +}) diff --git a/backend/test/mastodon/accounts.spec.ts b/backend/test/mastodon/accounts.spec.ts new file mode 100644 index 0000000..574770f --- /dev/null +++ b/backend/test/mastodon/accounts.spec.ts @@ -0,0 +1,883 @@ +import { strict as assert } from 'node:assert/strict' +import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config' +import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import * as accounts_following from 'wildebeest/functions/api/v1/accounts/[id]/following' +import * as accounts_featured_tags from 'wildebeest/functions/api/v1/accounts/[id]/featured_tags' +import * as accounts_lists from 'wildebeest/functions/api/v1/accounts/[id]/lists' +import * as accounts_relationships from 'wildebeest/functions/api/v1/accounts/relationships' +import * as accounts_followers from 'wildebeest/functions/api/v1/accounts/[id]/followers' +import * as accounts_follow from 'wildebeest/functions/api/v1/accounts/[id]/follow' +import * as accounts_unfollow from 'wildebeest/functions/api/v1/accounts/[id]/unfollow' +import * as accounts_statuses from 'wildebeest/functions/api/v1/accounts/[id]/statuses' +import * as accounts_get from 'wildebeest/functions/api/v1/accounts/[id]' +import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache, streamToArrayBuffer } from '../utils' +import * as accounts_verify_creds from 'wildebeest/functions/api/v1/accounts/verify_credentials' +import * as accounts_update_creds from 'wildebeest/functions/api/v1/accounts/update_credentials' +import { createPerson, getPersonById } from 'wildebeest/backend/src/activitypub/actors' +import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' +import { insertLike } from 'wildebeest/backend/src/mastodon/like' +import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog' + +const userKEK = 'test_kek2' +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('accounts', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/sven', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/sven') { + return new Response( + JSON.stringify({ + id: 'sven@remote.com', + type: 'Person', + preferredUsername: 'sven', + name: 'sven ssss', + + icon: { url: 'icon.jpg' }, + image: { url: 'image.jpg' }, + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + }) + + test('missing identity', async () => { + const data = { + cloudflareAccess: { + JWT: { + getIdentity() { + return null + }, + }, + }, + } + + const context: any = { data } + const res = await accounts_verify_creds.onRequest(context) + assert.equal(res.status, 401) + }) + + test('verify the credentials', async () => { + const db = await makeDB() + const connectedActor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + name: 'foo', + } + + const context: any = { data: { connectedActor }, env: { DATABASE: db } } + const res = await accounts_verify_creds.onRequest(context) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json() + assert.equal(data.display_name, 'foo') + // Mastodon app expects the id to be a number (as string), it uses + // it to construct an URL. ActivityPub uses URL as ObjectId so we + // make sure we don't return the URL. + assert(!isUrlValid(data.id)) + }) + + test('update credentials', async () => { + const db = await makeDB() + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const updates = new FormData() + updates.set('display_name', 'newsven') + updates.set('note', 'hein') + + const req = new Request('https://example.com', { + method: 'PATCH', + body: updates, + }) + const res = await accounts_update_creds.handleRequest( + db, + req, + connectedActor, + 'CF_ACCOUNT_ID', + 'CF_API_TOKEN', + userKEK + ) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.display_name, 'newsven') + assert.equal(data.note, 'hein') + + const updatedActor: any = await getPersonById(db, connectedActor.id) + assert(updatedActor) + assert.equal(updatedActor.name, 'newsven') + assert.equal(updatedActor.summary, 'hein') + }) + + test('update credentials sends update', async () => { + const db = await makeDB() + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') } + await addFollowing(db, actor2, connectedActor, 'sven2@' + domain) + await acceptFollowing(db, actor2, connectedActor) + + let receivedActivity: any = null + + globalThis.fetch = async (input: any) => { + if (input.url.toString() === `https://${domain}/ap/users/sven2/inbox`) { + assert.equal(input.method, 'POST') + receivedActivity = await input.json() + return new Response('') + } + + throw new Error('unexpected request to ' + input.url) + } + + const updates = new FormData() + updates.set('display_name', 'newsven') + + const req = new Request('https://example.com', { + method: 'PATCH', + body: updates, + }) + const res = await accounts_update_creds.handleRequest( + db, + req, + connectedActor, + 'CF_ACCOUNT_ID', + 'CF_API_TOKEN', + userKEK + ) + assert.equal(res.status, 200) + + assert(receivedActivity) + assert.equal(receivedActivity.type, 'Update') + assert.equal(receivedActivity.object.id.toString(), connectedActor.id.toString()) + assert.equal(receivedActivity.object.name, 'newsven') + }) + + test('update credentials avatar and header', async () => { + globalThis.fetch = async (input: RequestInfo, data: any) => { + if (input === 'https://api.cloudflare.com/client/v4/accounts/CF_ACCOUNT_ID/images/v1') { + assert.equal(data.method, 'POST') + const file: any = data.body.get('file') + return new Response( + JSON.stringify({ + success: true, + result: { + variants: ['https://example.com/' + file.name], + }, + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const updates = new FormData() + updates.set('avatar', new File(['bytes'], 'selfie.jpg', { type: 'image/jpeg' })) + updates.set('header', new File(['bytes2'], 'mountain.jpg', { type: 'image/jpeg' })) + + const req = new Request('https://example.com', { + method: 'PATCH', + body: updates, + }) + const res = await accounts_update_creds.handleRequest( + db, + req, + connectedActor, + 'CF_ACCOUNT_ID', + 'CF_API_TOKEN', + userKEK + ) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.avatar, 'https://example.com/selfie.jpg') + assert.equal(data.header, 'https://example.com/mountain.jpg') + }) + + test('get remote actor by id', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asven%40social.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/someone', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/someone') { + return new Response( + JSON.stringify({ + id: 'https://social.com/someone', + url: 'https://social.com/@someone', + type: 'Person', + preferredUsername: 'sven', + outbox: 'https://social.com/someone/outbox', + following: 'https://social.com/someone/following', + followers: 'https://social.com/someone/followers', + }) + ) + } + + if (input.toString() === 'https://social.com/someone/following') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://social.com/someone/following', + type: 'OrderedCollection', + totalItems: 123, + first: 'https://social.com/someone/following/page', + }) + ) + } + + if (input.toString() === 'https://social.com/someone/followers') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://social.com/someone/followers', + type: 'OrderedCollection', + totalItems: 321, + first: 'https://social.com/someone/followers/page', + }) + ) + } + + if (input.toString() === 'https://social.com/someone/outbox') { + return new Response( + JSON.stringify({ + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'https://social.com/someone/outbox', + type: 'OrderedCollection', + totalItems: 890, + first: 'https://social.com/someone/outbox/page', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + const res = await accounts_get.handleRequest(domain, 'sven@social.com', db) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.username, 'sven') + assert.equal(data.acct, 'sven@social.com') + + assert(isUrlValid(data.url)) + assert(data.url, 'https://social.com/@someone') + + assert.equal(data.followers_count, 321) + assert.equal(data.following_count, 123) + assert.equal(data.statuses_count, 890) + }) + + test('get unknown local actor by id', async () => { + const db = await makeDB() + const res = await accounts_get.handleRequest(domain, 'sven', db) + assert.equal(res.status, 404) + }) + + test('get local actor by id', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') } + const actor3: any = { id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') } + await addFollowing(db, actor, actor2, 'sven2@' + domain) + await acceptFollowing(db, actor, actor2) + await addFollowing(db, actor, actor3, 'sven3@' + domain) + await acceptFollowing(db, actor, actor3) + await addFollowing(db, actor3, actor, 'sven@' + domain) + await acceptFollowing(db, actor3, actor) + + const firstNote = await createPublicNote(domain, db, 'my first status', actor) + await addObjectInOutbox(db, actor, firstNote) + + const res = await accounts_get.handleRequest(domain, 'sven', db) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.username, 'sven') + assert.equal(data.acct, 'sven') + assert.equal(data.followers_count, 1) + assert.equal(data.following_count, 2) + assert.equal(data.statuses_count, 1) + assert(isUrlValid(data.url)) + assert(data.url.includes(domain)) + }) + + test('get local actor statuses', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + + const firstNote = await createPublicNote(domain, db, 'my first status', actor) + await addObjectInOutbox(db, actor, firstNote) + await insertLike(db, actor, firstNote) + await sleep(10) + const secondNode = await createPublicNote(domain, db, 'my second status', actor) + await addObjectInOutbox(db, actor, secondNode) + await insertReblog(db, actor, secondNode) + + const req = new Request('https://' + domain) + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 2) + + assert.equal(data[0].content, 'my second status') + assert.equal(data[0].account.acct, 'sven@' + domain) + assert.equal(data[0].favourites_count, 0) + assert.equal(data[0].reblogs_count, 1) + + assert.equal(data[1].content, 'my first status') + assert.equal(data[1].favourites_count, 1) + assert.equal(data[1].reblogs_count, 0) + }) + + test('get pinned statuses', async () => { + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const req = new Request('https://' + domain + '?pinned=true') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 0) + }) + + test('get local actor statuses with max_id', async () => { + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + await db + .prepare("INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id')") + .bind('object1', 'Note', JSON.stringify({ content: 'my first status' })) + .run() + await db + .prepare("INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id2')") + .bind('object2', 'Note', JSON.stringify({ content: 'my second status' })) + .run() + await db + .prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)') + .bind('outbox1', actorId.toString(), 'object1', '2022-12-16 08:14:48') + .run() + await db + .prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)') + .bind('outbox2', actorId.toString(), 'object2', '2022-12-16 10:14:48') + .run() + + { + // Query statuses after object1, should only see object2. + const req = new Request('https://' + domain + '?max_id=object1') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].content, 'my second status') + assert.equal(data[0].account.acct, 'sven@' + domain) + } + + { + // Query statuses after object2, nothing is after. + const req = new Request('https://' + domain + '?max_id=object2') + const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 0) + } + }) + + test('get remote actor statuses', async () => { + const db = await makeDB() + await configure(db, { title: 'title', description: 'a', email: 'email' }) + await generateVAPIDKeys(db) + + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const localNote = await createPublicNote(domain, db, 'my localnote status', actor) + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/someone', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/someone') { + return new Response( + JSON.stringify({ + id: 'https://social.com/someone', + type: 'Person', + preferredUsername: 'someone', + outbox: 'https://social.com/outbox', + }) + ) + } + + if (input.toString() === 'https://mastodon.social/users/someone') { + return new Response( + JSON.stringify({ + id: 'https://mastodon.social/users/someone', + type: 'Person', + }) + ) + } + + if (input.toString() === 'https://social.com/outbox') { + return new Response( + JSON.stringify({ + first: 'https://social.com/outbox/page1', + }) + ) + } + + if (input.toString() === 'https://social.com/outbox/page1') { + return new Response( + JSON.stringify({ + orderedItems: [ + { + id: 'https://mastodon.social/users/a/statuses/b/activity', + type: 'Create', + actor: 'https://mastodon.social/users/someone', + published: '2022-12-10T23:48:38Z', + object: { + id: 'https://example.com/object1', + type: 'Note', + content: '

p

', + }, + }, + { + id: 'https://mastodon.social/users/c/statuses/d/activity', + type: 'Announce', + actor: 'https://mastodon.social/users/someone', + published: '2022-12-10T23:48:38Z', + object: localNote.id, + }, + ], + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const req = new Request('https://example.com') + const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com', userKEK) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].content, '

p

') + assert.equal(data[0].account.username, 'someone') + + // Statuses were imported locally and once was a reblog of an already + // existing local object. + const row = await db.prepare(`SELECT count(*) as count FROM objects`).first() + assert.equal(row.count, 2) + }) + + test('get remote actor statuses ignoring object that fail to download', async () => { + const db = await makeDB() + await generateVAPIDKeys(db) + + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const localNote = await createPublicNote(domain, db, 'my localnote status', actor) + + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/someone', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/someone') { + return new Response( + JSON.stringify({ + id: 'https://social.com/someone', + type: 'Person', + preferredUsername: 'someone', + outbox: 'https://social.com/outbox', + }) + ) + } + + if (input.toString() === 'https://social.com/outbox') { + return new Response( + JSON.stringify({ + first: 'https://social.com/outbox/page1', + }) + ) + } + + if (input.toString() === 'https://nonexistingobject.com/') { + return new Response('', { status: 400 }) + } + + if (input.toString() === 'https://social.com/outbox/page1') { + return new Response( + JSON.stringify({ + orderedItems: [ + { + id: 'https://mastodon.social/users/c/statuses/d/activity', + type: 'Announce', + actor: 'https://mastodon.social/users/someone', + published: '2022-12-10T23:48:38Z', + object: 'https://nonexistingobject.com', + }, + ], + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const req = new Request('https://example.com') + const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com', userKEK) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 0) + }) + + test('get remote actor followers', async () => { + const db = await makeDB() + const connectedActor: any = { id: 'someid' } + const req = new Request(`https://${domain}`) + const res = await accounts_followers.handleRequest(req, db, 'sven@example.com', connectedActor) + assert.equal(res.status, 403) + }) + + test('get local actor followers', async () => { + globalThis.fetch = async (input: any, opts: any) => { + if (input.toString() === 'https://' + domain + '/ap/users/sven2') { + return new Response( + JSON.stringify({ + id: 'https://example.com/actor', + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor2, actor, 'sven@' + domain) + await acceptFollowing(db, actor2, actor) + + const connectedActor = actor + const req = new Request(`https://${domain}`) + const res = await accounts_followers.handleRequest(req, db, 'sven', connectedActor) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + }) + + test('get local actor following', async () => { + globalThis.fetch = async (input: any, opts: any) => { + if (input.toString() === 'https://' + domain + '/ap/users/sven2') { + return new Response( + JSON.stringify({ + id: 'https://example.com/foo', + type: 'Person', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor, actor2, 'sven@' + domain) + await acceptFollowing(db, actor, actor2) + + const connectedActor = actor + const req = new Request(`https://${domain}`) + const res = await accounts_following.handleRequest(req, db, 'sven', connectedActor) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + }) + + test('get remote actor following', async () => { + const db = await makeDB() + + const connectedActor: any = { id: 'someid' } + const req = new Request(`https://${domain}`) + const res = await accounts_following.handleRequest(req, db, 'sven@example.com', connectedActor) + assert.equal(res.status, 403) + }) + + test('get remote actor featured_tags', async () => { + const res = await accounts_featured_tags.onRequest() + assert.equal(res.status, 200) + }) + + test('get remote actor lists', async () => { + const res = await accounts_lists.onRequest() + assert.equal(res.status, 200) + }) + + describe('relationships', () => { + test('relationships missing ids', async () => { + const db = await makeDB() + const connectedActor: any = { id: 'someid' } + const req = new Request('https://mastodon.example/api/v1/accounts/relationships') + const res = await accounts_relationships.handleRequest(req, db, connectedActor) + assert.equal(res.status, 400) + }) + + test('relationships with ids', async () => { + const db = await makeDB() + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first&id[]=second') + const connectedActor: any = { id: 'someid' } + const res = await accounts_relationships.handleRequest(req, db, connectedActor) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json>() + assert.equal(data.length, 2) + assert.equal(data[0].id, 'first') + assert.equal(data[0].following, false) + assert.equal(data[1].id, 'second') + assert.equal(data[1].following, false) + }) + + test('relationships with one id', async () => { + const db = await makeDB() + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first') + const connectedActor: any = { id: 'someid' } + const res = await accounts_relationships.handleRequest(req, db, connectedActor) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].id, 'first') + assert.equal(data[0].following, false) + }) + + test('relationships following', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor, actor2, 'sven2@' + domain) + await acceptFollowing(db, actor, actor2) + + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain) + const res = await accounts_relationships.handleRequest(req, db, actor) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].following, true) + }) + + test('relationships following request', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + await addFollowing(db, actor, actor2, 'sven2@' + domain) + + const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain) + const res = await accounts_relationships.handleRequest(req, db, actor) + assert.equal(res.status, 200) + + const data = await res.json>() + assert.equal(data.length, 1) + assert.equal(data[0].requested, true) + assert.equal(data[0].following, false) + }) + }) + + test('follow local account', async () => { + const db = await makeDB() + + const connectedActor: any = { + id: 'connectedActor', + } + + const req = new Request('https://example.com', { method: 'POST' }) + const res = await accounts_follow.handleRequest(req, db, 'localuser', connectedActor, userKEK) + assert.equal(res.status, 403) + }) + + describe('follow', () => { + let receivedActivity: any = null + + beforeEach(() => { + receivedActivity = null + + globalThis.fetch = async (input: any, opts: any) => { + if ( + input.toString() === + 'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + '' + ) { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/sven', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/sven') { + return new Response( + JSON.stringify({ + id: `https://${domain}/ap/users/actor`, + type: 'Person', + inbox: 'https://example.com/inbox', + }) + ) + } + + if (input.url === 'https://example.com/inbox') { + assert.equal(input.method, 'POST') + receivedActivity = await input.json() + return new Response('') + } + + throw new Error('unexpected request to ' + input) + } + }) + + test('follow account', async () => { + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const connectedActor: any = { + id: actorId, + } + + const req = new Request('https://example.com', { method: 'POST' }) + const res = await accounts_follow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + assert(receivedActivity) + assert.equal(receivedActivity.type, 'Follow') + + const row = await db + .prepare(`SELECT target_actor_acct, target_actor_id, state FROM actor_following WHERE actor_id=?`) + .bind(actorId.toString()) + .first() + assert(row) + assert.equal(row.target_actor_acct, 'actor@' + domain) + assert.equal(row.target_actor_id, `https://${domain}/ap/users/actor`) + assert.equal(row.state, 'pending') + }) + + test('unfollow account', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const follower: any = { + id: await createPerson(domain, db, userKEK, 'actor@cloudflare.com'), + } + await addFollowing(db, actor, follower, 'not needed') + + const connectedActor: any = actor + + const req = new Request('https://example.com', { method: 'POST' }) + const res = await accounts_unfollow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + assert(receivedActivity) + assert.equal(receivedActivity.type, 'Undo') + assert.equal(receivedActivity.object.type, 'Follow') + + const row = await db + .prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`) + .bind(actor.id.toString()) + .first() + assert(row) + assert.equal(row.count, 0) + }) + }) + }) +}) diff --git a/backend/test/mastodon/media.spec.ts b/backend/test/mastodon/media.spec.ts new file mode 100644 index 0000000..489729b --- /dev/null +++ b/backend/test/mastodon/media.spec.ts @@ -0,0 +1,58 @@ +import * as media from 'wildebeest/functions/api/v2/media' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { strict as assert } from 'node:assert/strict' +import { makeDB, assertJSON, isUrlValid } from '../utils' +import * as objects from 'wildebeest/backend/src/activitypub/objects' + +const userKEK = 'test_kek10' +const CF_ACCOUNT_ID = 'testaccountid' +const CF_API_TOKEN = 'testtoken' +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('media', () => { + test('upload image creates object', async () => { + globalThis.fetch = async (input: RequestInfo, data: any) => { + if (input === 'https://api.cloudflare.com/client/v4/accounts/testaccountid/images/v1') { + return new Response( + JSON.stringify({ + success: true, + result: { + id: 'abcd', + variants: ['https://example.com/' + file.name], + }, + }) + ) + } + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const file = new File(['abc'], 'image.jpeg', { type: 'image/jpeg' }) + + const body = new FormData() + body.set('file', file) + + const req = new Request('https://example.com/api/v2/media', { + method: 'POST', + body, + }) + const res = await media.handleRequest(req, db, connectedActor, CF_ACCOUNT_ID, CF_API_TOKEN) + assert.equal(res.status, 200) + assertJSON(res) + + const data = await res.json() + assert(!isUrlValid(data.id)) + assert(isUrlValid(data.url)) + assert(isUrlValid(data.preview_url)) + + const obj = await objects.getObjectByMastodonId(db, data.id) + assert(obj) + assert(obj.mastodonId) + assert.equal(obj.type, 'Image') + assert.equal(obj.originalActorId, connectedActor.id.toString()) + }) + }) +}) diff --git a/backend/test/mastodon/notifications.spec.ts b/backend/test/mastodon/notifications.spec.ts new file mode 100644 index 0000000..867eb9c --- /dev/null +++ b/backend/test/mastodon/notifications.spec.ts @@ -0,0 +1,154 @@ +import * as notifications_get from 'wildebeest/functions/api/v1/notifications/[id]' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { createNotification, insertFollowNotification } from 'wildebeest/backend/src/mastodon/notification' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import * as notifications from 'wildebeest/functions/api/v1/notifications' +import { makeDB, assertJSON, assertCORS, createTestClient } from '../utils' +import { strict as assert } from 'node:assert/strict' +import { sendLikeNotification } from 'wildebeest/backend/src/mastodon/notification' +import { createSubscription } from 'wildebeest/backend/src/mastodon/subscription' +import { generateVAPIDKeys, configure } from 'wildebeest/backend/src/config' +import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops' +import { getNotifications } from 'wildebeest/backend/src/mastodon/notification' + +const userKEK = 'test_kek15' +const domain = 'cloudflare.com' +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +function parseCryptoKey(s: string): any { + const parts = s.split(';') + const out: any = {} + for (let i = 0, len = parts.length; i < len; i++) { + const parts2 = parts[i].split('=') + out[parts2[0]] = parts2[1] + } + + return out +} + +describe('Mastodon APIs', () => { + describe('notifications', () => { + test('returns notifications stored in KV cache', async () => { + const connectedActor: any = { id: 'id' } + const kv_cache: any = { + async get(key: string) { + assert.equal(key, 'id/notifications') + return 'cached data' + }, + } + const req = new Request('https://' + domain) + const data = await notifications.handleRequest(req, kv_cache, connectedActor) + assert.equal(await data.text(), 'cached data') + }) + + test('returns notifications stored in db', async () => { + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const fromActorId = await createPerson(domain, db, userKEK, 'from@cloudflare.com') + + const connectedActor: any = { + id: actorId, + } + const note = await createPublicNote(domain, db, 'my first status', connectedActor) + const fromActor: any = { + id: fromActorId, + } + await insertFollowNotification(db, connectedActor, fromActor) + await sleep(10) + await createNotification(db, 'favourite', connectedActor, fromActor, note) + await sleep(10) + await createNotification(db, 'mention', connectedActor, fromActor, note) + + const notifications: any = await getNotifications(db, connectedActor) + + assert.equal(notifications[0].type, 'mention') + assert.equal(notifications[0].account.username, 'from') + assert.equal(notifications[0].status.id, note.mastodonId) + + assert.equal(notifications[1].type, 'favourite') + assert.equal(notifications[1].account.username, 'from') + assert.equal(notifications[1].status.id, note.mastodonId) + assert.equal(notifications[1].status.account.username, 'sven') + + assert.equal(notifications[2].type, 'follow') + assert.equal(notifications[2].account.username, 'from') + assert.equal(notifications[2].status, undefined) + }) + + test('get single non existant notification', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const fromActor: any = { id: await createPerson(domain, db, userKEK, 'from@cloudflare.com') } + const note = await createPublicNote(domain, db, 'my first status', actor) + await createNotification(db, 'favourite', actor, fromActor, note) + + const res = await notifications_get.handleRequest(domain, '1', db, actor) + + assert.equal(res.status, 200) + assertJSON(res) + + const data = await res.json() + assert.equal(data.id, '1') + assert.equal(data.type, 'favourite') + assert.equal(data.account.acct, 'from@cloudflare.com') + assert.equal(data.status.content, 'my first status') + }) + + test('send like notification', async () => { + const db = await makeDB() + await generateVAPIDKeys(db) + await configure(db, { title: 'title', description: 'a', email: 'email' }) + + const clientKeys = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [ + 'sign', + 'verify', + ])) as CryptoKeyPair + + globalThis.fetch = async (input: RequestInfo, data: any) => { + if (input === 'https://push.com') { + assert(data.headers['Authorization'].includes('WebPush')) + + const cryptoKeyHeader = parseCryptoKey(data.headers['Crypto-Key']) + assert(cryptoKeyHeader.dh) + assert(cryptoKeyHeader.p256ecdsa) + + // Ensure the data has a valid signature using the client public key + const sign = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, clientKeys.privateKey, data.body) + assert(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, clientKeys.publicKey, sign, data.body)) + + // TODO: eventually decrypt what the server pushed + + return new Response() + } + throw new Error('unexpected request to ' + input) + } + + const client = await createTestClient(db) + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const p256dh = arrayBufferToBase64((await crypto.subtle.exportKey('raw', clientKeys.publicKey)) as ArrayBuffer) + const auth = arrayBufferToBase64(crypto.getRandomValues(new Uint8Array(16))) + + await createSubscription(db, actor, client, { + subscription: { + endpoint: 'https://push.com', + keys: { + p256dh, + auth, + }, + }, + data: { + alerts: {}, + policy: 'all', + }, + }) + + const fromActor: any = { + id: await createPerson(domain, db, userKEK, 'from@cloudflare.com'), + icon: { url: 'icon.com' }, + } + + await sendLikeNotification(db, fromActor, actor, 'notifid') + }) + }) +}) diff --git a/backend/test/mastodon/oauth.spec.ts b/backend/test/mastodon/oauth.spec.ts new file mode 100644 index 0000000..2a2b5f1 --- /dev/null +++ b/backend/test/mastodon/oauth.spec.ts @@ -0,0 +1,220 @@ +import { getSigningKey } from 'wildebeest/backend/src/mastodon/account' +import * as oauth_authorize from 'wildebeest/functions/oauth/authorize' +import * as first_login from 'wildebeest/functions/first-login' +import * as oauth_token from 'wildebeest/functions/oauth/token' +import { + isUrlValid, + makeDB, + assertCORS, + assertJSON, + assertCache, + streamToArrayBuffer, + createTestClient, +} from '../utils' +import { TEST_JWT, ACCESS_CERTS } from '../test-data' +import { strict as assert } from 'node:assert/strict' +import { configureAccess } from 'wildebeest/backend/src/config/index' + +const userKEK = 'test_kek3' +const accessDomain = 'access.com' +const accessAud = 'abcd' + +describe('Mastodon APIs', () => { + describe('oauth', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') { + return new Response(JSON.stringify(ACCESS_CERTS)) + } + + if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') { + return new Response( + JSON.stringify({ + email: 'some@cloudflare.com', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + }) + + test('authorize missing params', async () => { + const db = await makeDB() + await configureAccess(db, accessDomain, accessAud) + + let req = new Request('https://example.com/oauth/authorize') + let res = await oauth_authorize.handleRequest(req, db, userKEK) + assert.equal(res.status, 400) + + req = new Request('https://example.com/oauth/authorize?scope=foobar') + res = await oauth_authorize.handleRequest(req, db, userKEK) + assert.equal(res.status, 400) + }) + + test('authorize unsupported response_type', async () => { + const db = await makeDB() + await configureAccess(db, accessDomain, accessAud) + + const params = new URLSearchParams({ + redirect_uri: 'https://example.com', + response_type: 'hein', + client_id: 'client_id', + }) + + const req = new Request('https://example.com/oauth/authorize?' + params) + const res = await oauth_authorize.handleRequest(req, db, userKEK) + assert.equal(res.status, 400) + }) + + test("authorize redirect_uri doesn't match client redirect_uris", async () => { + const db = await makeDB() + const client = await createTestClient(db, 'https://redirect.com') + await configureAccess(db, accessDomain, accessAud) + + const params = new URLSearchParams({ + redirect_uri: 'https://example.com/a', + response_type: 'code', + client_id: client.id, + }) + + const headers = { 'Cf-Access-Jwt-Assertion': TEST_JWT } + + const req = new Request('https://example.com/oauth/authorize?' + params, { + headers, + }) + const res = await oauth_authorize.handleRequest(req, db, userKEK) + assert.equal(res.status, 403) + }) + + test('authorize redirects with code on success and show first login', async () => { + const db = await makeDB() + const client = await createTestClient(db) + await configureAccess(db, accessDomain, accessAud) + + const params = new URLSearchParams({ + redirect_uri: client.redirect_uris, + response_type: 'code', + client_id: client.id, + }) + + const headers = { 'Cf-Access-Jwt-Assertion': TEST_JWT } + + const req = new Request('https://example.com/oauth/authorize?' + params, { + headers, + }) + const res = await oauth_authorize.handleRequest(req, db, userKEK) + assert.equal(res.status, 302) + + const location = new URL(res.headers.get('location') || '') + assert.equal( + location.searchParams.get('redirect_uri'), + encodeURIComponent(`${client.redirect_uris}?code=${client.id}.${TEST_JWT}`) + ) + + // actor isn't created yet + const { count } = await db.prepare('SELECT count(*) as count FROM actors').first() + assert.equal(count, 0) + }) + + test('first login creates the user and redirects', async () => { + const db = await makeDB() + + const params = new URLSearchParams({ + redirect_uri: 'https://redirect.com/a', + email: 'a@cloudflare.com', + }) + + const formData = new FormData() + formData.set('username', 'username') + formData.set('name', 'name') + + const req = new Request('https://example.com/first-login?' + params, { + method: 'POST', + body: formData, + }) + const res = await first_login.handlePostRequest(req, db, userKEK) + assert.equal(res.status, 302) + + const location = res.headers.get('location') + assert.equal(location, 'https://redirect.com/a') + + const actor = await db.prepare('SELECT * FROM actors').first() + const properties = JSON.parse(actor.properties) + + assert.equal(actor.email, 'a@cloudflare.com') + assert.equal(properties.preferredUsername, 'username') + assert.equal(properties.name, 'name') + assert(isUrlValid(actor.id)) + // ensure that we generate a correct key pairs for the user + assert((await getSigningKey(userKEK, db, actor)) instanceof CryptoKey) + }) + + test('token error on unknown client', async () => { + const db = await makeDB() + const body = { code: 'some-code' } + + const req = new Request('https://example.com/oauth/token', { + method: 'POST', + body: JSON.stringify(body), + }) + const res = await oauth_token.handleRequest(db, req) + assert.equal(res.status, 403) + }) + + test('token returns auth infos', async () => { + const db = await makeDB() + const testScope = 'test abcd' + const client = await createTestClient(db, 'https://localhost', testScope) + + const body = { + code: client.id + '.some-code', + } + + const req = new Request('https://example.com/oauth/token', { + method: 'POST', + body: JSON.stringify(body), + }) + const res = await oauth_token.handleRequest(db, req) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + const data = await res.json() + assert.equal(data.access_token, body.code) + assert.equal(data.scope, testScope) + }) + + test('token handles empty code', async () => { + const db = await makeDB() + const body = { code: '' } + + const req = new Request('https://example.com/oauth/token', { + method: 'POST', + body: JSON.stringify(body), + }) + const res = await oauth_token.handleRequest(db, req) + assert.equal(res.status, 401) + }) + + test('token returns CORS', async () => { + const db = await makeDB() + const req = new Request('https://example.com/oauth/token', { + method: 'OPTIONS', + }) + const res = await oauth_token.handleRequest(db, req) + assert.equal(res.status, 200) + assertCORS(res) + }) + + test('authorize returns CORS', async () => { + const db = await makeDB() + const req = new Request('https://example.com/oauth/authorize', { + method: 'OPTIONS', + }) + const res = await oauth_authorize.handleRequest(req, db, userKEK) + assert.equal(res.status, 200) + assertCORS(res) + }) + }) +}) diff --git a/backend/test/mastodon/search.spec.ts b/backend/test/mastodon/search.spec.ts new file mode 100644 index 0000000..2cfd560 --- /dev/null +++ b/backend/test/mastodon/search.spec.ts @@ -0,0 +1,172 @@ +import * as search from 'wildebeest/functions/api/v2/search' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { defaultImages } from 'wildebeest/config/accounts' +import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache } from '../utils' +import { strict as assert } from 'node:assert/strict' + +const userKEK = 'test_kek11' +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('search', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/sven', + }, + ], + }) + ) + } + + if ( + input.toString() === + 'https://remote.com/.well-known/webfinger?resource=acct%3Adefault-avatar-and-header%40remote.com' + ) { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/default-avatar-and-header', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/sven') { + return new Response( + JSON.stringify({ + id: 'https://social.com/sven', + type: 'Person', + preferredUsername: 'sven', + name: 'sven ssss', + + icon: { url: 'icon.jpg' }, + image: { url: 'image.jpg' }, + }) + ) + } + + if (input.toString() === 'https://social.com/default-avatar-and-header') { + return new Response( + JSON.stringify({ + id: 'https://social.com/default-avatar-and-header', + type: 'Person', + preferredUsername: 'sven', + name: 'sven ssss', + }) + ) + } + + throw new Error(`unexpected request to "${input}"`) + } + }) + + test('no query returns an error', async () => { + const db = await makeDB() + const req = new Request('https://example.com/api/v2/search') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 400) + }) + + test('empty results', async () => { + const db = await makeDB() + const req = new Request('https://example.com/api/v2/search?q=non-existing-local-user') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 200) + assertJSON(res) + assertCORS(res) + + const data = await res.json() + assert.equal(data.accounts.length, 0) + assert.equal(data.statuses.length, 0) + assert.equal(data.hashtags.length, 0) + }) + + test('queries WebFinger when remote account', async () => { + const db = await makeDB() + const req = new Request('https://example.com/api/v2/search?q=@sven@remote.com&resolve=true') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 200) + assertJSON(res) + assertCORS(res) + + const data = await res.json() + assert.equal(data.accounts.length, 1) + assert.equal(data.statuses.length, 0) + assert.equal(data.hashtags.length, 0) + + const account = data.accounts[0] + assert.equal(account.id, 'sven@remote.com') + assert.equal(account.username, 'sven') + assert.equal(account.acct, 'sven@remote.com') + }) + + test('queries WebFinger when remote account with default avatar / header', async () => { + const db = await makeDB() + const req = new Request('https://example.com/api/v2/search?q=@default-avatar-and-header@remote.com&resolve=true') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 200) + assertJSON(res) + assertCORS(res) + + const data = await res.json() + assert.equal(data.accounts.length, 1) + assert.equal(data.statuses.length, 0) + assert.equal(data.hashtags.length, 0) + + const account = data.accounts[0] + assert.equal(account.avatar, defaultImages.avatar) + assert.equal(account.header, defaultImages.header) + }) + + test("don't queries WebFinger when resolve is set to false", async () => { + const db = await makeDB() + globalThis.fetch = () => { + throw new Error('unreachable') + } + + const req = new Request('https://example.com/api/v2/search?q=@sven@remote.com&resolve=false') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 200) + assertJSON(res) + assertCORS(res) + }) + + test('search local actors', async () => { + const db = await makeDB() + await createPerson(domain, db, userKEK, 'username@cloudflare.com', { name: 'foo' }) + await createPerson(domain, db, userKEK, 'username2@cloudflare.com', { name: 'bar' }) + + { + const req = new Request('https://example.com/api/v2/search?q=foo&resolve=false') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.accounts.length, 1) + assert.equal(data.accounts[0].display_name, 'foo') + } + + { + const req = new Request('https://example.com/api/v2/search?q=user&resolve=false') + const res = await search.handleRequest(db, req) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.accounts.length, 2) + assert.equal(data.accounts[0].display_name, 'foo') + assert.equal(data.accounts[1].display_name, 'bar') + } + }) + }) +}) diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts new file mode 100644 index 0000000..97c4109 --- /dev/null +++ b/backend/test/mastodon/statuses.spec.ts @@ -0,0 +1,487 @@ +import { strict as assert } from 'node:assert/strict' +import { insertReply } from 'wildebeest/backend/src/mastodon/reply' +import { getMentions } from 'wildebeest/backend/src/mastodon/status' +import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { createImage } from 'wildebeest/backend/src/activitypub/objects/image' +import * as statuses from 'wildebeest/functions/api/v1/statuses' +import * as statuses_get from 'wildebeest/functions/api/v1/statuses/[id]' +import * as statuses_favourite from 'wildebeest/functions/api/v1/statuses/[id]/favourite' +import * as statuses_reblog from 'wildebeest/functions/api/v1/statuses/[id]/reblog' +import * as statuses_context from 'wildebeest/functions/api/v1/statuses/[id]/context' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { insertLike } from 'wildebeest/backend/src/mastodon/like' +import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog' +import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache, streamToArrayBuffer } from '../utils' +import * as note from 'wildebeest/backend/src/activitypub/objects/note' + +const userKEK = 'test_kek4' +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('statuses', () => { + test('create new status missing params', async () => { + const db = await makeDB() + + const body = { status: 'my status' } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(body), + }) + + const connectedActor: any = {} + const res = await statuses.handleRequest(req, db, connectedActor, userKEK) + assert.equal(res.status, 400) + }) + + test('create new status creates Note', async () => { + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const body = { + status: 'my status', + visibility: 'public', + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(body), + }) + + const connectedActor: any = { id: actorId } + const res = await statuses.handleRequest(req, db, connectedActor, userKEK) + assert.equal(res.status, 200) + assertJSON(res) + + const data = await res.json() + assert(data.uri.includes('example.com')) + assert(data.uri.includes(data.id)) + // Required fields from https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java + assert(data.created_at !== undefined) + assert(data.account !== undefined) + assert(data.visibility !== undefined) + assert(data.spoiler_text !== undefined) + assert(data.media_attachments !== undefined) + assert(data.mentions !== undefined) + assert(data.tags !== undefined) + assert(data.emojis !== undefined) + assert(!isUrlValid(data.id)) + + const row = await db + .prepare( + ` + SELECT + json_extract(properties, '$.content') as content, + original_actor_id, + original_object_id + FROM objects + ` + ) + .first() + assert.equal(row.content, 'my status') + assert.equal(row.original_actor_id.toString(), actorId.toString()) + assert.equal(row.original_object_id, null) + }) + + test("create new status adds to Actor's outbox", async () => { + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const body = { + status: 'my status', + visibility: 'public', + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(body), + }) + + const connectedActor: any = { id: actorId } + const res = await statuses.handleRequest(req, db, connectedActor, userKEK) + assert.equal(res.status, 200) + + const data = await res.json() + const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first() + assert.equal(row.count, 1) + }) + + test('create new status with mention delivers ActivityPub Note', async () => { + let deliveredNote: any = null + + globalThis.fetch = async (input: RequestInfo, data: any) => { + if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') { + return new Response( + JSON.stringify({ + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: 'https://social.com/sven', + }, + ], + }) + ) + } + + if (input.toString() === 'https://social.com/sven') { + return new Response( + JSON.stringify({ + id: 'https://social.com/sven', + inbox: 'https://social.com/sven/inbox', + }) + ) + } + + if (input === 'https://social.com/sven/inbox') { + assert.equal(data.method, 'POST') + const body = JSON.parse(data.body) + deliveredNote = body + return new Response() + } + + // @ts-ignore: shut up + if (Object.keys(input).includes('url') && input.url === 'https://social.com/sven/inbox') { + const request = input as Request + assert.equal(request.method, 'POST') + const bodyB = await streamToArrayBuffer(request.body as ReadableStream) + const dec = new TextDecoder() + const body = JSON.parse(dec.decode(bodyB)) + deliveredNote = body + return new Response() + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const body = { + status: '@sven@remote.com my status', + visibility: 'public', + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(body), + }) + + const connectedActor: any = { id: actorId, type: 'Person' } + const res = await statuses.handleRequest(req, db, connectedActor, userKEK) + assert.equal(res.status, 200) + + assert(deliveredNote) + assert.equal(deliveredNote.type, 'Create') + assert.equal(deliveredNote.actor, `https://${domain}/ap/users/sven`) + assert.equal(deliveredNote.object.attributedTo, `https://${domain}/ap/users/sven`) + assert.equal(deliveredNote.object.type, 'Note') + assert(deliveredNote.object.to.includes(note.PUBLIC)) + assert.equal(deliveredNote.object.cc.length, 1) + }) + + test('create new status with image', async () => { + const db = await makeDB() + const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const properties = { url: 'foo' } + const image = await createImage(domain, db, connectedActor, properties) + + const body = { + status: 'my status', + media_ids: [image.mastodonId], + visibility: 'public', + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(body), + }) + + const res = await statuses.handleRequest(req, db, connectedActor, userKEK) + assert.equal(res.status, 200) + + const data = await res.json() + + assert(!isUrlValid(data.id)) + }) + + test('favourite status sends Like activity', async () => { + let deliveredActivity: any = null + + const db = await makeDB() + const actor = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const originalObjectId = 'https://example.com/note123' + + await db + .prepare( + 'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)' + ) + .bind( + 'https://example.com/object1', + 'Note', + JSON.stringify({ content: 'my first status' }), + actor.id.toString(), + originalObjectId, + 'mastodonid1' + ) + .run() + + globalThis.fetch = async (input: any, data: any) => { + if (input === actor.id.toString()) { + return new Response( + JSON.stringify({ + id: actor.id, + inbox: 'https://social.com/sven/inbox', + }) + ) + } + + if (input.url === 'https://social.com/sven/inbox') { + assert.equal(input.method, 'POST') + const body = await input.json() + deliveredActivity = body + return new Response() + } + + throw new Error('unexpected request to ' + JSON.stringify(input)) + } + + const connectedActor: any = actor + + const res = await statuses_favourite.handleRequest(db, 'mastodonid1', connectedActor, userKEK) + assert.equal(res.status, 200) + + assert(deliveredActivity) + assert.equal(deliveredActivity.type, 'Like') + assert.equal(deliveredActivity.object, originalObjectId) + }) + + test('favourite records in db', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const note = await createPublicNote(domain, db, 'my first status', actor) + + const connectedActor: any = actor + + const res = await statuses_favourite.handleRequest(db, note.mastodonId!, connectedActor, userKEK) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.favourited, true) + + const row = await db.prepare(`SELECT * FROM actor_favourites`).first() + assert.equal(row.actor_id, actor.id.toString()) + assert.equal(row.object_id, note.id.toString()) + }) + + test('get mentions from status', () => { + { + const mentions = getMentions('test status') + assert.equal(mentions.length, 0) + } + + { + const mentions = getMentions('@sven@instance.horse test status') + assert.equal(mentions.length, 1) + assert.equal(mentions[0].localPart, 'sven') + assert.equal(mentions[0].domain, 'instance.horse') + } + + { + const mentions = getMentions('@sven test status') + assert.equal(mentions.length, 1) + assert.equal(mentions[0].localPart, 'sven') + assert.equal(mentions[0].domain, null) + } + + { + const mentions = getMentions('@sven @james @pete') + assert.deepEqual(mentions, [ + { localPart: 'sven', domain: null }, + { localPart: 'james', domain: null }, + { localPart: 'pete', domain: null }, + ]) + } + + { + const mentions = getMentions('

@sven

') + assert.deepEqual(mentions, [{ localPart: 'sven', domain: null }]) + } + }) + + test('get status count likes', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') } + const actor3: any = { id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') } + const note = await createPublicNote(domain, db, 'my first status', actor) + + await insertLike(db, actor2, note) + await insertLike(db, actor3, note) + + const res = await statuses_get.handleRequest(db, note.mastodonId!) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.favourites_count, 2) + }) + + test('get status with image', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const properties = { url: 'https://example.com/image.jpg' } + const mediaAttachments = [await createImage(domain, db, actor, properties)] + const note = await createPublicNote(domain, db, 'my first status', actor, mediaAttachments) + + const res = await statuses_get.handleRequest(db, note.mastodonId!) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.media_attachments.length, 1) + assert.equal(data.media_attachments[0].url, properties.url) + assert.equal(data.media_attachments[0].preview_url, properties.url) + assert.equal(data.media_attachments[0].type, 'image') + }) + + test('status context shows descendants', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'a post', actor) + await addObjectInOutbox(db, actor, note) + await sleep(10) + + const inReplyTo = note.id + const reply = await createPublicNote(domain, db, 'a reply', actor, [], { inReplyTo }) + await addObjectInOutbox(db, actor, reply) + await sleep(10) + + await insertReply(db, actor, reply, note) + + const res = await statuses_context.handleRequest(domain, db, note.mastodonId!) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.ancestors.length, 0) + assert.equal(data.descendants.length, 1) + assert.equal(data.descendants[0].content, 'a reply') + }) + + describe('reblog', () => { + test('get status count reblogs', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') } + const actor3: any = { id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') } + const note = await createPublicNote(domain, db, 'my first status', actor) + + await insertReblog(db, actor2, note) + await insertReblog(db, actor3, note) + + const res = await statuses_get.handleRequest(db, note.mastodonId!) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.reblogs_count, 2) + }) + + test('reblog records in db', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const note = await createPublicNote(domain, db, 'my first status', actor) + + const connectedActor: any = actor + + const res = await statuses_reblog.handleRequest(db, note.mastodonId!, connectedActor, userKEK) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.reblogged, true) + + const row = await db.prepare(`SELECT * FROM actor_reblogs`).first() + assert.equal(row.actor_id, actor.id.toString()) + assert.equal(row.object_id, note.id.toString()) + }) + + test('reblog status adds in actor outbox', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const originalObjectId = 'https://example.com/note123' + + await db + .prepare( + 'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, mastodon_id, local) VALUES (?, ?, ?, ?, ?, ?, 0)' + ) + .bind( + 'https://example.com/object1', + 'Note', + JSON.stringify({ content: 'my first status' }), + actor.id.toString(), + originalObjectId, + 'mastodonid1' + ) + .run() + + const connectedActor: any = actor + + const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK) + assert.equal(res.status, 200) + + const row = await db.prepare(`SELECT * FROM outbox_objects`).first() + assert.equal(row.actor_id, actor.id.toString()) + assert.equal(row.object_id, 'https://example.com/object1') + }) + + test('reblog remote status status sends Announce activity to author', async () => { + let deliveredActivity: any = null + + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + const originalObjectId = 'https://example.com/note123' + + await db + .prepare( + 'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, mastodon_id, local) VALUES (?, ?, ?, ?, ?, ?, 0)' + ) + .bind( + 'https://example.com/object1', + 'Note', + JSON.stringify({ content: 'my first status' }), + actor.id.toString(), + originalObjectId, + 'mastodonid1' + ) + .run() + + globalThis.fetch = async (input: any, data: any) => { + if (input === actor.id.toString()) { + return new Response( + JSON.stringify({ + id: actor.id, + inbox: 'https://social.com/sven/inbox', + }) + ) + } + + if (input.url === 'https://social.com/sven/inbox') { + assert.equal(input.method, 'POST') + const body = await input.json() + deliveredActivity = body + return new Response() + } + + throw new Error('unexpected request to ' + JSON.stringify(input)) + } + + const connectedActor: any = actor + + const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK) + assert.equal(res.status, 200) + + assert(deliveredActivity) + assert.equal(deliveredActivity.type, 'Announce') + assert.equal(deliveredActivity.actor, actor.id.toString()) + assert.equal(deliveredActivity.object, originalObjectId) + }) + }) + }) +}) diff --git a/backend/test/mastodon/timelines.spec.ts b/backend/test/mastodon/timelines.spec.ts new file mode 100644 index 0000000..942b387 --- /dev/null +++ b/backend/test/mastodon/timelines.spec.ts @@ -0,0 +1,239 @@ +import { strict as assert } from 'node:assert/strict' +import { insertReply } from 'wildebeest/backend/src/mastodon/reply' +import { createImage } from 'wildebeest/backend/src/activitypub/objects/image' +import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' +import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { makeDB, assertCORS, assertJSON, assertCache } from '../utils' +import * as timelines_home from 'wildebeest/functions/api/v1/timelines/home' +import * as timelines_public from 'wildebeest/functions/api/v1/timelines/public' +import * as timelines from 'wildebeest/backend/src/mastodon/timeline' +import { insertLike } from 'wildebeest/backend/src/mastodon/like' +import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog' + +const userKEK = 'test_kek6' +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) +const domain = 'cloudflare.com' + +describe('Mastodon APIs', () => { + describe('timelines', () => { + test('home returns Notes in following Actors', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + const actor3: any = { + id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com'), + } + + // Actor is following actor2, but not actor3. + await addFollowing(db, actor, actor2, 'not needed') + await acceptFollowing(db, actor, actor2) + + // Actor 2 is posting + const firstNoteFromActor2 = await createPublicNote(domain, db, 'first status from actor2', actor2) + await addObjectInOutbox(db, actor2, firstNoteFromActor2) + await sleep(10) + await addObjectInOutbox(db, actor2, await createPublicNote(domain, db, 'second status from actor2', actor2)) + await sleep(10) + await addObjectInOutbox(db, actor3, await createPublicNote(domain, db, 'first status from actor3', actor3)) + await sleep(10) + + await insertLike(db, actor, firstNoteFromActor2) + await insertReblog(db, actor, firstNoteFromActor2) + + // Actor should only see posts from actor2 in the timeline + const connectedActor: any = actor + const data = await timelines.getHomeTimeline(domain, db, connectedActor) + assert.equal(data.length, 2) + assert(data[0].id) + assert.equal(data[0].content, 'second status from actor2') + assert.equal(data[0].account.username, 'sven2') + assert.equal(data[1].content, 'first status from actor2') + assert.equal(data[1].account.username, 'sven2') + assert.equal(data[1].favourites_count, 1) + assert.equal(data[1].reblogs_count, 1) + }) + + test('home returns Notes from ourself', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + + // Actor is posting + await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'status from myself', actor)) + + // Actor should only see posts from actor2 in the timeline + const connectedActor: any = actor + const data = await timelines.getHomeTimeline(domain, db, connectedActor) + assert.equal(data.length, 1) + assert(data[0].id) + assert.equal(data[0].content, 'status from myself') + assert.equal(data[0].account.username, 'sven') + }) + + test('home returns cache', async () => { + const connectedActor: any = { id: 'id' } + const kv_cache: any = { + async get(key: string) { + assert.equal(key, 'id/timeline/home') + return 'cached data' + }, + } + const req = new Request('https://' + domain) + const data = await timelines_home.handleRequest(req, kv_cache, connectedActor) + assert.equal(await data.text(), 'cached data') + }) + + test('public returns Notes', async () => { + const db = await makeDB() + const actor: any = { + id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'), + } + const actor2: any = { + id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'), + } + + const statusFromActor = await createPublicNote(domain, db, 'status from actor', actor) + await addObjectInOutbox(db, actor, statusFromActor) + await sleep(10) + await addObjectInOutbox(db, actor2, await createPublicNote(domain, db, 'status from actor2', actor2)) + + await insertLike(db, actor, statusFromActor) + await insertReblog(db, actor, statusFromActor) + + const res = await timelines_public.handleRequest(domain, db) + assert.equal(res.status, 200) + assertJSON(res) + assertCORS(res) + + const data = await res.json() + assert.equal(data.length, 2) + assert(data[0].id) + assert.equal(data[0].content, 'status from actor2') + assert.equal(data[0].account.username, 'sven2') + assert.equal(data[1].content, 'status from actor') + assert.equal(data[1].account.username, 'sven') + assert.equal(data[1].favourites_count, 1) + assert.equal(data[1].reblogs_count, 1) + + // if we request only remote objects nothing should be returned + const remoteRes = await timelines_public.handleRequest(domain, db, { + local: false, + remote: true, + only_media: false, + }) + assert.equal(remoteRes.status, 200) + assertJSON(remoteRes) + assertCORS(remoteRes) + const remoteData = await remoteRes.json() + assert.equal(remoteData.length, 0) + }) + + test('public includes attachment', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const properties = { url: 'https://example.com/image.jpg' } + const mediaAttachments = [await createImage(domain, db, actor, properties)] + const note = await createPublicNote(domain, db, 'status from actor', actor, mediaAttachments) + await addObjectInOutbox(db, actor, note) + + const res = await timelines_public.handleRequest(domain, db) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data.length, 1) + assert.equal(data[0].media_attachments.length, 1) + assert.equal(data[0].media_attachments[0].type, 'image') + assert.equal(data[0].media_attachments[0].url, properties.url) + }) + + test('public timeline uses published_date', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const note1 = await createPublicNote(domain, db, 'note1', actor) + const note2 = await createPublicNote(domain, db, 'note2', actor) + const note3 = await createPublicNote(domain, db, 'note3', actor) + await addObjectInOutbox(db, actor, note1, '2022-12-10T23:48:38Z') + await addObjectInOutbox(db, actor, note2, '2000-12-10T23:48:38Z') + await addObjectInOutbox(db, actor, note3, '2048-12-10T23:48:38Z') + + const res = await timelines_public.handleRequest(domain, db) + assert.equal(res.status, 200) + + const data = await res.json() + assert.equal(data[0].content, 'note3') + assert.equal(data[1].content, 'note1') + assert.equal(data[2].content, 'note2') + }) + + test('timelines hides and counts replies', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'a post', actor) + await addObjectInOutbox(db, actor, note) + await sleep(10) + + const inReplyTo = note.id + const reply = await createPublicNote(domain, db, 'a reply', actor, [], { inReplyTo }) + await addObjectInOutbox(db, actor, reply) + await sleep(10) + + await insertReply(db, actor, reply, note) + + const connectedActor: any = actor + + { + const data = await timelines.getHomeTimeline(domain, db, connectedActor) + assert.equal(data.length, 1) + assert.equal(data[0].content, 'a post') + assert.equal(data[0].replies_count, 1) + } + + { + const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet) + assert.equal(data.length, 1) + assert.equal(data[0].content, 'a post') + assert.equal(data[0].replies_count, 1) + } + }) + + test('show status reblogged', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'a post', actor) + await addObjectInOutbox(db, actor, note) + await insertReblog(db, actor, note) + + const connectedActor: any = actor + + const data = await timelines.getHomeTimeline(domain, db, connectedActor) + assert.equal(data.length, 1) + assert.equal(data[0].reblogged, true) + }) + + test('show status favourited', async () => { + const db = await makeDB() + const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') } + + const note = await createPublicNote(domain, db, 'a post', actor) + await addObjectInOutbox(db, actor, note) + await insertLike(db, actor, note) + + const connectedActor: any = actor + + const data = await timelines.getHomeTimeline(domain, db, connectedActor) + assert.equal(data.length, 1) + assert.equal(data[0].favourited, true) + }) + }) +}) diff --git a/backend/test/mastodon/trends.spec.ts b/backend/test/mastodon/trends.spec.ts new file mode 100644 index 0000000..4f022cc --- /dev/null +++ b/backend/test/mastodon/trends.spec.ts @@ -0,0 +1,16 @@ +import { strict as assert } from 'node:assert/strict' +import * as trends_statuses from 'wildebeest/functions/api/v1/trends/statuses' +import { makeDB, assertJSON } from '../utils' + +describe('Mastodon APIs', () => { + describe('trends', () => { + test('trending statuses return empty array', async () => { + const res = await trends_statuses.onRequest() + assert.equal(res.status, 200) + assertJSON(res) + + const data = await res.json() + assert.equal(data.length, 0) + }) + }) +}) diff --git a/backend/test/middleware.spec.ts b/backend/test/middleware.spec.ts new file mode 100644 index 0000000..fa1f3ae --- /dev/null +++ b/backend/test/middleware.spec.ts @@ -0,0 +1,120 @@ +import { isUrlValid, makeDB, assertCORS } from './utils' +import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { TEST_JWT, ACCESS_CERTS } from './test-data' +import { strict as assert } from 'node:assert/strict' +import { configureAccess } from 'wildebeest/backend/src/config/index' +import * as middleware_main from 'wildebeest/backend/src/middleware/main' + +const userKEK = 'test_kek12' +const domain = 'cloudflare.com' +const accessDomain = 'access.com' +const accessAud = 'abcd' + +describe('middleware', () => { + test('CORS on OPTIONS', async () => { + const request = new Request('https://example.com', { method: 'OPTIONS' }) + const ctx: any = { + request, + } + + const res = await middleware_main.main(ctx) + assert.equal(res.status, 200) + assertCORS(res) + }) + + test('test no identity', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') { + return new Response(JSON.stringify(ACCESS_CERTS)) + } + + if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') { + return new Response('', { status: 404 }) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + + const headers = { authorization: 'Bearer APPID.' + TEST_JWT } + const request = new Request('https://example.com', { headers }) + const ctx: any = { + env: { DATABASE: db }, + data: {}, + request, + } + + const res = await middleware_main.main(ctx) + assert.equal(res.status, 401) + }) + + test('test user not found', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') { + return new Response(JSON.stringify(ACCESS_CERTS)) + } + + if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') { + return new Response( + JSON.stringify({ + email: 'some@cloudflare.com', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + + const headers = { authorization: 'Bearer APPID.' + TEST_JWT } + const request = new Request('https://example.com', { headers }) + const ctx: any = { + env: { DATABASE: db }, + data: {}, + request, + } + + const res = await middleware_main.main(ctx) + assert.equal(res.status, 401) + }) + + test('success passes data and calls next', async () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') { + return new Response(JSON.stringify(ACCESS_CERTS)) + } + + if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') { + return new Response( + JSON.stringify({ + email: 'sven@cloudflare.com', + }) + ) + } + + throw new Error('unexpected request to ' + input) + } + + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + await configureAccess(db, accessDomain, accessAud) + + const headers = { authorization: 'Bearer APPID.' + TEST_JWT } + const request = new Request('https://example.com', { headers }) + const ctx: any = { + next: () => new Response(), + data: {}, + env: { DATABASE: db }, + request, + } + + const res = await middleware_main.main(ctx) + assert.equal(res.status, 200) + assert(!ctx.data.connectedUser) + assert(isUrlValid(ctx.data.connectedActor.id)) + assert.equal(ctx.data.accessDomain, accessDomain) + assert.equal(ctx.data.accessAud, accessAud) + }) +}) diff --git a/backend/test/start-instance.spec.ts b/backend/test/start-instance.spec.ts new file mode 100644 index 0000000..4520d69 --- /dev/null +++ b/backend/test/start-instance.spec.ts @@ -0,0 +1,50 @@ +import * as startInstance from 'wildebeest/functions/start-instance' +import { TEST_JWT, ACCESS_CERTS } from './test-data' +import { strict as assert } from 'node:assert/strict' +import { makeDB } from './utils' + +const accessDomain = 'access.com' +const accessAud = 'abcd' + +describe('Wildebeest', () => { + globalThis.fetch = async (input: RequestInfo) => { + if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') { + return new Response(JSON.stringify(ACCESS_CERTS)) + } + if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') { + return new Response( + JSON.stringify({ + email: 'some@cloudflare.com', + }) + ) + } + throw new Error('unexpected request to ' + input) + } + + test('start instance should generate a VAPID key and store a JWK', async () => { + const db = await makeDB() + + const body = JSON.stringify({ + title: 'title', + description: 'description', + email: 'email', + accessDomain, + accessAud, + }) + + const headers = { + cookie: 'CF_Authorization=' + TEST_JWT, + } + + const req = new Request('https://example.com', { method: 'POST', body, headers }) + const res = await startInstance.handlePostRequest(req, db) + assert.equal(res.status, 201) + + const { value } = await db.prepare("SELECT value FROM instance_config WHERE key = 'vapid_jwk'").first() + const jwk = JSON.parse(value) + + assert.equal(jwk.key_ops.length, 1) + assert.equal(jwk.key_ops[0], 'sign') + assert.equal(jwk.crv, 'P-256') + }) +}) diff --git a/backend/test/test-data.ts b/backend/test/test-data.ts new file mode 100644 index 0000000..695752b --- /dev/null +++ b/backend/test/test-data.ts @@ -0,0 +1,38 @@ +// Response from https://that-test.cloudflareaccess.com/cdn-cgi/access/certs +export const ACCESS_CERTS = { + keys: [ + { + kid: 'd112849a221376416483d8ddd41dc301a2d4fa67c04a49e35ead3fb98908bc7e', + kty: 'RSA', + alg: 'RS256', + use: 'sig', + e: 'AQAB', + n: 'xV1PApGhRR3VTx2fxDfgIMKPO2y-31aZRGG03QyrVgOTgU7sVDQcri6d9Ae5KobNh2Xpyw5iLjme_KHraw_JMsvA4jqQrUZff6YYItMc3AI5N0jUj4MAC5JA_7nBrELO5XIldyXwNdruzrVSdZCxUIjCO_7wGIGt7t75wHxXo88ggPHp4qioGe5wXKkQOMGF1SpWyNBKIVzmCYcQwiku1KI8BqERbCq28zvdfursTv6mhkJ0hMnd0iTDCoxJtWyG5yZWsPdBMa8zjbfGcRVYCZulxR19KPY_UDQAx3AhNRMTS2JrAWIFRTPAf1OUcj0fxNAjhw0EgBRUH5SCVmb8yQ', + }, + { + kid: '26edff6c8d4d065bc771a11424d2fbb4ce53352f508154895a93b1045d4d8de8', + kty: 'RSA', + alg: 'RS256', + use: 'sig', + e: 'AQAB', + n: 'vToHdbded4Qb3IJ94Jquh9rnAnsgzxg-0cqdDLan1pSo0KVq8oovVQ8N1736vtwMtQ18eHLUhBAwe0H_DG5PDvwwHXdACuJ1mPGdpqtlzTjFXfjwRcFKRBZxMYTEhOGixMvXpO4LPfbeDLLk2iBTTDhS3evrzHl9bbgkqBB-tOY2Jd2dASjthsrdUKV8ODoLI5CyzcsQHxS3_lqLnwvk4MThafoCbSftV0pN52jKxBygisCvD-uCzvTLK0XFjA5l4wLXF5vJHDMUpYRnv3HmfoiTlt6flZ6iTq8fDxzOHm1u2KjMUoSFNGJZdId3J19_6P6KwaBjxYAcKbTbZ-2myQ', + }, + ], + public_cert: { + kid: 'd112849a221376416483d8ddd41dc301a2d4fa67c04a49e35ead3fb98908bc7e', + cert: '-----BEGIN CERTIFICATE-----\nMIIDUDCCAjigAwIBAgIQalPMevEDwYmA8uOnTQbLtDANBgkqhkiG9w0BAQsFADBi\nMQswCQYDVQQGEwJVUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjET\nMBEGA1UEChMKQ2xvdWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5j\nb20wHhcNMjIxMTI1MjExODIzWhcNMjMxMjA5MjExODIzWjBiMQswCQYDVQQGEwJV\nUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjETMBEGA1UEChMKQ2xv\ndWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFXU8CkaFFHdVPHZ/EN+Agwo87bL7fVplE\nYbTdDKtWA5OBTuxUNByuLp30B7kqhs2HZenLDmIuOZ78oetrD8kyy8DiOpCtRl9/\nphgi0xzcAjk3SNSPgwALkkD/ucGsQs7lciV3JfA12u7OtVJ1kLFQiMI7/vAYga3u\n3vnAfFejzyCA8eniqKgZ7nBcqRA4wYXVKlbI0EohXOYJhxDCKS7UojwGoRFsKrbz\nO91+6uxO/qaGQnSEyd3SJMMKjEm1bIbnJlaw90ExrzONt8ZxFVgJm6XFHX0o9j9Q\nNADHcCE1ExNLYmsBYgVFM8B/U5RyPR/E0COHDQSAFFQflIJWZvzJAgMBAAGjAjAA\nMA0GCSqGSIb3DQEBCwUAA4IBAQA6Eir3jYipcJ9MdLxq4iMnDbWQT3F3tnsan9ni\nQa0N1YuAu6M9rsDbhCZz/igidUYqEFb4MEVMrQvPp6ChQc9J2hi8qGqAJoMZGZV6\nKCxSfwSrOdprDUYodoaTcEZ4oxcrx6vu6NX+2RluSu2Q04Co2+D/0jF3ABMm8fo6\n+oBLCJcHhNC57XEaMwtPCeA/SXareUAgl7mZDaHHWqLx0D5OEo4d1PEoJLyQdFcV\nIxq/vf8kE+dbY7OSwkcXOaScvxWm398GxV924zFxsijO6D0pOu7A0WTDH5n5fAIX\n4BaROg1WTOjiaL8XoqUOt0y1MSMp5HcjJnoFMImSlsHcoBMA\n-----END CERTIFICATE-----\n', + }, + public_certs: [ + { + kid: 'd112849a221376416483d8ddd41dc301a2d4fa67c04a49e35ead3fb98908bc7e', + cert: '-----BEGIN CERTIFICATE-----\nMIIDUDCCAjigAwIBAgIQalPMevEDwYmA8uOnTQbLtDANBgkqhkiG9w0BAQsFADBi\nMQswCQYDVQQGEwJVUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjET\nMBEGA1UEChMKQ2xvdWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5j\nb20wHhcNMjIxMTI1MjExODIzWhcNMjMxMjA5MjExODIzWjBiMQswCQYDVQQGEwJV\nUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjETMBEGA1UEChMKQ2xv\ndWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFXU8CkaFFHdVPHZ/EN+Agwo87bL7fVplE\nYbTdDKtWA5OBTuxUNByuLp30B7kqhs2HZenLDmIuOZ78oetrD8kyy8DiOpCtRl9/\nphgi0xzcAjk3SNSPgwALkkD/ucGsQs7lciV3JfA12u7OtVJ1kLFQiMI7/vAYga3u\n3vnAfFejzyCA8eniqKgZ7nBcqRA4wYXVKlbI0EohXOYJhxDCKS7UojwGoRFsKrbz\nO91+6uxO/qaGQnSEyd3SJMMKjEm1bIbnJlaw90ExrzONt8ZxFVgJm6XFHX0o9j9Q\nNADHcCE1ExNLYmsBYgVFM8B/U5RyPR/E0COHDQSAFFQflIJWZvzJAgMBAAGjAjAA\nMA0GCSqGSIb3DQEBCwUAA4IBAQA6Eir3jYipcJ9MdLxq4iMnDbWQT3F3tnsan9ni\nQa0N1YuAu6M9rsDbhCZz/igidUYqEFb4MEVMrQvPp6ChQc9J2hi8qGqAJoMZGZV6\nKCxSfwSrOdprDUYodoaTcEZ4oxcrx6vu6NX+2RluSu2Q04Co2+D/0jF3ABMm8fo6\n+oBLCJcHhNC57XEaMwtPCeA/SXareUAgl7mZDaHHWqLx0D5OEo4d1PEoJLyQdFcV\nIxq/vf8kE+dbY7OSwkcXOaScvxWm398GxV924zFxsijO6D0pOu7A0WTDH5n5fAIX\n4BaROg1WTOjiaL8XoqUOt0y1MSMp5HcjJnoFMImSlsHcoBMA\n-----END CERTIFICATE-----\n', + }, + { + kid: '26edff6c8d4d065bc771a11424d2fbb4ce53352f508154895a93b1045d4d8de8', + cert: '-----BEGIN CERTIFICATE-----\nMIIDUDCCAjigAwIBAgIQDHWwCqyvlIwH+WIPr57DNzANBgkqhkiG9w0BAQsFADBi\nMQswCQYDVQQGEwJVUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjET\nMBEGA1UEChMKQ2xvdWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5j\nb20wHhcNMjIxMTI1MjExODIzWhcNMjMxMjA5MjExODIzWjBiMQswCQYDVQQGEwJV\nUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjETMBEGA1UEChMKQ2xv\ndWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9Ogd1t153hBvcgn3gmq6H2ucCeyDPGD7R\nyp0MtqfWlKjQpWryii9VDw3Xvfq+3Ay1DXx4ctSEEDB7Qf8Mbk8O/DAdd0AK4nWY\n8Z2mq2XNOMVd+PBFwUpEFnExhMSE4aLEy9ek7gs99t4MsuTaIFNMOFLd6+vMeX1t\nuCSoEH605jYl3Z0BKO2Gyt1QpXw4OgsjkLLNyxAfFLf+WoufC+TgxOFp+gJtJ+1X\nSk3naMrEHKCKwK8P64LO9MsrRcWMDmXjAtcXm8kcMxSlhGe/ceZ+iJOW3p+VnqJO\nrx8PHM4ebW7YqMxShIU0Yll0h3cnX3/o/orBoGPFgBwptNtn7abJAgMBAAGjAjAA\nMA0GCSqGSIb3DQEBCwUAA4IBAQB2syAsQnUJizx0cjYq1bvVlpTdNKTj7aL6QRjC\nUqfJaLuI4ciP7DgPAIPlAcopU3S6KOXwjl8jZotBEB7wKnlatWPdCOGe1U6DozQn\n/qvSXWZ8N8gtyeXWOeh37JxRmg4qBO7h+QVto0WRX7P8WV3sS07yxcv5LlxxhQmD\nUXmoNEu/u4LP6fsa6Yibz6vtr4+Eu7TwkYq3C9dPvsBkMGDSkD8lqJ/Cu6TfoUC2\nEJAY3vcamqFdvlK6dfFM0nQ5JYubJwmn4stFqNDJaXQbxVvqeUokYw7aAyoLX6nu\nsK92TNP9E/VpOQfor2esOJBVYTtTmv/Q2QJrJpPRmUhpNlMc\n-----END CERTIFICATE-----\n', + }, + ], +} + +export const TEST_JWT = + 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImQxMTI4NDlhMjIxMzc2NDE2NDgzZDhkZGQ0MWRjMzAxYTJkNGZhNjdjMDRhNDllMzVlYWQzZmI5ODkwOGJjN2UifQ.eyJhdWQiOlsiYjQ1YjE2Mjc0YTFhMjEyYjk1Y2JjNjA4MTAzNWM1Yzc2MWM4MTIyOTY5MzIzZjE2NDRjYWZkY2QwYjI3MzU1ZSJdLCJlbWFpbCI6InN2ZW5AY2xvdWRmbGFyZS5jb20iLCJleHAiOjE2NzA5Njg5OTMsImlhdCI6MTY3MDM2NDE5MywibmJmIjoxNjcwMzY0MTkzLCJpc3MiOiJodHRwczovL3RoYXQtdGVzdC5jbG91ZGZsYXJlYWNjZXNzLmNvbSIsInR5cGUiOiJhcHAiLCJpZGVudGl0eV9ub25jZSI6IjNHcmdlSExnUGNrOWNnbUEiLCJzdWIiOiI1YzlhZjE0NC0wOTc2LTQ4NTMtYjBjOC0zYWUyODkyYmQ2ZDAiLCJjb3VudHJ5IjoiRlIifQ.EvMo2vXL3-_qFrROO5Pk7r3oUQGmDF2HOzQq9OSMMBESdT3TESKZ48NC36hrmOfB-_6Pi_iQrc1EE_X6U3rs66UwEyGnF7NjEMiKMFaBRQp5wGANTTSuLz1VpDlzv7mTGqREd7kwTEOe0jzJMEtbhkbp8aQ_w01aBGmgyz2FM3FSTurzd3_r82nn9tqmsjpZXE0pGOzazjT8gPO6JRrwM5myCQ83f8NlZMIz8OXAk3Y-W0429tOiwZvPuVnyFb_vBEQmPlyDWeg_hSBVI1pTiyml_I9irMfaQhGmw3PDfMMkvQdOC-MfPO23Yu56awq_OVJoR8FjfHfPGYeLa-bvMQ' diff --git a/backend/test/utils.spec.ts b/backend/test/utils.spec.ts new file mode 100644 index 0000000..32bef84 --- /dev/null +++ b/backend/test/utils.spec.ts @@ -0,0 +1,74 @@ +import { strict as assert } from 'node:assert/strict' + +import { parseHandle } from '../src/utils/parse' +import { urlToHandle } from '../src/utils/handle' + +import { generateUserKey, unwrapPrivateKey, importPublicKey } from 'wildebeest/backend/src/utils/key-ops' +import { signRequest } from 'wildebeest/backend/src/utils/http-signing' +import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage' +import { parseRequest } from 'wildebeest/backend/src/utils/httpsigjs/parser' +import { fetchKey, verifySignature } from 'wildebeest/backend/src/utils/httpsigjs/verifier' + +describe('utils', () => { + test('user key lifecycle', async () => { + const userKEK = 'userkey' + const userKeyPair = await generateUserKey(userKEK) + await unwrapPrivateKey(userKEK, userKeyPair.wrappedPrivKey, userKeyPair.salt) + await importPublicKey(userKeyPair.pubKey) + }) + + test('request signing', async () => { + const body = '{"foo": "bar"}' + const digest = await generateDigestHeader(body) + const request = new Request('https://example.com', { + method: 'POST', + body: body, + headers: { header1: 'value1', Digest: digest }, + }) + const userKEK = 'userkey' + const userKeyPair = await generateUserKey(userKEK) + const privateKey = await unwrapPrivateKey(userKEK, userKeyPair.wrappedPrivKey, userKeyPair.salt) + const keyid = new URL('https://foo.com/key') + await signRequest(request, privateKey, keyid) + assert(request.headers.has('Signature'), 'no signature in signed request') + + const parsedSignature = parseRequest(request) + const publicKey = await importPublicKey(userKeyPair.pubKey) + assert(await verifySignature(parsedSignature, publicKey), 'verify signature failed') + }) + + test('handle parsing', async () => { + let res + + res = parseHandle('') + assert.equal(res.localPart, '') + assert.equal(res.domain, null) + + res = parseHandle('@a') + assert.equal(res.localPart, 'a') + assert.equal(res.domain, null) + + res = parseHandle('a') + assert.equal(res.localPart, 'a') + assert.equal(res.domain, null) + + res = parseHandle('@a@remote.com') + assert.equal(res.localPart, 'a') + assert.equal(res.domain, 'remote.com') + + res = parseHandle('a@remote.com') + assert.equal(res.localPart, 'a') + assert.equal(res.domain, 'remote.com') + + res = parseHandle('a%40masto.ai') + assert.equal(res.localPart, 'a') + assert.equal(res.domain, 'masto.ai') + }) + + test('URL to handle', async () => { + let res + + res = urlToHandle(new URL('https://host.org/users/foobar')) + assert.equal(res, 'foobar@host.org') + }) +}) diff --git a/backend/test/utils.ts b/backend/test/utils.ts new file mode 100644 index 0000000..94cd3ce --- /dev/null +++ b/backend/test/utils.ts @@ -0,0 +1,66 @@ +import { strict as assert } from 'node:assert/strict' +import { createClient } from 'wildebeest/backend/src/mastodon/client' +import type { Client } from 'wildebeest/backend/src/mastodon/client' +import { promises as fs } from 'fs' +import { BetaDatabase } from '@miniflare/d1' +import * as Database from 'better-sqlite3' + +export function isUrlValid(s: string) { + let url + try { + url = new URL(s) + } catch (err) { + return false + } + return url.protocol === 'https:' +} + +export async function makeDB(): Promise { + const db = new Database(':memory:') + const db2 = new BetaDatabase(db)! + + // Manually run our migrations since @miniflare/d1 doesn't support it (yet). + const initial = await fs.readFile('./migrations/0000_initial.sql', 'utf-8') + await db.exec(initial) + + return db2 +} + +export function assertCORS(response: Response) { + assert(response.headers.has('Access-Control-Allow-Origin')) + assert(response.headers.has('Access-Control-Allow-Headers')) +} + +export function assertJSON(response: Response) { + assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8') +} + +export function assertCache(response: Response, maxge: number) { + assert(response.headers.has('cache-control')) + assert(response.headers.get('cache-control')!.includes('max-age=' + maxge)) +} + +export async function streamToArrayBuffer(stream: ReadableStream) { + let result = new Uint8Array(0) + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) { + break + } + + const newResult = new Uint8Array(result.length + value.length) + newResult.set(result) + newResult.set(value, result.length) + result = newResult + } + return result +} + +export async function createTestClient( + db: D1Database, + redirectUri: string = 'https://localhost', + scopes: string = 'read follow' +): Promise { + return createClient(db, 'test client', redirectUri, 'https://cloudflare.com', scopes) +} diff --git a/backend/test/webfinger.spec.ts b/backend/test/webfinger.spec.ts new file mode 100644 index 0000000..a819504 --- /dev/null +++ b/backend/test/webfinger.spec.ts @@ -0,0 +1,55 @@ +import { makeDB, assertCache } from './utils' +import { strict as assert } from 'node:assert/strict' + +import * as webfinger from 'wildebeest/functions/.well-known/webfinger' + +describe('WebFinger', () => { + test('no resource queried', async () => { + const db = await makeDB() + + const req = new Request('https://example.com/.well-known/webfinger') + const res = await webfinger.handleRequest(req, db) + assert.equal(res.status, 400) + }) + + test('invalid resource', async () => { + const db = await makeDB() + + const req = new Request('https://example.com/.well-known/webfinger?resource=hein:a') + const res = await webfinger.handleRequest(req, db) + assert.equal(res.status, 400) + }) + + test('query local account', async () => { + const db = await makeDB() + + const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven') + const res = await webfinger.handleRequest(req, db) + assert.equal(res.status, 400) + }) + + test('query remote non-existing account', async () => { + const db = await makeDB() + + const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven@example.com') + const res = await webfinger.handleRequest(req, db) + assert.equal(res.status, 404) + }) + + test('query remote existing account', async () => { + const db = await makeDB() + await db + .prepare('INSERT INTO actors (id, email, type) VALUES (?, ?, ?)') + .bind('https://example.com/ap/users/sven', 'sven@cloudflare.com', 'Person') + .run() + + const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven@example.com') + const res = await webfinger.handleRequest(req, db) + assert.equal(res.status, 200) + assert.equal(res.headers.get('content-type'), 'application/jrd+json') + assertCache(res, 3600) + + const data = await res.json() + assert.equal(data.links.length, 1) + }) +}) diff --git a/cfsetup.yaml b/cfsetup.yaml new file mode 100644 index 0000000..330f0a4 --- /dev/null +++ b/cfsetup.yaml @@ -0,0 +1,19 @@ +default-flavor: bullseye + +template: + build: + builddeps: &builddeps + nodejs: + procps: + post-cache: + +bullseye: + test: + builddeps: + <<: *builddeps + post-cache: + - yarn install + - yarn pretty + - yarn test + # Until https://github.com/cloudflare/wrangler2/issues/2463 is resolved. + # - yarn database:create-mock && yarn test:ui diff --git a/config/accounts.ts b/config/accounts.ts new file mode 100644 index 0000000..0ab481d --- /dev/null +++ b/config/accounts.ts @@ -0,0 +1,6 @@ +import type { DefaultImages } from '../backend/src/types/configs' + +export const defaultImages: DefaultImages = { + avatar: 'https://masto.ai/avatars/original/missing.png', + header: 'https://arrifana.org/system/cache/accounts/headers/109/541/309/468/846/872/original/89ed71066eac95f7.png', +} diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..51fc5ee --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,37 @@ +module.exports = { + root: true, + env: { + browser: true, + es2021: true, + node: true, + }, + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:qwik/recommended'], + parser: '@typescript-eslint/parser', + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig.json'], + ecmaVersion: 2021, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + plugins: ['@typescript-eslint'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-inferrable-types': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-namespace': 'error', + '@typescript-eslint/no-empty-function': 'error', + '@typescript-eslint/no-this-alias': 'error', + '@typescript-eslint/ban-types': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + 'prefer-spread': 'error', + 'no-case-declarations': 'error', + 'no-console': 'error', + '@typescript-eslint/no-unused-vars': ['error'], + 'prefer-const': 'error', + }, +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b29623f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,41 @@ +# Build +/dist +/lib +/lib-types +/server + +# Development +node_modules + +# Cache +.cache +.mf +.vscode +.rollup.cache +tsconfig.tsbuildinfo + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Yarn +.yarn/* +!.yarn/releases + +# Cloudflare +functions/**/*.js diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e7e342a --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,17 @@ +# Wildebeest UI + +This directory contains a website that server-side renders a readonly public view of the data available via the REST APIs of the server. + +The site is built using the Qwik framework, which consists of client-side JavaScript code, static assets and a server-side Cloudflare Pages Function to do the server-side rendering. + +In the top level of the repository run the following to build the app and host the whole server: + +``` +yarn dev +``` + +If you make a change to the Qwik application, you can open a new terminal and run the following to regenerate the website code: + +``` +yarn --cwd ui build +``` diff --git a/frontend/adaptors/cloudflare-pages/vite.config.ts b/frontend/adaptors/cloudflare-pages/vite.config.ts new file mode 100644 index 0000000..34baa4b --- /dev/null +++ b/frontend/adaptors/cloudflare-pages/vite.config.ts @@ -0,0 +1,20 @@ +import { cloudflarePagesAdaptor } from '@builder.io/qwik-city/adaptors/cloudflare-pages/vite' +import { extendConfig } from '@builder.io/qwik-city/vite' +import baseConfig from '../../vite.config' + +export default extendConfig(baseConfig, () => { + return { + build: { + ssr: true, + rollupOptions: { + input: ['src/entry.cloudflare-pages.tsx', '@qwik-city-plan'], + }, + }, + plugins: [ + cloudflarePagesAdaptor({ + // Do not SSG as the D1 database is not available at build time, I think. + // staticGenerate: true, + }), + ], + } +}) diff --git a/frontend/jest.config.js b/frontend/jest.config.js new file mode 100644 index 0000000..1428b6c --- /dev/null +++ b/frontend/jest.config.js @@ -0,0 +1,7 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +export default { + preset: 'ts-jest', + verbose: true, + testMatch: ["/test/**/(*.)+(spec|test).[jt]s?(x)"], + testTimeout:15000, +} diff --git a/frontend/mock-db/init.ts b/frontend/mock-db/init.ts new file mode 100644 index 0000000..c8665da --- /dev/null +++ b/frontend/mock-db/init.ts @@ -0,0 +1,42 @@ +import { createPerson, getPersonByEmail, type Person } from 'wildebeest/backend/src/activitypub/actors' +import * as statusesAPI from 'wildebeest/functions/api/v1/statuses' +import { statuses } from 'wildebeest/frontend/src/dummyData' +import type { MastodonStatus } from 'wildebeest/frontend/src/types' +import type { MastodonAccount } from 'wildebeest/backend/src/types' + +const kek = 'test-kek' +/** + * Run helper commands to initialize the database with actors, statuses, etc. + */ +export async function init(domain: string, db: D1Database) { + for (const status of statuses as MastodonStatus[]) { + const actor = await getOrCreatePerson(domain, db, status.account.username) + await createStatus(db, actor, status.content) + } +} + +/** + * Create a status object in the given actors outbox. + */ +async function createStatus(db: D1Database, actor: Person, status: string, visibility = 'public') { + const body = { + status, + visibility, + } + const req = new Request('https://example.com', { + method: 'POST', + body: JSON.stringify(body), + }) + await statusesAPI.handleRequest(req, db, actor, kek) +} + +async function getOrCreatePerson(domain: string, db: D1Database, username: string): Promise { + const person = await getPersonByEmail(db, username) + if (person) return person + await createPerson(domain, db, kek, username) + const newPerson = await getPersonByEmail(db, username) + if (!newPerson) { + throw new Error('Could not create Actor ' + username) + } + return newPerson +} diff --git a/frontend/mock-db/run.mjs b/frontend/mock-db/run.mjs new file mode 100644 index 0000000..006493a --- /dev/null +++ b/frontend/mock-db/run.mjs @@ -0,0 +1,33 @@ +import console from 'console'; +import { dirname, resolve } from 'path'; +import process from 'process'; +import { fileURLToPath } from 'url'; +import { unstable_dev } from 'wrangler' + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * A simple utility to run a Cloudflare Worker that will populate a local D1 database with mock data. + * + * Uses Wrangler's `unstable_dev()` helper to execute the Worker and exit cleanly; + * this is much harder to do with the command line Wrangler binary. + */ +async function main() { + const options = { + local: true, + persist: true, + nodeCompat: true, + config: resolve(__dirname, '../../wrangler.toml'), + tsconfig: resolve(__dirname, '../../tsconfig.json'), + define: ['jest:{}'], + } + const workerPath = resolve(__dirname, "./worker.ts"); + const worker = await unstable_dev(workerPath, options, { disableExperimentalWarning: true }) + await worker.fetch() + await worker.stop() +} + +main().catch((e) => { + console.error(e) + process.exitCode = 1 +}) diff --git a/frontend/mock-db/worker.ts b/frontend/mock-db/worker.ts new file mode 100644 index 0000000..16b06d8 --- /dev/null +++ b/frontend/mock-db/worker.ts @@ -0,0 +1,35 @@ +import { init } from './init' + +interface Env { + DATABASE: D1Database +} + +/** + * A Cloudflare Worker that will run helpers against a D1 database to populate it with mock data. + */ +const handler: ExportedHandler = { + async fetch(req, { DATABASE }) { + const domain = new URL(req.url).hostname + try { + await init(domain, DATABASE) + console.log('Database initialized.') + } catch (e) { + if (isD1ConstraintError(e)) { + console.log('Database already initialized.') + } else { + throw e + } + } + return new Response('OK') + }, +} + +/** + * Check whether the error is because of a SQL constraint, + * which will indicate that the database was already populated. + */ +function isD1ConstraintError(e: unknown) { + return (e as any).message === 'D1_RUN_ERROR' && (e as any).cause?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' +} + +export default handler diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c1265ae --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,41 @@ +{ + "name": "my-qwik-basic-starter", + "description": "App with Routing built-in (recommended)", + "type": "module", + "engines": { + "node": ">=15.0.0" + }, + "private": true, + "scripts": { + "lint": "eslint src", + "build": "vite build && vite build -c adaptors/cloudflare-pages/vite.config.ts", + "dev": "vite --mode ssr", + "watch": "concurrently \"vite build -w\" \"vite build -w -c adaptors/cloudflare-pages/vite.config.ts\"" + }, + "devDependencies": { + "@builder.io/qwik": "0.16.1", + "@builder.io/qwik-city": "0.1.0-beta6", + "@types/eslint": "8.4.10", + "@types/jest": "^29.2.4", + "@types/node": "^18.11.16", + "@types/node-fetch": "latest", + "@typescript-eslint/eslint-plugin": "5.46.1", + "@typescript-eslint/parser": "5.46.1", + "concurrently": "^7.6.0", + "eslint": "8.30.0", + "eslint-plugin-qwik": "0.16.1", + "jest": "^29.3.1", + "node-fetch": "3.3.0", + "prettier": "2.8.1", + "sass": "^1.57.0", + "ts-jest": "^29.0.3", + "typescript": "4.9.4", + "undici": "5.14.0", + "vite": "4.0.1", + "vite-tsconfig-paths": "3.5.0", + "wrangler": "latest" + }, + "dependencies": { + "modern-normalize": "^1.1.0" + } +} diff --git a/frontend/public/_headers b/frontend/public/_headers new file mode 100644 index 0000000..0690cb4 --- /dev/null +++ b/frontend/public/_headers @@ -0,0 +1,4 @@ +# https://developers.cloudflare.com/pages/platform/headers/ + +/build/* + Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable diff --git a/frontend/public/_redirects b/frontend/public/_redirects new file mode 100644 index 0000000..e274610 --- /dev/null +++ b/frontend/public/_redirects @@ -0,0 +1 @@ +# https://developers.cloudflare.com/pages/platform/redirects/ diff --git a/frontend/public/_routes.json b/frontend/public/_routes.json new file mode 100644 index 0000000..7b1e721 --- /dev/null +++ b/frontend/public/_routes.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "include": ["/*"], + "exclude": ["/build/*", "/assets/*"] +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a405791e320043cf7fa973697dc61bc099fee44a GIT binary patch literal 15086 zcmeHO33OCdwk-^h5axLfC^AKmK>?94Y5(qpS?d4oa{8_PwP`U783=?SUDtx#zL`dI1Al9VBmY0Fu11rL5A?!kaEj(V z82?>)J*gi4^YhP_Lvy5daQ>lkZYs^nGeHt%Mp)FQtal zU@@f>2y^3Mhd&m8PyI*50r>w|e}dKs4&?XYFElVYU%&?xlZ!FlPZU`LkIDG|pSD;; z)0lhIF#0YviYcHbu{vrz6E>r?k_Wg*9u20H8sL-vc@+2?MBHi}5PtK7|HNOZ{sb5} z1Wwe_2iuHd-WM3(14N8b@`&*82m55&K|#+fDiMLv_o&JALTWZkM@?bOqlv;p=Z(h+ z#8<1|${{fFb`&^NfCKqYx=HW_)dh#1@%KdX=M^IM2+@>-HShrbXz=?W%O={y<}3J{ zf25;kvthn?sIgUn*oyou_~Gv!fs^lC3yiu=;6N$|_y8ON;Dfg}>cz(=FusRKiKVGN zcyNx)#AimX(2K^?3#bL~w}>mF+IS#GuEFqhd-zwg8To#Vr`}uAXbN&}GIEbOM1n(v z>W6w0v=kJ3MigE96LO;~Bcg9+p9~8TUh)OuJvu7MH0g8lIF>Qb@jE23G5qnU?& z@NniwEdC$5&Lo=8Dx_8kI%<^&4lW+5KfLfLK#qhw4E|Oh6$ZA5Ey!**GoPAHzfVn= z!&H?+!^yYd3zY-sL)&S$#jISLn3o3*M~OHeyz$^Xk3oD*J>U>MQc6Y3xDskJr;J*I zgI6BT9ASSnjn!qt0w0t^>-eHK!NJ^ORsl7a9H!l)#^BHhesFPsFY1HC=(J+7_#}yV z#L;YUsENmH@Ou;UY3ul6;MY-`q_QeJT6y|I8TCmm-}kju;=4$rpPFmF6KPht-XFM*Z_+9wrL+eC1PN4|Xo4zuzRPxp}%;4n?)0AFO)nQ*hg<}1`-)j8U+=p@k` zJl7IozIbqs#2*tBvdSRZCY4Z!`Fd*4=E(!G)w-%bu4B&X?Ty0|OZ0WSE-qWqWlA58tIv}kIILWHnkX3@=6&g#$47{vIf%1AWST|$c{bX%$?@Sw+2%Shn?4u~g`@x&b3R_25j@zmG-I{f?U3)Cn59BCJQO~ihv ziAN&v$K+OsUa5M~F$MQpXrN9DVBq4D$6Uo9d*G8s^4>jG7~bi=++ck&O;6pHfdhCv z4jx^>gMHC?0mdh#lsdZQM4R}+czM3(5gg){pCwv=T=*EzwiK8z9{k*RZL39e#5e^l z)KkzRgI69(j#TD{6?4`*eBWNH3_(594QG3-G!S?Yd_f8imoLBrF%`7X$$@iXZt<0N zbBcmIam)8>FH%s(1-i^}v=Dx%i3jJ&>|+kmA8Vsd@OPKR2F0rJfKNKe98r0k#oQYd zyxN%9JHt=`9@Nvt!Ju+*`hsIhjVB%Asnp(Se#v-=)z@*&MG8wlPxe%BSbVBx9{k++ z^G=)S3SW0gWxs<18@SZKqXIdS_|zKXRdAr*88C3@mF~{N=?fWCN<8UKc3x20(id*| z_qE?p3vkHeI9dV@so>y?2ggdbj$G}#&S>qk z)=1z%l8538@Zgy0&M}3&Kz@@mzkSBN#4eBPzM(M05v75{(la&l;OE9$Syl&+{}aoM zsx39v#>4h>rpelOy@~o@pZ%0Ok7~Y9V+uUtB)-~?8!k~8d_XJC5iN(+#Do3wzu8vD z?=h$~ya4W_Fw0uO>O$d@lFb3=auIo_f+?3=%%7Mm_p-kNWS z(&2-Z=Y8{F>rVl^Emg!Qlo<5na$>R25vT6!GrpPM?dC)czW8a zFN}&WFi-i+gX|wDZrfENj6ZF-Oq2;@9_zgD;Jo+;6d;Q)#QuHdCH)LKl*saQO=LlXXiE2Zv7k4=I@C<`HqPF;El(6 zU{5{k5YJ)kgE9a0U2mp7Z0p>3fD`+~!ykJQQ?B&`V|eyc^JrKxco2L+?2BqVJn|cH zwG!s4S021UetWOeO5i6R|4+eT3pn`Vfi*Y%-#)7dUTdJIF^BZqV8%RRroJ$hhq=-p zl{|`jtuj?vzcKzn+bnfqD+X;f6L^q256`@C%kLuiV+5biFW?`(?`JB?2L8`}Alm+S z-#n1twD@d=7`(|O7`qy~X2dRxdBB*5;*)h%c&q?^*P2(ov-)%Jc1!T!ZI++G!^NSt zn8JLU^?16W!6}qx4{2!!@YBv8iI~F1eXL zg55ED{yyY0%d{ceEe`M?ufC|B-=?0cjjx`#L$4gsl5sEa@BWEs7t9-vt-#IkwE$y3 z=o6EI{n@QNUKaQq9K7*>Pv8#+{4vcFujl*cc39fNPJ;)*AH=?>!owpk`eL4XezW;( zu2!5m{43GE8$`@u&voxSvfz{KYeKxX$0GUzKi@;%$2$)T=0wbk8_lN?SGDfnna|KH z>sv#!EN1W^g@?kSGNw4cV3e$$;AtgY~;oR*RPnvu$|TrSOMPyzHsr7 z*XF$DXZ5U;<&IPLi9WwgbnF(<5peLu<0!brohlcDw;|?m-H_cj8nz1@cGk$F0RDKW z`k@oQAFjQm>h)C~><5mUFV9)-^A;#pG#dBF^8nMSl z!*gs3hgx_nfv;T87{>OXFT{Qi_~2T|ZY$yF@-@eQKddblU9dYY6cK#|{3q@aeF+ZL zcsPCXCAdvHSuUR2!Pu=da-WSx?zPp%;~Mg#$AjYa_BlMqqTt_(;W_MYC{1bBUhvHvfOJzr<^fpQwXznn(x2L}%x(kJkH1@0T=4b#K? z4RwjuSjT5$k0m?*Y1FPx&sMo9BR=$6n%>M^7H-z-jXj6=Kv* zGmS*tFm`uy@c~$`E4<+AA8KQerUEi z6Sy*g(U+~l|Ds@he|M<-ADSyA#Qz-swIe{vOQUfeD>Rysg!ej)rYRruG#WqmV*>h# z_`N(bT)Y>2jITW6GPsaNBac>o-$kRbYTi?hIzBelXtWyK$iMQKR|lU&1m*eROdJ#V zSO@3&D|e_oGB|$8qsRZ{dGb2)x}NuwI3zBKQ{t9KPkzeX@xwI^3C?lJ^B6BVj<}FA zt{&sfeOg^%qhPK+t!8-hZ}h4k_H(^ix#a_)m5)WgMFe_z!pH5RaP;wnqZcWB;%-NH zxA^>U|A^b+=s^k(h`JXZFu5Qcy+;w~bs39(odM`O^13IjZ5Zl}*EaO~@Wz~%(n|_w z9j@#tl6^SbOT;}!FRswLdWZs}(LWSZqV^A|y+eh-kq8_QwZ>*_(BLPbx5?Ie%1w&K zdW?G@WM2^9mvL}!)A(H#)HS{U{XfuPr6@8SYD3S=#M=0}{~vt--O-bA zjeBG2hG}VJY7zR9gwhkkeG3u?_rP$E&#UWA0zC!L!%JO#3LZFq0G0>pw`e*e|1tDc zq&A4Wt@MR(FG91Zn=}c%QQV6ldqLcBMD2CZQz<2)>8ui0?}zM{kT}rqu^3iszXY`G z5NN;!N?!ypG(cZRJz#h?r9jL%E@%c~VH*0I+;MOZ$Dg)ZMe8{9Z*cDhO!lC-aLm*d z0Lw7ix~pMg+6}@JqC(4&pi^dx1iBf6+@%Rw= z`aN{w>o9&qXnLg^>tYR>#u}2MtvWSH)`p=+D!Op1xPmLtgoEV$*Ib|==wFvve`GD# z9S8Ro%sA{2{n8AgBlIlRwOlyROCb9?pj)RwE3J!q_Za9|g^E5}RgY7&-464L?a)N4 z>59;D!S_ZaI3&}tQ(7Gt4m z%}#9-x@I2qwZp6M%66cb@2K6qGs7wLaDz6rZFU?1+sp8T<11*;NXIhGCaF(rV~PtDuE34#qPnw_GUN zGwUtTj8Gd#j8(x=2)~R$9i%QSP0=Q}hK%}?)Ev1U;`GU7#HcI(prr(%-g=q!2dM=~ z9IR(8J14}*&1UEitiM*)=qqt38eAIgudIzwhw2S&;ku$NuvR7YHXaMsZ&jbT<{xMw zaa`}^n)Mc^o+EMaal#>+;Fw~~K+y{n?ZyqqYSVR$N0ojd)ZSuH&vUTGp^k-$Lm3M- zKDFIe8G=vTreM@+wb?(aIsj`K5(jG+D=#|4@U3RlprP}i?$0%57Y^26)|nls^;he+ zLLH|q?3Ap<%CTUrNbw2A0@_^$w1|xTsDzV6J2DLCu=$zq>FNtSzK} zTOo#TH`8F$q_}pi)MRDNTGbEC7!RNIYSdmU>(jV~dcLYT4FZl8mny^;sDrW|z_nY#1bTGyY93o+jvd2o&Eimby#piXrjHM!HM&BO9;`_ zNr*#9Ar9!cSiy$h`EUF`{tSOsi*w}p@_Kx2c^`RSUW9U4qASc*r>JGbo=UhA5V^-b z1pCiWe6J^T><&k0=h^w8{*iY>1EPyUvF9FweLt!{US8vG`T0-K&U|G}Ss}jPVZt>` z*hia&EY=wVr{tUP-5C?UGgF8?^9&d&MO9zuZ61c+?1G@!y95l%o|yN;?|o*cPUu@! z_Ivp52lkrzxK@?@9`En4PtM}KC4XPw59vB(&%)oA;qPkveyth%NA)`l%AU<*zt%3P zl@g=XFt@ijWlx_2Y~nBxZTKX^|Vf$s$MUZi*R=3<{?#`g$bX9n0~w8tLjB=_E; zN0Rq7_J&jLj$3|)#$i8Y=XHA=_6~1swJJTDyk~Is$DYJqp*`k`~5m$Z-D;e zyaqA%nxogDIo7~=+)JtS93I6!6l)!&myOrj$$C51*-2Q(6mkz9uPK409DR>(%aO%8 z`VFjMZQRqyeVHHRl%uCj?a5MlM?cmVVoogNbs4Xzpjngy&s*~RjhE>StV35=^7fdMPbwN4p2g_3u=iSR9EaYDad__AWlzY|&+X_hQF|r0M*_V!C$ZLP z&sed(cpc9ZGp}*DALxbc<{LTJ#SPZf`5C_9J3%i&3uC>UHGiy2%~-oA{^RqV&*vEzbaRBN!EsWO)&H8a+hYxnlIK6S)TeX&K(8}1utXH%)KcTU5d3RXwCFWuBw}{ zhKXlJKJHf~ei=iTG=pDohGAyP+44fnizdv6COnf&v1iJSqYl_inD0!O`wQ_LoQdb!aSqnx-POhPpRYL Z`>3ejvKWIuE5^U`-{k-KvwRMp`~Perx%dD8 literal 0 HcmV?d00001 diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..0c6a9ef --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "name": "Wildebeest (Mastodon on Cloudflare)", + "short_name": "Wildebeest", + "start_url": ".", + "display": "standalone", + "background_color": "#fff", + "description": "A mastodon server deployed on Cloudflare." +} diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/MastodonLogo.tsx b/frontend/src/components/MastodonLogo.tsx new file mode 100644 index 0000000..8ee88b6 --- /dev/null +++ b/frontend/src/components/MastodonLogo.tsx @@ -0,0 +1,52 @@ +import { component$ } from '@builder.io/qwik' + +export const heightsMap = { + small: '2rem', + medium: '2.65rem', +} as const + +type Props = { + size: keyof typeof heightsMap +} + +export const MastodonLogo = component$(({ size }) => { + return ( + + ) +}) diff --git a/frontend/src/components/Sparkline/index.tsx b/frontend/src/components/Sparkline/index.tsx new file mode 100644 index 0000000..d07b286 --- /dev/null +++ b/frontend/src/components/Sparkline/index.tsx @@ -0,0 +1,90 @@ +import { component$ } from '@builder.io/qwik' + +type Props = { + data: Array + width?: number + height?: number + margin?: number + color?: string +} + +type Point = { + x: number + y: number +} + +export const Sparkline = component$((props: Props) => { + const defaults = { + margin: 2, + width: 50, + height: 33, + color: '#aaabff', + } + const height = props.height || defaults.height + const width = props.width || defaults.width + const margin = props.margin || defaults.margin + const color = props.color || defaults.color + + const dataToPoints = (data: Array) => { + const max = Math.max(...data) + const min = Math.min(...data) + + const len = data.length + const vfactor = (height - margin * 2) / (max - min || 2) + const hfactor = (width - margin * 2) / (len - (len > 1 ? 1 : 0)) + + return data.map((d, i) => ({ + x: i * hfactor + margin, + y: (max === min ? 1 : max - d) * vfactor + margin, + })) + } + + const points = dataToPoints(props.data) + + let prev: Point + const divisor = 0.25 + const curve = (p: Point) => { + let res + if (!prev) { + res = [p.x, p.y] + } else { + const len = (p.x - prev.x) * divisor + res = ['C', prev.x + len, prev.y, p.x - len, p.y, p.x, p.y] + } + prev = p + return res + } + const linePoints = points.map((p) => curve(p)).reduce((a, b) => a.concat(b)) + const closePolyPoints = [ + 'L' + points[points.length - 1].x, + height - margin, + margin, + height - margin, + margin, + points[0].y, + ] + const fillPoints = linePoints.concat(closePolyPoints) + + const lineStyle = { + stroke: color, + strokeWidth: '1', + strokeLinejoin: 'round', + strokeLinecap: 'round', + fill: 'none', + } + const fillStyle = { + stroke: 'none', + strokeWidth: '0', + fillOpacity: '.1', + fill: color, + } + + return ( + + + + + + + ) +}) diff --git a/frontend/src/components/Status.scss b/frontend/src/components/Status.scss new file mode 100644 index 0000000..cd52dbe --- /dev/null +++ b/frontend/src/components/Status.scss @@ -0,0 +1,45 @@ +@use '../styles/theme.scss' as theme; + +.status-content { + a { + text-decoration: none; + color: theme.$indigo400; + } + a.mention { + color: theme.$slate200; + } + a.hashtag { + color: theme.$slate200; + } +} + +.preview-image { + width: 4rem; + height: 4rem; +} + +// These are rules that are attached to content and expected to be +// defined by the client +.invisible { + font-size: 0; + line-height: 0; + display: inline-block; + width: 0; + height: 0; +} + +.ellipsis { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + text-decoration: none; +} + +.ellipsis::after { + content: "..."; +} + +.status-link { + color: theme.$slate200; + text-decoration: none; +} diff --git a/frontend/src/components/Status.tsx b/frontend/src/components/Status.tsx new file mode 100644 index 0000000..492c6ca --- /dev/null +++ b/frontend/src/components/Status.tsx @@ -0,0 +1,57 @@ +import { component$, $, useStyles$ } from '@builder.io/qwik' +import { Link, useNavigate } from '@builder.io/qwik-city' +import { formatTimeAgo } from '~/utils/dateTime' +import { MastodonStatus } from '~/types' +import styles from './Status.scss?inline' + +type Props = { + status: MastodonStatus +} + +export default component$((props: Props) => { + useStyles$(styles) + const nav = useNavigate() + + const status = props.status + + const accountUrl = `/@${status.account.username}` + const statusUrl = `${accountUrl}/${status.id}` + + const handleContentClick = $(() => nav(statusUrl)) + + return ( +
+
+
+ +
+
+ {/* TODO: this should either have an href or not being an `a` element (also consider using QwikCity's `Link` instead) */} + {status.account.display_name} +
+
@{status.account.username}
+
+
+ +
+ + {formatTimeAgo(new Date(status.created_at))} +
+ +
+ + ) +}) diff --git a/frontend/src/components/StickyHeader/StickyHeader.scss b/frontend/src/components/StickyHeader/StickyHeader.scss new file mode 100644 index 0000000..f9fc816 --- /dev/null +++ b/frontend/src/components/StickyHeader/StickyHeader.scss @@ -0,0 +1,10 @@ +@use '../../styles/theme.scss' as theme; + +header { + position: sticky; + top: 0; + // compensates for the 10px set w/ sticky on the main app columns + padding-top: 10px; + background: theme.$backgroundBaseColor; + z-index: 2; +} \ No newline at end of file diff --git a/frontend/src/components/StickyHeader/StickyHeader.tsx b/frontend/src/components/StickyHeader/StickyHeader.tsx new file mode 100644 index 0000000..f5e5395 --- /dev/null +++ b/frontend/src/components/StickyHeader/StickyHeader.tsx @@ -0,0 +1,13 @@ +import { component$, Slot } from '@builder.io/qwik' +import { useStylesScoped$ } from '@builder.io/qwik' +import styles from './StickyHeader.scss?inline' + +export default component$(() => { + useStylesScoped$(styles) + + return ( +
+ +
+ ) +}) diff --git a/frontend/src/components/TagDetailsCard/index.tsx b/frontend/src/components/TagDetailsCard/index.tsx new file mode 100644 index 0000000..1ce4751 --- /dev/null +++ b/frontend/src/components/TagDetailsCard/index.tsx @@ -0,0 +1,26 @@ +import { component$ } from '@builder.io/qwik' +import { TagDetails } from '~/types' +import { Sparkline } from '~/components/Sparkline' +import { formatHistory } from '~/utils/history' + +type Props = { + tagDetails: TagDetails +} + +export default component$((props: Props) => { + const history = props.tagDetails.history + const data = [...history] + .sort((r) => parseInt(r.day)) + .reverse() + .map((r) => parseInt(r.uses)) + + return ( +
+
+
#{props.tagDetails.name}
+
{formatHistory(history)}
+
+ +
+ ) +}) diff --git a/frontend/src/components/header/header.tsx b/frontend/src/components/header/header.tsx new file mode 100644 index 0000000..ea5ce93 --- /dev/null +++ b/frontend/src/components/header/header.tsx @@ -0,0 +1,4 @@ +import { component$ } from '@builder.io/qwik' +export default component$(() => { + return
The Wildebeest (Mastodon on Cloudflare)
+}) diff --git a/frontend/src/components/router-head/router-head.tsx b/frontend/src/components/router-head/router-head.tsx new file mode 100644 index 0000000..e067da6 --- /dev/null +++ b/frontend/src/components/router-head/router-head.tsx @@ -0,0 +1,32 @@ +import { component$ } from '@builder.io/qwik' +import { useDocumentHead, useLocation } from '@builder.io/qwik-city' + +/** + * The RouterHead component is placed inside of the document `` element. + */ +export const RouterHead = component$(() => { + const head = useDocumentHead() + const loc = useLocation() + + return ( + <> + {head.title} + + + + + + {head.meta.map((m) => ( + + ))} + + {head.links.map((l) => ( + + ))} + + {head.styles.map((s) => ( +