├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── PRs.yml │ ├── deploy.yml │ └── semgrep.yml ├── .gitignore ├── .node-version ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── .wrangler └── state │ └── d1 │ └── .gitkeep ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── backend ├── src │ ├── access │ │ └── index.ts │ ├── accounts │ │ ├── alias.ts │ │ └── getAccount.ts │ ├── activitypub │ │ ├── activities │ │ │ ├── accept.ts │ │ │ ├── announce.ts │ │ │ ├── create.ts │ │ │ ├── delete.ts │ │ │ ├── follow.ts │ │ │ ├── handle.ts │ │ │ ├── index.ts │ │ │ ├── like.ts │ │ │ ├── unfollow.ts │ │ │ └── update.ts │ │ ├── actors │ │ │ ├── follow.ts │ │ │ ├── inbox.ts │ │ │ ├── index.ts │ │ │ └── outbox.ts │ │ ├── deliver.ts │ │ ├── objects │ │ │ ├── collection.ts │ │ │ ├── image.ts │ │ │ ├── index.ts │ │ │ ├── link.ts │ │ │ ├── mention.ts │ │ │ └── note.ts │ │ └── peers.ts │ ├── cache │ │ └── index.ts │ ├── config │ │ ├── index.ts │ │ ├── rules.ts │ │ └── server.ts │ ├── database │ │ ├── d1.ts │ │ ├── index.ts │ │ ├── neon.sql │ │ └── neon.ts │ ├── errors │ │ └── index.ts │ ├── mastodon │ │ ├── account.ts │ │ ├── client.ts │ │ ├── follow.ts │ │ ├── hashtag.ts │ │ ├── idempotency.ts │ │ ├── like.ts │ │ ├── microformats.ts │ │ ├── notification.ts │ │ ├── reblog.ts │ │ ├── reply.ts │ │ ├── status.ts │ │ ├── subscription.ts │ │ ├── timeline.ts │ │ └── utils.ts │ ├── media │ │ ├── image.ts │ │ └── index.ts │ ├── middleware │ │ ├── error.ts │ │ ├── logger.ts │ │ └── main.ts │ ├── types │ │ ├── account.ts │ │ ├── configs.ts │ │ ├── context.ts │ │ ├── env.ts │ │ ├── index.ts │ │ ├── media.ts │ │ ├── notification.ts │ │ ├── objects.ts │ │ ├── queue.ts │ │ ├── status.ts │ │ └── tag.ts │ ├── utils │ │ ├── adjustLocalHostDomain.ts │ │ ├── auth │ │ │ ├── getAdmins.ts │ │ │ ├── getJwtEmail.ts │ │ │ └── isUserAuthenticated.ts │ │ ├── body.ts │ │ ├── cors.ts │ │ ├── getDomain.ts │ │ ├── handle.ts │ │ ├── http-signing-cavage.ts │ │ ├── http-signing.ts │ │ ├── httpsigjs │ │ │ ├── LICENSE │ │ │ ├── parser.ts │ │ │ ├── utils.ts │ │ │ └── verifier.ts │ │ ├── key-ops.ts │ │ ├── parse.ts │ │ └── sentry.ts │ ├── webfinger │ │ └── index.ts │ └── webpush │ │ ├── hkdf.ts │ │ ├── index.ts │ │ ├── jwk.ts │ │ ├── message.ts │ │ ├── util.ts │ │ ├── vapid.ts │ │ └── webpushinfos.ts └── test │ ├── activitypub.spec.ts │ ├── activitypub │ ├── follow.spec.ts │ └── handle.spec.ts │ ├── mastodon.spec.ts │ ├── mastodon │ ├── accounts.spec.ts │ ├── apps.spec.ts │ ├── instance.spec.ts │ ├── media.spec.ts │ ├── notifications.spec.ts │ ├── oauth.spec.ts │ ├── search.spec.ts │ ├── statuses.spec.ts │ ├── subscription.spec.ts │ ├── tags.spec.ts │ ├── timelines.spec.ts │ └── trends.spec.ts │ ├── middleware.spec.ts │ ├── nodeinfo.spec.ts │ ├── shared.utils.ts │ ├── test-data.ts │ ├── utils.spec.ts │ ├── utils.ts │ ├── webfinger.spec.ts │ └── wildebeest │ └── settings.spec.ts ├── config ├── accounts.ts ├── ua.ts └── versions.ts ├── consumer ├── package.json ├── src │ ├── deliver.ts │ ├── inbox.ts │ ├── index.ts │ └── sentry.ts ├── test │ └── consumer.spec.ts ├── tsconfig.json ├── wrangler.toml └── yarn.lock ├── do ├── package.json ├── src │ └── index.ts ├── tsconfig.json └── wrangler.toml ├── docs ├── access-policy.md ├── getting-started.md ├── other-services.md ├── requirements.md ├── supported-clients.md ├── troubleshooting.md └── updating.md ├── frontend ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── adaptors │ └── cloudflare-pages │ │ └── vite.config.ts ├── jest.config.js ├── mock-db │ ├── init.ts │ ├── run.mjs │ └── worker.ts ├── package.json ├── postcss.config.cjs ├── public │ ├── _headers │ ├── _redirects │ ├── _routes.json │ ├── assets │ │ └── wildebeest-splash.png │ ├── favicon.svg │ ├── manifest.json │ └── robots.txt ├── src │ ├── components │ │ ├── Accordion │ │ │ └── Accordion.tsx │ │ ├── AccountCard │ │ │ └── AccountCard.tsx │ │ ├── HtmlContent │ │ │ ├── HtmlContent.scss │ │ │ └── HtmlContent.tsx │ │ ├── MastodonLogo.tsx │ │ ├── MediaGallery.tsx │ │ │ ├── Image.tsx │ │ │ ├── ImagesModal.tsx │ │ │ ├── Video.tsx │ │ │ ├── index.scss │ │ │ └── index.tsx │ │ ├── ResultMessage │ │ │ └── index.tsx │ │ ├── Settings │ │ │ ├── SubmitButton.tsx │ │ │ ├── TextArea.tsx │ │ │ └── TextInput.tsx │ │ ├── Sparkline │ │ │ └── index.tsx │ │ ├── Spinner │ │ │ └── index.tsx │ │ ├── Status │ │ │ └── index.tsx │ │ ├── StatusInfoTray │ │ │ └── StatusInfoTray.tsx │ │ ├── StatusesPanel │ │ │ └── StatusesPanel.tsx │ │ ├── StickyHeader │ │ │ └── StickyHeader.tsx │ │ ├── TagDetailsCard │ │ │ └── index.tsx │ │ ├── avatar │ │ │ └── index.tsx │ │ ├── header │ │ │ └── header.tsx │ │ ├── layout │ │ │ ├── LeftColumn │ │ │ │ └── LeftColumn.tsx │ │ │ └── RightColumn │ │ │ │ └── RightColumn.tsx │ │ └── router-head │ │ │ └── router-head.tsx │ ├── dummyData │ │ ├── accounts.ts │ │ ├── generateDummyStatus.ts │ │ ├── getRandomDateInThePastYear.ts │ │ ├── index.tsx │ │ ├── links.ts │ │ ├── statuses.ts │ │ └── tags.ts │ ├── entry.cloudflare-pages.tsx │ ├── entry.ssr.tsx │ ├── root.tsx │ ├── routes │ │ ├── (admin) │ │ │ ├── first-login │ │ │ │ └── index.tsx │ │ │ ├── oauth │ │ │ │ └── authorize │ │ │ │ │ └── index.tsx │ │ │ └── settings │ │ │ │ ├── (admin) │ │ │ │ ├── layout.tsx │ │ │ │ ├── migration │ │ │ │ │ └── index.tsx │ │ │ │ └── server-settings │ │ │ │ │ ├── about │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── branding │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── rules │ │ │ │ │ ├── edit │ │ │ │ │ └── [id] │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── (auth) │ │ │ │ ├── aliases │ │ │ │ │ └── index.tsx │ │ │ │ └── layout.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── layout.tsx │ │ ├── (frontend) │ │ │ ├── [accountId] │ │ │ │ ├── [...] │ │ │ │ │ └── index.tsx │ │ │ │ ├── [statusId] │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── with_replies │ │ │ │ │ └── index.tsx │ │ │ ├── about │ │ │ │ └── index.tsx │ │ │ ├── explore │ │ │ │ ├── index.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── links │ │ │ │ │ └── index.hidden.tsx │ │ │ │ └── tags │ │ │ │ │ └── index.hidden.tsx │ │ │ ├── layout.tsx │ │ │ ├── public │ │ │ │ ├── index.tsx │ │ │ │ └── local │ │ │ │ │ └── index.tsx │ │ │ └── tags │ │ │ │ └── [tag] │ │ │ │ └── index.tsx │ │ ├── index.tsx │ │ ├── layout.tsx │ │ └── service-worker.ts │ ├── styles.scss │ ├── types.ts │ └── utils │ │ ├── adminLoader.ts │ │ ├── authLoader.ts │ │ ├── dateTime.ts │ │ ├── getCommitHash.ts │ │ ├── getDisplayNameElement.tsx │ │ ├── getDocumentHead.ts │ │ ├── getErrorHtml │ │ ├── error-raw.html │ │ └── getErrorHtml.ts │ │ ├── getNotFoundHtml │ │ ├── getNotFoundHtml.ts │ │ └── not-found-raw.html │ │ ├── history.ts │ │ ├── innerHtmlContent.scss │ │ ├── instanceConfig.ts │ │ ├── numbers.ts │ │ ├── useAccountUrl.ts │ │ └── useDomain.ts ├── tailwind.config.cjs ├── tsconfig.json ├── vite.config.ts └── yarn.lock ├── functions ├── .well-known │ ├── nodeinfo.ts │ └── webfinger.ts ├── [[path]].ts ├── _middleware.ts ├── ap │ ├── _middleware.ts │ ├── o │ │ └── [id].ts │ └── users │ │ ├── [id].ts │ │ └── [id] │ │ ├── followers.ts │ │ ├── followers │ │ └── page.ts │ │ ├── following.ts │ │ ├── following │ │ └── page.ts │ │ ├── inbox.ts │ │ ├── outbox.ts │ │ └── outbox │ │ └── page.ts ├── api │ ├── _middleware.ts │ ├── v1 │ │ ├── accounts │ │ │ ├── [id].ts │ │ │ ├── [id] │ │ │ │ ├── featured_tags.ts │ │ │ │ ├── follow.ts │ │ │ │ ├── followers.ts │ │ │ │ ├── following.ts │ │ │ │ ├── lists.ts │ │ │ │ ├── statuses.ts │ │ │ │ └── unfollow.ts │ │ │ ├── relationships.ts │ │ │ ├── update_credentials.ts │ │ │ └── verify_credentials.ts │ │ ├── apps.ts │ │ ├── apps │ │ │ └── verify_credentials.ts │ │ ├── blocks.ts │ │ ├── custom_emojis.ts │ │ ├── filters.ts │ │ ├── instance.ts │ │ ├── instance │ │ │ ├── peers.ts │ │ │ └── rules.ts │ │ ├── mutes.ts │ │ ├── notifications.ts │ │ ├── notifications │ │ │ └── [id].ts │ │ ├── push │ │ │ └── subscription.ts │ │ ├── statuses.ts │ │ ├── statuses │ │ │ ├── [id].ts │ │ │ └── [id] │ │ │ │ ├── context.ts │ │ │ │ ├── favourite.ts │ │ │ │ └── reblog.ts │ │ ├── tags │ │ │ └── [tag].ts │ │ ├── timelines │ │ │ ├── home.ts │ │ │ ├── public.ts │ │ │ └── tag │ │ │ │ └── [tag].ts │ │ └── trends │ │ │ ├── links.ts │ │ │ └── statuses.ts │ └── v2 │ │ ├── instance.ts │ │ ├── media.ts │ │ ├── media │ │ └── [id].ts │ │ └── search.ts ├── first-login.ts ├── nodeinfo │ ├── 2.0.ts │ └── 2.1.ts └── oauth │ ├── authorize.ts │ └── token.ts ├── jest.config.js ├── migrations ├── 0000_initial.sql ├── 0001_add-unique-following.sql ├── 0002_add-target-outbox_objects.sql ├── 0003_add_peers.sql ├── 0004_add_outbox_objects_indices.sql ├── 0005_add_idempotency_keys.sql ├── 0006_add_note_hashtags.sql ├── 0007_change_subscriptions_id.sql ├── 0008_add_server-settings.sql ├── 0009_add_admin.sql └── 0010_add_ui_client.sql ├── package.json ├── playwright.config.ts ├── scripts ├── generate-one-click-deploy-button.mjs └── generate-vapid-keys.mjs ├── tf └── main.tf ├── tsconfig.json ├── ui-e2e-tests ├── about-page.spec.ts ├── account-page.spec.ts ├── custom-emojis.spec.ts ├── explore-page.spec.ts ├── infinite-scrolling.spec.ts ├── invidivual-toot.spec.ts ├── page-not-found.spec.ts └── seo.spec.ts ├── wrangler.toml └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | **/.DS_Store 3 | *. 4 | .vscode/settings.json 5 | .history 6 | .yarn 7 | bazel-* 8 | bazel-bin 9 | bazel-out 10 | bazel-qwik 11 | bazel-testlogs 12 | dist 13 | dist-dev 14 | lib 15 | lib-types 16 | etc 17 | external 18 | node_modules 19 | temp 20 | tsc-out 21 | tsdoc-metadata.json 22 | target 23 | output 24 | rollup.config.js 25 | build 26 | .cache 27 | .vscode 28 | .rollup.cache 29 | dist 30 | tsconfig.tsbuildinfo 31 | vite.config.ts 32 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:@typescript-eslint/recommended', 5 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 6 | ], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | tsconfigRootDir: __dirname, 10 | project: ['./tsconfig.json'], 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | root: true, 14 | rules: { 15 | 'prefer-const': 'error', 16 | 'no-var': 'error', 17 | '@typescript-eslint/no-unsafe-return': 'error', 18 | '@typescript-eslint/no-unused-vars': 'error', 19 | 'no-console': 'off', 20 | 'no-constant-condition': 'off', 21 | '@typescript-eslint/require-await': 'off', 22 | '@typescript-eslint/no-unsafe-call': 'error', 23 | '@typescript-eslint/await-thenable': 'error', 24 | '@typescript-eslint/no-misused-promises': 'error', 25 | /* 26 | Note: the following rules have been set to off so that linting 27 | can pass with the current code, but we need to gradually 28 | re-enable most of them 29 | */ 30 | '@typescript-eslint/no-unsafe-assignment': 'off', 31 | '@typescript-eslint/no-unsafe-argument': 'off', 32 | '@typescript-eslint/no-unsafe-member-access': 'off', 33 | '@typescript-eslint/restrict-plus-operands': 'off', 34 | '@typescript-eslint/restrict-template-expressions': 'off', 35 | '@typescript-eslint/no-unnecessary-type-assertion': 'off', 36 | '@typescript-eslint/no-explicit-any': 'off', 37 | '@typescript-eslint/no-inferrable-types': 'off', 38 | '@typescript-eslint/no-non-null-assertion': 'off', 39 | '@typescript-eslint/ban-ts-comment': 'off', 40 | '@typescript-eslint/no-empty-function': 'off', 41 | '@typescript-eslint/ban-types': 'off', 42 | '@typescript-eslint/no-empty-interface': 'off', 43 | }, 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: {} 3 | workflow_dispatch: {} 4 | push: 5 | branches: 6 | - main 7 | - master 8 | schedule: 9 | - cron: '0 0 * * *' 10 | name: Semgrep config 11 | jobs: 12 | semgrep: 13 | name: semgrep/ci 14 | runs-on: ubuntu-latest 15 | env: 16 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 17 | SEMGREP_URL: https://cloudflare.semgrep.dev 18 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 20 | container: 21 | image: semgrep/semgrep 22 | steps: 23 | - uses: actions/checkout@v4 24 | - run: semgrep ci 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn-error.log 3 | package-lock.json 4 | .wrangler/state/d1/*.sqlite3 5 | .DS_Store 6 | /test-results/ 7 | /playwright-report/ 8 | /playwright/.cache/ 9 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 16.13.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Files Prettier should not format 2 | **/*.log 3 | **/.DS_Store 4 | *. 5 | dist 6 | node_modules 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "useTabs": true, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["ms-playwright.playwright"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "activitypub", 4 | "cdate", 5 | "favourited", 6 | "favourites", 7 | "reblog", 8 | "reblogged", 9 | "reblogger", 10 | "reblogs" 11 | ] 12 | } -------------------------------------------------------------------------------- /.wrangler/state/d1/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/wildebeest/b056670a7204bc4d852c8a0cda9a3c9e39f8a0e1/.wrangler/state/d1/.gitkeep -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Cloudflare, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Vulnerabilities 2 | 3 | Please see [this page](https://www.cloudflare.com/.well-known/security.txt) for information on how to report a vulnerability to Cloudflare. Thanks! 4 | -------------------------------------------------------------------------------- /backend/src/accounts/alias.ts: -------------------------------------------------------------------------------- 1 | import { setActorAlias } from 'wildebeest/backend/src/activitypub/actors' 2 | import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver' 3 | import { getSigningKey } from 'wildebeest/backend/src/mastodon/account' 4 | import * as follow from 'wildebeest/backend/src/activitypub/activities/follow' 5 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 6 | import { parseHandle } from 'wildebeest/backend/src/utils/parse' 7 | import { queryAcct } from 'wildebeest/backend/src/webfinger' 8 | import { type Database } from 'wildebeest/backend/src/database' 9 | 10 | export async function addAlias(db: Database, alias: string, connectedActor: Actor, userKEK: string, domain: string) { 11 | const handle = parseHandle(alias) 12 | const acct = `${handle.localPart}@${handle.domain}` 13 | if (handle.domain === null) { 14 | throw new Error("account migration within an instance isn't supported") 15 | } 16 | 17 | const actor = await queryAcct(handle.domain, db, acct) 18 | if (actor === null) { 19 | throw new Error('actor not found') 20 | } 21 | 22 | await setActorAlias(db, connectedActor.id, actor.id) 23 | 24 | // For Mastodon to deliver the Move Activity we need to be following the 25 | // "moving from" actor. 26 | { 27 | const activity = follow.create(connectedActor, actor) 28 | const signingKey = await getSigningKey(userKEK, db, connectedActor) 29 | await deliverToActor(signingKey, connectedActor, actor, activity, domain) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/accounts/getAccount.ts: -------------------------------------------------------------------------------- 1 | // https://docs.joinmastodon.org/methods/accounts/#get 2 | 3 | import { type Database } from 'wildebeest/backend/src/database' 4 | import { actorURL, getActorById } from 'wildebeest/backend/src/activitypub/actors' 5 | import { parseHandle } from 'wildebeest/backend/src/utils/parse' 6 | import type { Handle } from 'wildebeest/backend/src/utils/parse' 7 | import { queryAcct } from 'wildebeest/backend/src/webfinger/index' 8 | import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' 9 | import { MastodonAccount } from '../types' 10 | import { adjustLocalHostDomain } from '../utils/adjustLocalHostDomain' 11 | 12 | export async function getAccount(domain: string, accountId: string, db: Database): Promise { 13 | const handle = parseHandle(accountId) 14 | 15 | if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) { 16 | // Retrieve the statuses from a local user 17 | return getLocalAccount(domain, db, handle) 18 | } else if (handle.domain !== null) { 19 | // Retrieve the statuses of a remote actor 20 | const acct = `${handle.localPart}@${handle.domain}` 21 | return getRemoteAccount(handle, acct, db) 22 | } else { 23 | return null 24 | } 25 | } 26 | 27 | async function getRemoteAccount(handle: Handle, acct: string, db: Database): Promise { 28 | // TODO: using webfinger isn't the optimal implementation. We could cache 29 | // the object in D1 and directly query the remote API, indicated by the actor's 30 | // url field. For now, let's keep it simple. 31 | const actor = await queryAcct(handle.domain!, db, acct) 32 | if (actor === null) { 33 | return null 34 | } 35 | 36 | return await loadExternalMastodonAccount(acct, actor, true) 37 | } 38 | 39 | async function getLocalAccount(domain: string, db: Database, handle: Handle): Promise { 40 | const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart) 41 | 42 | const actor = await getActorById(db, actorId) 43 | if (actor === null) { 44 | return null 45 | } 46 | 47 | return await loadLocalMastodonAccount(db, actor) 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/accept.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from '../objects' 2 | import type { Actor } from '../actors' 3 | import type { Activity } from '.' 4 | 5 | const ACCEPT = 'Accept' 6 | 7 | export function create(actor: Actor, object: APObject): Activity { 8 | return { 9 | '@context': 'https://www.w3.org/ns/activitystreams', 10 | type: ACCEPT, 11 | actor: actor.id, 12 | object, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/announce.ts: -------------------------------------------------------------------------------- 1 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce 2 | 3 | import type { Actor } from '../actors' 4 | import type { Activity } from '.' 5 | 6 | const ANNOUNCE = 'Announce' 7 | 8 | export function create(actor: Actor, object: URL): Activity { 9 | return { 10 | '@context': 'https://www.w3.org/ns/activitystreams', 11 | type: ANNOUNCE, 12 | actor: actor.id, 13 | object, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/create.ts: -------------------------------------------------------------------------------- 1 | import type { Note } from '../objects/note' 2 | import type { Actor } from '../actors' 3 | import type { Activity } from '.' 4 | import * as activity from '.' 5 | 6 | const CREATE = 'Create' 7 | 8 | export function create(domain: string, actor: Actor, object: Note): Activity { 9 | const a: Activity = { 10 | '@context': [ 11 | 'https://www.w3.org/ns/activitystreams', 12 | { 13 | ostatus: 'http://ostatus.org#', 14 | atomUri: 'ostatus:atomUri', 15 | inReplyToAtomUri: 'ostatus:inReplyToAtomUri', 16 | conversation: 'ostatus:conversation', 17 | sensitive: 'as:sensitive', 18 | toot: 'http://joinmastodon.org/ns#', 19 | votersCount: 'toot:votersCount', 20 | }, 21 | ], 22 | id: activity.uri(domain), 23 | type: CREATE, 24 | actor: actor.id, 25 | object, 26 | } 27 | 28 | if (object.published) { 29 | a.published = object.published 30 | } 31 | if (object.to) { 32 | a.to = object.to 33 | } 34 | if (object.cc) { 35 | a.cc = object.cc 36 | } 37 | 38 | return a 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/delete.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from '../objects' 2 | import type { Actor } from '../actors' 3 | import type { Activity } from '.' 4 | import * as activity from '.' 5 | 6 | const DELETE = 'Delete' 7 | 8 | export function create(domain: string, actor: Actor, object: APObject): Activity { 9 | return { 10 | '@context': ['https://www.w3.org/ns/activitystreams'], 11 | id: activity.uri(domain), 12 | type: DELETE, 13 | actor: actor.id, 14 | object, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/follow.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from '../objects' 2 | import type { Actor } from '../actors' 3 | import type { Activity } from '.' 4 | 5 | const FOLLOW = 'Follow' 6 | 7 | export function create(actor: Actor, object: APObject): Activity { 8 | return { 9 | '@context': 'https://www.w3.org/ns/activitystreams', 10 | type: FOLLOW, 11 | actor: actor.id, 12 | object: object.id, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/index.ts: -------------------------------------------------------------------------------- 1 | export type Activity = any 2 | 3 | export const PUBLIC_GROUP = 'https://www.w3.org/ns/activitystreams#Public' 4 | 5 | // Generate a unique ID. Note that currently the generated URL aren't routable. 6 | export function uri(domain: string): URL { 7 | const id = crypto.randomUUID() 8 | return new URL('/ap/a/' + id, 'https://' + domain) 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/like.ts: -------------------------------------------------------------------------------- 1 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like 2 | 3 | import type { Actor } from '../actors' 4 | import type { Activity } from '.' 5 | 6 | const Like = 'Like' 7 | 8 | export function create(actor: Actor, object: URL): Activity { 9 | return { 10 | '@context': 'https://www.w3.org/ns/activitystreams', 11 | type: Like, 12 | actor: actor.id, 13 | object, 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/unfollow.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from '../objects' 2 | import type { Actor } from '../actors' 3 | import type { Activity } from '.' 4 | import * as follow from './follow' 5 | 6 | const UNDO = 'Undo' 7 | 8 | export function create(actor: Actor, object: APObject): Activity { 9 | return { 10 | '@context': 'https://www.w3.org/ns/activitystreams', 11 | type: UNDO, 12 | actor: actor.id, 13 | object: follow.create(actor, object), 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/activitypub/activities/update.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from '../objects' 2 | import type { Actor } from '../actors' 3 | import type { Activity } from '.' 4 | import * as activity from '.' 5 | 6 | const UPDATE = 'Update' 7 | 8 | export function create(domain: string, actor: Actor, object: APObject): Activity { 9 | return { 10 | '@context': ['https://www.w3.org/ns/activitystreams'], 11 | id: activity.uri(domain), 12 | type: UPDATE, 13 | actor: actor.id, 14 | object, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/activitypub/actors/follow.ts: -------------------------------------------------------------------------------- 1 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 2 | import * as actors from 'wildebeest/backend/src/activitypub/actors' 3 | import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/objects/collection' 4 | import { getMetadata, loadItems } from 'wildebeest/backend/src/activitypub/objects/collection' 5 | import { type Database } from 'wildebeest/backend/src/database' 6 | 7 | export async function countFollowing(actor: Actor): Promise { 8 | const collection = await getMetadata(actor.following) 9 | return collection.totalItems 10 | } 11 | 12 | export async function countFollowers(actor: Actor): Promise { 13 | const collection = await getMetadata(actor.followers) 14 | return collection.totalItems 15 | } 16 | 17 | export async function getFollowers(actor: Actor): Promise> { 18 | const collection = await getMetadata(actor.followers) 19 | collection.items = await loadItems(collection) 20 | return collection 21 | } 22 | 23 | export async function getFollowing(actor: Actor): Promise> { 24 | const collection = await getMetadata(actor.following) 25 | collection.items = await loadItems(collection) 26 | return collection 27 | } 28 | 29 | export async function loadActors(db: Database, collection: OrderedCollection): Promise> { 30 | const promises = collection.items.map((item) => { 31 | const actorId = new URL(item) 32 | return actors.getAndCache(actorId, db) 33 | }) 34 | 35 | return Promise.all(promises) 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/activitypub/actors/inbox.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from 'wildebeest/backend/src/activitypub/objects' 2 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 3 | import { type Database } from 'wildebeest/backend/src/database' 4 | 5 | export async function addObjectInInbox(db: Database, actor: Actor, obj: APObject) { 6 | const id = crypto.randomUUID() 7 | const out = await db 8 | .prepare('INSERT INTO inbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)') 9 | .bind(id, actor.id.toString(), obj.id.toString()) 10 | .run() 11 | if (!out.success) { 12 | throw new Error('SQL error: ' + out.error) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/activitypub/actors/outbox.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from 'wildebeest/backend/src/activitypub/objects' 2 | import type { Activity } from 'wildebeest/backend/src/activitypub/activities' 3 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 4 | import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/objects/collection' 5 | import { getMetadata, loadItems } from 'wildebeest/backend/src/activitypub/objects/collection' 6 | import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities' 7 | import { type Database } from 'wildebeest/backend/src/database' 8 | 9 | export async function addObjectInOutbox( 10 | db: Database, 11 | actor: Actor, 12 | obj: APObject, 13 | published_date?: string, 14 | target: string = PUBLIC_GROUP 15 | ) { 16 | const id = crypto.randomUUID() 17 | let out: any = null 18 | 19 | if (published_date !== undefined) { 20 | out = await db 21 | .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date, target) VALUES(?, ?, ?, ?, ?)') 22 | .bind(id, actor.id.toString(), obj.id.toString(), published_date, target) 23 | .run() 24 | } else { 25 | out = await db 26 | .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, target) VALUES(?, ?, ?, ?)') 27 | .bind(id, actor.id.toString(), obj.id.toString(), target) 28 | .run() 29 | } 30 | if (!out.success) { 31 | throw new Error('SQL error: ' + out.error) 32 | } 33 | } 34 | 35 | export async function get(actor: Actor): Promise> { 36 | const collection = await getMetadata(actor.outbox) 37 | collection.items = await loadItems(collection, 20) 38 | 39 | return collection 40 | } 41 | 42 | export async function countStatuses(actor: Actor): Promise { 43 | const metadata = await getMetadata(actor.outbox) 44 | return metadata.totalItems 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/activitypub/objects/collection.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from 'wildebeest/backend/src/activitypub/objects' 2 | 3 | export interface Collection extends APObject { 4 | totalItems: number 5 | current?: string 6 | first: URL 7 | last: URL 8 | items: Array 9 | } 10 | 11 | export interface OrderedCollection extends Collection {} 12 | 13 | export interface OrderedCollectionPage extends APObject { 14 | next?: string 15 | orderedItems: Array 16 | } 17 | 18 | const headers = { 19 | accept: 'application/activity+json', 20 | } 21 | 22 | export async function getMetadata(url: URL): Promise> { 23 | const res = await fetch(url, { headers }) 24 | if (!res.ok) { 25 | throw new Error(`${url} returned ${res.status}`) 26 | } 27 | 28 | return res.json>() 29 | } 30 | 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | export async function loadItems(collection: OrderedCollection, max?: number): Promise> { 33 | // FIXME: implement max 34 | 35 | const items = [] 36 | let pageUrl = collection.first 37 | 38 | while (true) { 39 | const page = await loadPage(pageUrl) 40 | if (page === null) { 41 | break 42 | } 43 | items.push(...page.orderedItems) 44 | if (page.next) { 45 | pageUrl = new URL(page.next) 46 | } else { 47 | break 48 | } 49 | } 50 | 51 | return items 52 | } 53 | 54 | export async function loadPage(url: URL): Promise> { 55 | const res = await fetch(url, { headers }) 56 | if (!res.ok) { 57 | console.warn(`${url} return ${res.status}`) 58 | return null 59 | } 60 | 61 | return res.json>() 62 | } 63 | -------------------------------------------------------------------------------- /backend/src/activitypub/objects/image.ts: -------------------------------------------------------------------------------- 1 | import * as objects from '.' 2 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 3 | import { type Database } from 'wildebeest/backend/src/database' 4 | 5 | export const IMAGE = 'Image' 6 | 7 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image 8 | export interface Image extends objects.Document { 9 | description?: string 10 | } 11 | 12 | export async function createImage(domain: string, db: Database, actor: Actor, properties: any): Promise { 13 | const actorId = new URL(actor.id) 14 | return (await objects.createObject(domain, db, IMAGE, properties, actorId, true)) as Image 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/activitypub/objects/link.ts: -------------------------------------------------------------------------------- 1 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link 2 | export interface Link { 3 | type: string 4 | 5 | href: URL 6 | name: string 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/activitypub/objects/mention.ts: -------------------------------------------------------------------------------- 1 | import type { Link } from 'wildebeest/backend/src/activitypub/objects/link' 2 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 3 | import { urlToHandle } from 'wildebeest/backend/src/utils/handle' 4 | 5 | // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-mention 6 | export interface Mention extends Link {} 7 | 8 | export function newMention(actor: Actor): Mention { 9 | return { 10 | type: 'Mention', 11 | href: actor.id, 12 | name: urlToHandle(actor.id), 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/src/activitypub/peers.ts: -------------------------------------------------------------------------------- 1 | import { getResultsField } from 'wildebeest/backend/src/mastodon/utils' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | 4 | export async function getPeers(db: Database): Promise> { 5 | const query = `SELECT domain FROM peers ` 6 | const statement = db.prepare(query) 7 | 8 | return getResultsField(statement, 'domain') 9 | } 10 | 11 | export async function addPeer(db: Database, domain: string): Promise { 12 | const query = db.qb.insertOrIgnore(` 13 | INTO peers (domain) VALUES (?) 14 | `) 15 | 16 | const out = await db.prepare(query).bind(domain).run() 17 | if (!out.success) { 18 | throw new Error('SQL error: ' + out.error) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/cache/index.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'wildebeest/consumer/src' 2 | 3 | const CACHE_DO_NAME = 'cachev1' 4 | 5 | export interface Cache { 6 | get(key: string): Promise 7 | put(key: string, value: T): Promise 8 | } 9 | 10 | export function cacheFromEnv(env: Env): Cache { 11 | return { 12 | async get(key: string): Promise { 13 | const id = env.DO_CACHE.idFromName(CACHE_DO_NAME) 14 | const stub = env.DO_CACHE.get(id) 15 | 16 | const res = await stub.fetch('http://cache/' + key) 17 | if (!res.ok) { 18 | if (res.status === 404) { 19 | return null 20 | } 21 | 22 | throw new Error(`DO cache returned ${res.status}: ${await res.text()}`) 23 | } 24 | 25 | return (await res.json()) as T 26 | }, 27 | 28 | async put(key: string, value: T): Promise { 29 | const id = env.DO_CACHE.idFromName(CACHE_DO_NAME) 30 | const stub = env.DO_CACHE.get(id) 31 | 32 | const res = await stub.fetch('http://cache/', { 33 | method: 'PUT', 34 | body: JSON.stringify({ key, value }), 35 | }) 36 | 37 | if (!res.ok) { 38 | throw new Error(`DO cache returned ${res.status}: ${await res.text()}`) 39 | } 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'wildebeest/backend/src/types/env' 2 | import type { JWK } from 'wildebeest/backend/src/webpush/jwk' 3 | 4 | export const DEFAULT_THUMBNAIL = 5 | 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/thumbnail' 6 | 7 | export function getVAPIDKeys(env: Env): JWK { 8 | const value: JWK = JSON.parse(env.VAPID_JWK) 9 | return value 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/config/rules.ts: -------------------------------------------------------------------------------- 1 | import { type Database } from 'wildebeest/backend/src/database' 2 | 3 | export async function getRules(db: Database): Promise> { 4 | const query = `SELECT * from server_rules;` 5 | const result = await db.prepare(query).all<{ id: string; text: string }>() 6 | 7 | if (!result.success) { 8 | throw new Error('SQL error: ' + result.error) 9 | } 10 | 11 | return result.results ?? [] 12 | } 13 | 14 | export async function upsertRule(db: Database, rule: { id?: number; text: string } | string) { 15 | const id = typeof rule === 'string' ? null : rule.id ?? null 16 | const text = typeof rule === 'string' ? rule : rule.text 17 | return await db 18 | .prepare( 19 | `INSERT INTO server_rules (id, text) 20 | VALUES (?, ?) 21 | ON CONFLICT(id) DO UPDATE SET text=excluded.text;` 22 | ) 23 | .bind(id, text) 24 | .run() 25 | } 26 | 27 | export async function deleteRule(db: Database, ruleId: number) { 28 | return await db.prepare('DELETE FROM server_rules WHERE id=?').bind(ruleId).run() 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/config/server.ts: -------------------------------------------------------------------------------- 1 | import { type Database } from 'wildebeest/backend/src/database' 2 | import { type ServerSettingsData } from 'wildebeest/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout' 3 | 4 | export async function getSettings(db: Database): Promise { 5 | const query = `SELECT * from server_settings` 6 | const result = await db.prepare(query).all<{ setting_name: string; setting_value: string }>() 7 | 8 | const data = (result.results ?? []).reduce( 9 | (settings, { setting_name, setting_value }) => ({ 10 | ...settings, 11 | [setting_name]: setting_value, 12 | }), 13 | {} as Object 14 | ) 15 | 16 | if (!result.success) { 17 | throw new Error('SQL Error: ' + result.error) 18 | } 19 | 20 | return data as ServerSettingsData 21 | } 22 | 23 | export async function updateSettings(db: Database, data: Partial) { 24 | const result = await upsertServerSettings(db, data) 25 | if (result && !result.success) { 26 | throw new Error('SQL Error: ' + result.error) 27 | } 28 | 29 | return new Response('', { status: 200 }) 30 | } 31 | 32 | export async function upsertServerSettings(db: Database, settings: Partial) { 33 | const settingsEntries = Object.entries(settings) 34 | 35 | if (!settingsEntries.length) { 36 | return null 37 | } 38 | 39 | const query = ` 40 | INSERT INTO server_settings (setting_name, setting_value) 41 | VALUES ${settingsEntries.map(() => `(?, ?)`).join(', ')} 42 | ON CONFLICT(setting_name) DO UPDATE SET setting_value=excluded.setting_value 43 | ` 44 | 45 | return await db 46 | .prepare(query) 47 | .bind(...settingsEntries.flat()) 48 | .run() 49 | } 50 | -------------------------------------------------------------------------------- /backend/src/database/d1.ts: -------------------------------------------------------------------------------- 1 | import { type Database, QueryBuilder } from 'wildebeest/backend/src/database' 2 | import type { Env } from 'wildebeest/backend/src/types/env' 3 | 4 | const qb: QueryBuilder = { 5 | jsonExtract(obj: string, prop: string): string { 6 | return `json_extract(${obj}, '$.${prop}')` 7 | }, 8 | 9 | jsonExtractIsNull(obj: string, prop: string): string { 10 | return `${qb.jsonExtract(obj, prop)} IS NULL` 11 | }, 12 | 13 | set(array: string): string { 14 | return `(SELECT value FROM json_each(${array}))` 15 | }, 16 | 17 | epoch(): string { 18 | return '00-00-00 00:00:00' 19 | }, 20 | 21 | insertOrIgnore(q: string): string { 22 | return `INSERT OR IGNORE ${q}` 23 | }, 24 | 25 | psqlOnly(): string { 26 | return '' 27 | }, 28 | 29 | jsonSet(obj: string, field: string, value: string): string { 30 | return `json_set(${obj}, '$.${field}', ${value})` 31 | }, 32 | } 33 | 34 | export default function make({ DATABASE }: Pick): Database { 35 | const db = DATABASE as any 36 | db.qb = qb 37 | db.client = 'd1' 38 | 39 | return db as Database 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/database/index.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from 'wildebeest/backend/src/types/env' 2 | import d1 from './d1' 3 | import neon from './neon' 4 | 5 | export interface Result { 6 | results?: T[] 7 | success: boolean 8 | error?: string 9 | meta: any 10 | } 11 | 12 | export interface Database { 13 | prepare(query: string): PreparedStatement 14 | dump(): Promise 15 | batch(statements: PreparedStatement[]): Promise[]> 16 | exec(query: string): Promise> 17 | qb: QueryBuilder 18 | client: string 19 | } 20 | 21 | export interface PreparedStatement { 22 | bind(...values: any[]): PreparedStatement 23 | first(colName?: string): Promise 24 | run(): Promise> 25 | all(): Promise> 26 | raw(): Promise 27 | } 28 | 29 | export interface QueryBuilder { 30 | jsonExtract(obj: string, prop: string): string 31 | jsonExtractIsNull(obj: string, prop: string): string 32 | set(array: string): string 33 | epoch(): string 34 | insertOrIgnore(q: string): string 35 | psqlOnly(raw: string): string 36 | jsonSet(obj: string, field: string, value: string): string 37 | } 38 | 39 | export async function getDatabase(env: Pick): Promise { 40 | if (env.NEON_DATABASE_URL !== undefined) { 41 | return neon(env) 42 | } 43 | 44 | return d1(env) 45 | } 46 | -------------------------------------------------------------------------------- /backend/src/mastodon/client.ts: -------------------------------------------------------------------------------- 1 | import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | 4 | export interface Client { 5 | id: string 6 | secret: string 7 | name: string 8 | redirect_uris: string 9 | scopes: string 10 | website?: string 11 | } 12 | 13 | export async function createClient( 14 | db: Database, 15 | name: string, 16 | redirect_uris: string, 17 | scopes: string, 18 | website?: string 19 | ): Promise { 20 | const id = crypto.randomUUID() 21 | 22 | const secretBytes = new Uint8Array(64) 23 | crypto.getRandomValues(secretBytes) 24 | 25 | const secret = arrayBufferToBase64(secretBytes.buffer) 26 | 27 | const query = ` 28 | INSERT INTO clients (id, secret, name, redirect_uris, website, scopes) 29 | VALUES (?, ?, ?, ?, ?, ?) 30 | ` 31 | const { success, error } = await db 32 | .prepare(query) 33 | .bind(id, secret, name, redirect_uris, website === undefined ? null : website, scopes) 34 | .run() 35 | if (!success) { 36 | throw new Error('SQL error: ' + error) 37 | } 38 | 39 | return { 40 | id: id, 41 | secret: secret, 42 | name: name, 43 | redirect_uris: redirect_uris, 44 | website: website, 45 | scopes: scopes, 46 | } 47 | } 48 | 49 | export async function getClientById(db: Database, id: string): Promise { 50 | const stmt = db.prepare('SELECT * FROM clients WHERE id=?').bind(id) 51 | const { results } = await stmt.all() 52 | if (!results || results.length === 0) { 53 | return null 54 | } 55 | const row: any = results[0] 56 | return { 57 | id: id, 58 | secret: row.secret, 59 | name: row.name, 60 | redirect_uris: row.redirect_uris, 61 | website: row.website, 62 | scopes: row.scopes, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /backend/src/mastodon/hashtag.ts: -------------------------------------------------------------------------------- 1 | import type { Note } from 'wildebeest/backend/src/activitypub/objects/note' 2 | import type { Tag } from 'wildebeest/backend/src/types/tag' 3 | import { type Database } from 'wildebeest/backend/src/database' 4 | 5 | export type Hashtag = string 6 | 7 | const HASHTAG_RE = /#([\S]+)/g 8 | 9 | export function getHashtags(input: string): Array { 10 | const matches = input.matchAll(HASHTAG_RE) 11 | if (matches === null) { 12 | return [] 13 | } 14 | 15 | return [...matches].map((match) => match[1]) 16 | } 17 | 18 | export async function insertHashtags(db: Database, note: Note, values: Array): Promise { 19 | const queries = [] 20 | const stmt = db.prepare(` 21 | INSERT INTO note_hashtags (value, object_id) 22 | VALUES (?, ?) 23 | `) 24 | 25 | for (let i = 0, len = values.length; i < len; i++) { 26 | const value = values[i] 27 | queries.push(stmt.bind(value, note.id.toString())) 28 | } 29 | 30 | await db.batch(queries) 31 | } 32 | 33 | export async function getTag(db: Database, domain: string, tag: string): Promise { 34 | const query = ` 35 | SELECT * FROM note_hashtags WHERE value=? 36 | ` 37 | const { results, success, error } = await db.prepare(query).bind(tag).all<{ value: string }>() 38 | if (!success) { 39 | throw new Error('SQL error: ' + error) 40 | } 41 | 42 | if (!results || results.length === 0) { 43 | return null 44 | } 45 | 46 | return { 47 | name: results[0].value, 48 | url: new URL(`/tags/${results[0].value}`, `https://${domain}`), 49 | history: [], 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/mastodon/idempotency.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from 'wildebeest/backend/src/activitypub/objects' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | import { 4 | mastodonIdSymbol, 5 | originalActorIdSymbol, 6 | originalObjectIdSymbol, 7 | } from 'wildebeest/backend/src/activitypub/objects' 8 | 9 | export async function insertKey(db: Database, key: string, obj: APObject): Promise { 10 | const query = ` 11 | INSERT INTO idempotency_keys (key, object_id, expires_at) 12 | VALUES (?1, ?2, datetime('now', '+1 hour')) 13 | ` 14 | 15 | const { success, error } = await db.prepare(query).bind(key, obj.id.toString()).run() 16 | if (!success) { 17 | throw new Error('SQL error: ' + error) 18 | } 19 | } 20 | 21 | export async function hasKey(db: Database, key: string): Promise { 22 | const query = ` 23 | SELECT objects.* 24 | FROM idempotency_keys 25 | INNER JOIN objects ON objects.id = idempotency_keys.object_id 26 | WHERE idempotency_keys.key = ?1 AND expires_at >= datetime() 27 | ` 28 | 29 | const { results, success, error } = await db.prepare(query).bind(key).all() 30 | if (!success) { 31 | throw new Error('SQL error: ' + error) 32 | } 33 | 34 | if (!results || results.length === 0) { 35 | return null 36 | } 37 | 38 | const result = results[0] 39 | let properties 40 | if (typeof result.properties === 'object') { 41 | // neon uses JSONB for properties which is returned as a deserialized 42 | // object. 43 | properties = result.properties 44 | } else { 45 | // D1 uses a string for JSON properties 46 | properties = JSON.parse(result.properties) 47 | } 48 | 49 | return { 50 | published: new Date(result.cdate).toISOString(), 51 | ...properties, 52 | 53 | type: result.type, 54 | id: new URL(result.id), 55 | 56 | [mastodonIdSymbol]: result.mastodon_id, 57 | [originalActorIdSymbol]: result.original_actor_id, 58 | [originalObjectIdSymbol]: result.original_object_id, 59 | } as APObject 60 | } 61 | -------------------------------------------------------------------------------- /backend/src/mastodon/like.ts: -------------------------------------------------------------------------------- 1 | import type { APObject } from 'wildebeest/backend/src/activitypub/objects' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 4 | import { getResultsField } from './utils' 5 | 6 | export async function insertLike(db: Database, actor: Actor, obj: APObject) { 7 | const id = crypto.randomUUID() 8 | 9 | const query = ` 10 | INSERT INTO actor_favourites (id, actor_id, object_id) 11 | VALUES (?, ?, ?) 12 | ` 13 | 14 | const out = await db.prepare(query).bind(id, actor.id.toString(), obj.id.toString()).run() 15 | if (!out.success) { 16 | throw new Error('SQL error: ' + out.error) 17 | } 18 | } 19 | 20 | export function getLikes(db: Database, obj: APObject): Promise> { 21 | const query = ` 22 | SELECT actor_id FROM actor_favourites WHERE object_id=? 23 | ` 24 | 25 | const statement = db.prepare(query).bind(obj.id.toString()) 26 | 27 | return getResultsField(statement, 'actor_id') 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/mastodon/reblog.ts: -------------------------------------------------------------------------------- 1 | // Also known as boost. 2 | 3 | import type { APObject } from 'wildebeest/backend/src/activitypub/objects' 4 | import { type Database } from 'wildebeest/backend/src/database' 5 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 6 | import { getResultsField } from './utils' 7 | import { addObjectInOutbox } from '../activitypub/actors/outbox' 8 | 9 | /** 10 | * Creates a reblog and inserts it in the reblog author's outbox 11 | * 12 | * @param db Database 13 | * @param actor Reblogger 14 | * @param obj ActivityPub object to reblog 15 | */ 16 | export async function createReblog(db: Database, actor: Actor, obj: APObject) { 17 | await Promise.all([addObjectInOutbox(db, actor, obj), insertReblog(db, actor, obj)]) 18 | } 19 | 20 | export async function insertReblog(db: Database, actor: Actor, obj: APObject) { 21 | const id = crypto.randomUUID() 22 | 23 | const query = ` 24 | INSERT INTO actor_reblogs (id, actor_id, object_id) 25 | VALUES (?, ?, ?) 26 | ` 27 | 28 | const out = await db.prepare(query).bind(id, actor.id.toString(), obj.id.toString()).run() 29 | if (!out.success) { 30 | throw new Error('SQL error: ' + out.error) 31 | } 32 | } 33 | 34 | export function getReblogs(db: Database, obj: APObject): Promise> { 35 | const query = ` 36 | SELECT actor_id FROM actor_reblogs WHERE object_id=? 37 | ` 38 | 39 | const statement = db.prepare(query).bind(obj.id.toString()) 40 | 41 | return getResultsField(statement, 'actor_id') 42 | } 43 | 44 | export async function hasReblog(db: Database, actor: Actor, obj: APObject): Promise { 45 | const query = ` 46 | SELECT count(*) as count FROM actor_reblogs WHERE object_id=?1 AND actor_id=?2 47 | ` 48 | 49 | const { count } = await db.prepare(query).bind(obj.id.toString(), actor.id.toString()).first<{ count: number }>() 50 | return count > 0 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/mastodon/utils.ts: -------------------------------------------------------------------------------- 1 | export async function getResultsField(statement: D1PreparedStatement, fieldName: string): Promise> { 2 | const out: D1Result> = await statement.all() 3 | 4 | if (!out.success) { 5 | throw new Error('SQL error: ' + out.error) 6 | } 7 | 8 | return (out.results ?? []).map((x) => x[fieldName]) 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/middleware/error.ts: -------------------------------------------------------------------------------- 1 | import { internalServerError } from '../errors' 2 | import type { Env } from 'wildebeest/backend/src/types/env' 3 | import { initSentry } from 'wildebeest/backend/src/utils/sentry' 4 | 5 | /** 6 | * A Pages middleware function that logs errors to the console and responds with 500 errors and stack-traces. 7 | */ 8 | export async function errorHandling(context: EventContext) { 9 | const sentry = initSentry(context.request, context.env, context) 10 | 11 | try { 12 | return await context.next() 13 | } catch (err: any) { 14 | if (sentry !== null) { 15 | sentry.captureException(err) 16 | } 17 | console.error(err.stack, err.cause) 18 | return internalServerError() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/src/middleware/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A Pages middleware function that logs requests/responses to the console. 3 | */ 4 | export async function logger(context: EventContext) { 5 | const { method, url } = context.request 6 | console.log(`-> ${method} ${url} `) 7 | const res = await context.next() 8 | if (context.data.connectedActor) { 9 | console.log(`<- ${res.status} (${context.data.connectedActor.id})`) 10 | } else { 11 | console.log(`<- ${res.status}`) 12 | } 13 | 14 | return res 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/types/account.ts: -------------------------------------------------------------------------------- 1 | // https://docs.joinmastodon.org/entities/Account/ 2 | // https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Account.java 3 | export interface MastodonAccount { 4 | id: string 5 | username: string 6 | acct: string 7 | url: string 8 | display_name: string 9 | note: string 10 | 11 | avatar: string 12 | avatar_static: string 13 | 14 | header: string 15 | header_static: string 16 | 17 | created_at: string 18 | 19 | locked?: boolean 20 | bot?: boolean 21 | discoverable?: boolean 22 | group?: boolean 23 | 24 | followers_count: number 25 | following_count: number 26 | statuses_count: number 27 | 28 | emojis: Array 29 | fields: Array 30 | } 31 | 32 | // https://docs.joinmastodon.org/entities/Relationship/ 33 | // https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java 34 | export type Relationship = { 35 | id: string 36 | } 37 | 38 | export type Privacy = 'public' | 'unlisted' | 'private' | 'direct' 39 | 40 | // https://docs.joinmastodon.org/entities/Account/#CredentialAccount 41 | export interface CredentialAccount extends MastodonAccount { 42 | source: { 43 | note: string 44 | fields: Array 45 | privacy: Privacy 46 | sensitive: boolean 47 | language: string 48 | follow_requests_count: number 49 | } 50 | role: Role 51 | } 52 | 53 | // https://docs.joinmastodon.org/entities/Role/ 54 | export type Role = { 55 | id: string 56 | name: string 57 | color: string 58 | position: number 59 | // https://docs.joinmastodon.org/entities/Role/#permission-flags 60 | permissions: number 61 | highlighted: boolean 62 | created_at: string 63 | updated_at: string 64 | } 65 | 66 | export type Field = { 67 | name: string 68 | value: string 69 | verified_at?: string 70 | } 71 | -------------------------------------------------------------------------------- /backend/src/types/configs.ts: -------------------------------------------------------------------------------- 1 | // https://docs.joinmastodon.org/entities/Instance/ 2 | export type InstanceConfig = { 3 | uri: string 4 | title: string 5 | thumbnail: string 6 | languages: Array 7 | email: string 8 | description: string 9 | short_description?: string 10 | rules: Array 11 | } 12 | 13 | export type InstanceConfigV2 = { 14 | domain: string 15 | title: string 16 | version: string 17 | source_url: string 18 | description: string 19 | thumbnail: { 20 | url: string 21 | } 22 | languages: Array 23 | registrations: { 24 | enabled: boolean 25 | } 26 | contact: { 27 | email: string 28 | } 29 | rules: Array 30 | } 31 | 32 | // https://docs.joinmastodon.org/entities/Rule/ 33 | export type Rule = { 34 | id: string 35 | text: string 36 | } 37 | 38 | export type DefaultImages = { 39 | avatar: string 40 | header: string 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/types/context.ts: -------------------------------------------------------------------------------- 1 | import type { Person } from 'wildebeest/backend/src/activitypub/actors' 2 | 3 | export type Identity = { 4 | email: string 5 | } 6 | 7 | export type ContextData = { 8 | // ActivityPub Person object of the logged in user 9 | connectedActor: Person 10 | 11 | // Object returned by Cloudflare Access' provider 12 | identity: Identity 13 | 14 | // Client or app identifier 15 | clientId: string 16 | } 17 | -------------------------------------------------------------------------------- /backend/src/types/env.ts: -------------------------------------------------------------------------------- 1 | import type { Queue, MessageBody } from 'wildebeest/backend/src/types/queue' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | 4 | export interface Env { 5 | DATABASE: Database 6 | // FIXME: shouldn't it be USER_KEY? 7 | userKEK: string 8 | QUEUE: Queue 9 | DO_CACHE: DurableObjectNamespace 10 | 11 | CF_ACCOUNT_ID: string 12 | CF_API_TOKEN: string 13 | 14 | // Configuration for Cloudflare Access 15 | ACCESS_AUD: string 16 | ACCESS_AUTH_DOMAIN: string 17 | 18 | // Configuration for the instance 19 | INSTANCE_TITLE: string 20 | ADMIN_EMAIL: string 21 | INSTANCE_DESCR: string 22 | VAPID_JWK: string 23 | DOMAIN: string 24 | 25 | SENTRY_DSN: string 26 | SENTRY_ACCESS_CLIENT_ID: string 27 | SENTRY_ACCESS_CLIENT_SECRET: string 28 | 29 | NEON_DATABASE_URL?: string 30 | } 31 | -------------------------------------------------------------------------------- /backend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './status' 2 | export * from './account' 3 | 4 | export type UUID = string 5 | -------------------------------------------------------------------------------- /backend/src/types/media.ts: -------------------------------------------------------------------------------- 1 | export type MediaType = 'unknown' | 'image' | 'gifv' | 'video' | 'audio' 2 | 3 | export type MediaAttachment = { 4 | id: string 5 | type: MediaType 6 | url: URL 7 | preview_url: URL 8 | meta: any 9 | description: string 10 | blurhash: string 11 | } 12 | -------------------------------------------------------------------------------- /backend/src/types/notification.ts: -------------------------------------------------------------------------------- 1 | import type { MastodonAccount } from 'wildebeest/backend/src/types/account' 2 | import type { MastodonStatus } from 'wildebeest/backend/src/types/status' 3 | import type { ObjectsRow } from './objects' 4 | 5 | export type NotificationType = 6 | | 'mention' 7 | | 'status' 8 | | 'reblog' 9 | | 'follow' 10 | | 'follow_request' 11 | | 'favourite' 12 | | 'poll' 13 | | 'update' 14 | | 'admin.sign_up' 15 | | 'admin.report' 16 | 17 | export type Notification = { 18 | id: string 19 | type: NotificationType 20 | created_at: string 21 | account: MastodonAccount 22 | status?: MastodonStatus 23 | } 24 | 25 | export interface NotificationsQueryResult extends ObjectsRow { 26 | type: NotificationType 27 | original_actor_id: URL 28 | notif_from_actor_id: URL 29 | notif_cdate: string 30 | notif_id: URL 31 | from_actor_id: string 32 | } 33 | -------------------------------------------------------------------------------- /backend/src/types/objects.ts: -------------------------------------------------------------------------------- 1 | export interface ObjectsRow { 2 | properties: string 3 | mastodon_id: string 4 | id: URL 5 | cdate: string 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/types/queue.ts: -------------------------------------------------------------------------------- 1 | import type { Activity } from 'wildebeest/backend/src/activitypub/activities' 2 | import type { JWK } from 'wildebeest/backend/src/webpush/jwk' 3 | 4 | export enum MessageType { 5 | Inbox = 1, 6 | Deliver, 7 | } 8 | 9 | export interface MessageBody { 10 | type: MessageType 11 | actorId: string 12 | } 13 | 14 | // ActivityPub messages received by an Actor's Inbox are sent into the queue. 15 | export interface InboxMessageBody extends MessageBody { 16 | activity: Activity 17 | 18 | // Send secrets as part of the message because it's too complicated 19 | // to bind them to the consumer worker. 20 | userKEK: string 21 | vapidKeys: JWK 22 | } 23 | 24 | // ActivityPub message delivery job are sent to the queue and the consumer does 25 | // the actual delivery. 26 | export interface DeliverMessageBody extends MessageBody { 27 | activity: Activity 28 | toActorId: string 29 | 30 | // Send secrets as part of the message because it's too complicated 31 | // to bind them to the consumer worker. 32 | userKEK: string 33 | } 34 | 35 | export type MessageSendRequest = { 36 | body: Body 37 | } 38 | 39 | export interface Queue { 40 | send(body: Body): Promise 41 | sendBatch(messages: Iterable>): Promise 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/types/status.ts: -------------------------------------------------------------------------------- 1 | import type { MastodonAccount } from './account' 2 | import type { MediaAttachment } from './media' 3 | import type { UUID } from 'wildebeest/backend/src/types' 4 | 5 | export type Visibility = 'public' | 'unlisted' | 'private' | 'direct' 6 | 7 | // https://docs.joinmastodon.org/entities/Status/ 8 | // https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java 9 | export type MastodonStatus = { 10 | id: UUID 11 | uri: URL 12 | url: URL 13 | created_at: string 14 | account: MastodonAccount 15 | content: string 16 | visibility: Visibility 17 | spoiler_text: string 18 | emojis: Array 19 | media_attachments: Array 20 | mentions: Array 21 | tags: Array 22 | favourites_count?: number 23 | reblogs_count?: number 24 | reblog?: MastodonStatus 25 | edited_at?: string 26 | replies_count?: number 27 | reblogged?: boolean 28 | favourited?: boolean 29 | in_reply_to_id?: string 30 | in_reply_to_account_id?: string 31 | } 32 | 33 | // https://docs.joinmastodon.org/entities/Context/ 34 | export type Context = { 35 | ancestors: Array 36 | descendants: Array 37 | } 38 | -------------------------------------------------------------------------------- /backend/src/types/tag.ts: -------------------------------------------------------------------------------- 1 | export type Tag = { 2 | name: string 3 | url: URL 4 | history: Array 5 | following?: boolean 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/utils/adjustLocalHostDomain.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * checks if a domain is a localhost one ('localhost' or '127.x.x.x') and 3 | * in that case replaces it with '0.0.0.0' (which is what we use for our local data) 4 | * 5 | * Note: only needed for local development 6 | * 7 | * @param domain the potentially localhost domain 8 | * @returns the adjusted domain if it was a localhost one, the original domain otherwise 9 | */ 10 | export function adjustLocalHostDomain(domain: string) { 11 | return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0') 12 | } 13 | -------------------------------------------------------------------------------- /backend/src/utils/auth/getAdmins.ts: -------------------------------------------------------------------------------- 1 | import { type Database } from 'wildebeest/backend/src/database' 2 | import { Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors' 3 | 4 | export async function getAdmins(db: Database): Promise { 5 | let rows: unknown[] = [] 6 | try { 7 | const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=1') 8 | const result = await stmt.all() 9 | rows = result.success ? (result.results as unknown[]) : [] 10 | } catch { 11 | /* empty */ 12 | } 13 | 14 | return rows.map(personFromRow) 15 | } 16 | -------------------------------------------------------------------------------- /backend/src/utils/auth/getJwtEmail.ts: -------------------------------------------------------------------------------- 1 | import * as access from 'wildebeest/backend/src/access' 2 | 3 | export function getJwtEmail(jwtCookie: string) { 4 | let payload: access.JWTPayload 5 | if (!jwtCookie) { 6 | throw new Error('Missing Authorization') 7 | } 8 | try { 9 | // TODO: eventually, verify the JWT with Access, however this 10 | // is not critical. 11 | payload = access.getPayload(jwtCookie) 12 | } catch (e: unknown) { 13 | const error = e as { stack: string; cause: string } 14 | console.warn(error.stack, error.cause) 15 | throw new Error('Failed to validate Access JWT') 16 | } 17 | 18 | if (!payload.email) { 19 | throw new Error("The Access JWT doesn't contain an email") 20 | } 21 | 22 | return payload.email 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/utils/auth/isUserAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import * as access from 'wildebeest/backend/src/access' 2 | 3 | export async function isUserAuthenticated(request: Request, jwt: string, accessAuthDomain: string, accessAud: string) { 4 | if (!jwt) return false 5 | 6 | try { 7 | const validate = access.generateValidator({ 8 | jwt, 9 | domain: accessAuthDomain, 10 | aud: accessAud, 11 | }) 12 | await validate(new Request(request.url)) 13 | } catch { 14 | return false 15 | } 16 | 17 | const identity = await access.getIdentity({ jwt, domain: accessAuthDomain }) 18 | if (identity) { 19 | return true 20 | } 21 | return false 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/utils/cors.ts: -------------------------------------------------------------------------------- 1 | export function cors(): object { 2 | return { 3 | 'Access-Control-Allow-Origin': '*', 4 | 'Access-Control-Allow-Headers': 'content-type, authorization, idempotency-key', 5 | 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE', 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/utils/getDomain.ts: -------------------------------------------------------------------------------- 1 | import { adjustLocalHostDomain } from './adjustLocalHostDomain' 2 | 3 | export function getDomain(url: URL | string) { 4 | const domain = new URL(url).hostname 5 | return adjustLocalHostDomain(domain) 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/utils/handle.ts: -------------------------------------------------------------------------------- 1 | // Naive way of transforming an Actor ObjectID into a handle like WebFinger uses 2 | export function urlToHandle(input: URL): string { 3 | const { pathname, host } = input 4 | const parts = pathname.split('/') 5 | if (parts.length === 0) { 6 | throw new Error('malformed URL') 7 | } 8 | const localPart = parts[parts.length - 1] 9 | return `${localPart}@${host}` 10 | } 11 | -------------------------------------------------------------------------------- /backend/src/utils/http-signing.ts: -------------------------------------------------------------------------------- 1 | import { Algorithm, sign } from './http-signing-cavage' 2 | import { str2ab } from './key-ops' 3 | 4 | export async function signRequest(request: Request, key: CryptoKey, keyId: URL): Promise { 5 | const mySigner = async (data: string) => 6 | new Uint8Array( 7 | await crypto.subtle.sign( 8 | { 9 | name: 'RSASSA-PKCS1-v1_5', 10 | hash: 'SHA-256', 11 | }, 12 | key, 13 | str2ab(data as string) 14 | ) 15 | ) 16 | mySigner.alg = 'hs2019' as Algorithm 17 | 18 | if (!request.headers.has('Host')) { 19 | const url = new URL(request.url) 20 | request.headers.set('Host', url.host) 21 | } 22 | 23 | const components = ['@request-target', 'host'] 24 | if (request.method == 'POST') { 25 | components.push('digest') 26 | } 27 | 28 | await sign(request, { 29 | components: components, 30 | parameters: { 31 | created: Math.floor(Date.now() / 1000), 32 | }, 33 | keyId: keyId.toString(), 34 | signer: mySigner, 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /backend/src/utils/httpsigjs/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Joyent, Inc. All rights reserved. 2 | Permission is hereby granted, free of charge, to any person obtaining a copy 3 | of this software and associated documentation files (the "Software"), to 4 | deal in the Software without restriction, including without limitation the 5 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or 6 | sell copies of the Software, and to permit persons to whom the Software is 7 | furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in 10 | all copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 17 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS 18 | IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /backend/src/utils/httpsigjs/utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2012 Joyent, Inc. All rights reserved. 2 | export const HASH_ALGOS = new Set(['sha1', 'sha256', 'sha512']) 3 | 4 | export const PK_ALGOS = new Set(['rsa', 'dsa', 'ecdsa']) 5 | 6 | export const HEADER = { 7 | AUTH: 'authorization', 8 | SIG: 'signature', 9 | } 10 | 11 | export class HttpSignatureError extends Error { 12 | constructor(message: string, caller: any) { 13 | super(message) 14 | if (Error.captureStackTrace) Error.captureStackTrace(this, caller || HttpSignatureError) 15 | 16 | this.message = message 17 | this.name = caller.name 18 | } 19 | } 20 | 21 | export class InvalidAlgorithmError extends HttpSignatureError { 22 | constructor(message: string) { 23 | super(message, InvalidAlgorithmError) 24 | } 25 | } 26 | 27 | /** 28 | * @param algorithm {String} the algorithm of the signature 29 | * @param publicKeyType {String?} fallback algorithm (public key type) for 30 | * hs2019 31 | * @returns {[string, string]} 32 | */ 33 | export function validateAlgorithm(algorithm: string, publicKeyType?: string): [string, string] { 34 | const alg = algorithm.toLowerCase().split('-') 35 | 36 | if (alg[0] === 'hs2019') { 37 | return publicKeyType !== undefined ? validateAlgorithm(publicKeyType + '-sha256') : ['hs2019', 'sha256'] 38 | } 39 | 40 | if (alg.length !== 2) { 41 | throw new InvalidAlgorithmError(alg[0].toUpperCase() + ' is not a ' + 'valid algorithm') 42 | } 43 | 44 | if (alg[0] !== 'hmac' && !PK_ALGOS.has(alg[0])) { 45 | throw new InvalidAlgorithmError(alg[0].toUpperCase() + ' type keys ' + 'are not supported') 46 | } 47 | 48 | if (!HASH_ALGOS.has(alg[1])) { 49 | throw new InvalidAlgorithmError(alg[1].toUpperCase() + ' is not a ' + 'supported hash algorithm') 50 | } 51 | 52 | return alg as [string, string] 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/utils/httpsigjs/verifier.ts: -------------------------------------------------------------------------------- 1 | import { importPublicKey, str2ab } from '../key-ops' 2 | import { ParsedSignature } from './parser' 3 | 4 | interface Profile { 5 | publicKey: { 6 | id: string 7 | owner: string 8 | publicKeyPem: string 9 | } 10 | } 11 | 12 | export async function verifySignature(parsedSignature: ParsedSignature, key: CryptoKey): Promise { 13 | return crypto.subtle.verify( 14 | 'RSASSA-PKCS1-v1_5', 15 | key, 16 | str2ab(atob(parsedSignature.signature)), 17 | str2ab(parsedSignature.signingString) 18 | ) 19 | } 20 | 21 | export async function fetchKey(parsedSignature: ParsedSignature): Promise { 22 | const url = parsedSignature.keyId 23 | const res = await fetch(url, { 24 | headers: { Accept: 'application/activity+json' }, 25 | }) 26 | if (!res.ok) { 27 | console.warn(`failed to fetch keys from "${url}", returned ${res.status}.`) 28 | return null 29 | } 30 | 31 | const parsedResponse = (await res.json()) as Profile 32 | return importPublicKey(parsedResponse.publicKey.publicKeyPem) 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/utils/parse.ts: -------------------------------------------------------------------------------- 1 | export type Handle = { 2 | localPart: string 3 | domain: string | null 4 | } 5 | 6 | // Parse a "handle" in the form: `[@] '@' ` 7 | export function parseHandle(query: string): Handle { 8 | // Remove the leading @, if there's one. 9 | if (query.startsWith('@')) { 10 | query = query.substring(1) 11 | } 12 | 13 | // In case the handle has been URL encoded 14 | query = decodeURIComponent(query) 15 | 16 | const parts = query.split('@') 17 | const localPart = parts[0] 18 | 19 | if (!/^[\w-.]+$/.test(localPart)) { 20 | throw new Error('invalid handle: localPart: ' + localPart) 21 | } 22 | 23 | if (parts.length > 1) { 24 | return { localPart, domain: parts[1] } 25 | } else { 26 | return { localPart, domain: null } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/src/utils/sentry.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js' 2 | import type { Env } from 'wildebeest/backend/src/types/env' 3 | 4 | export function initSentry(request: Request, env: Env, context: any) { 5 | if (env.SENTRY_DSN === '') { 6 | return null 7 | } 8 | 9 | const headers: any = {} 10 | 11 | if (env.SENTRY_ACCESS_CLIENT_ID !== '' && env.SENTRY_ACCESS_CLIENT_SECRET !== '') { 12 | headers['CF-Access-Client-ID'] = env.SENTRY_ACCESS_CLIENT_ID 13 | headers['CF-Access-Client-Secret'] = env.SENTRY_ACCESS_CLIENT_SECRET 14 | } 15 | 16 | const sentry = new Toucan({ 17 | dsn: env.SENTRY_DSN, 18 | context, 19 | request, 20 | transportOptions: { headers }, 21 | }) 22 | const cf = (request as { cf?: IncomingRequestCfProperties }).cf 23 | const colo = cf?.colo ? cf.colo : 'UNKNOWN' 24 | sentry.setTag('colo', colo) 25 | 26 | // cf-connecting-ip should always be present, but if not we can fallback to XFF. 27 | const ipAddress = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') 28 | const userAgent = request.headers.get('user-agent') || '' 29 | sentry.setUser({ ip: ipAddress, userAgent: userAgent, colo: colo }) 30 | return sentry 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/webfinger/index.ts: -------------------------------------------------------------------------------- 1 | import * as actors from '../activitypub/actors' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | import type { Actor } from '../activitypub/actors' 4 | 5 | export type WebFingerResponse = { 6 | subject: string 7 | aliases: Array 8 | links: Array 9 | } 10 | 11 | const headers = { 12 | accept: 'application/jrd+json', 13 | } 14 | 15 | export async function queryAcct(domain: string, db: Database, acct: string): Promise { 16 | const url = await queryAcctLink(domain, acct) 17 | if (url === null) { 18 | return null 19 | } 20 | return actors.getAndCache(url, db) 21 | } 22 | 23 | export async function queryAcctLink(domain: string, acct: string): Promise { 24 | const params = new URLSearchParams({ resource: `acct:${acct}` }) 25 | let data: WebFingerResponse 26 | try { 27 | const url = new URL('/.well-known/webfinger?' + params, 'https://' + domain) 28 | console.log('query', url.href) 29 | const res = await fetch(url, { headers }) 30 | if (!res.ok) { 31 | throw new Error(`WebFinger API returned: ${res.status}`) 32 | } 33 | 34 | data = await res.json() 35 | } catch (err) { 36 | console.warn('failed to query WebFinger:', err) 37 | return null 38 | } 39 | 40 | for (let i = 0, len = data.links.length; i < len; i++) { 41 | const link = data.links[i] 42 | if (link.rel === 'self' && link.type === 'application/activity+json') { 43 | return new URL(link.href) 44 | } 45 | } 46 | 47 | return null 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/webpush/hkdf.ts: -------------------------------------------------------------------------------- 1 | export async function hmacSign(ikm: Uint8Array | ArrayBuffer, input: ArrayBuffer): Promise { 2 | const key = await crypto.subtle.importKey('raw', ikm, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) 3 | return await crypto.subtle.sign('HMAC', key, input) 4 | } 5 | 6 | export async function hkdfGenerate( 7 | ikm: ArrayBuffer, 8 | salt: Uint8Array, 9 | info: Uint8Array, 10 | byteLength: number 11 | ): Promise { 12 | const fullInfoBuffer = new Uint8Array(info.byteLength + 1) 13 | fullInfoBuffer.set(info, 0) 14 | fullInfoBuffer.set(new Uint8Array(1).fill(1), info.byteLength) 15 | const prk = await hmacSign(salt, ikm) 16 | const nextPrk = await hmacSign(prk, fullInfoBuffer) 17 | return nextPrk.slice(0, byteLength) 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/webpush/index.ts: -------------------------------------------------------------------------------- 1 | import type { JWK } from './jwk' 2 | import { WebPushInfos, WebPushMessage, WebPushResult } from './webpushinfos' 3 | import { generateAESGCMEncryptedMessage } from './message' 4 | import { generateV1Headers } from './vapid' 5 | 6 | export async function generateWebPushMessage( 7 | message: WebPushMessage, 8 | deviceData: WebPushInfos, 9 | applicationServerKeys: JWK 10 | ): Promise { 11 | const [authHeaders, encryptedPayloadDetails] = await Promise.all([ 12 | generateV1Headers(deviceData.endpoint, applicationServerKeys, message.sub), 13 | generateAESGCMEncryptedMessage(message.data, deviceData), 14 | ]) 15 | 16 | const headers: { [headerName: string]: string } = { ...authHeaders } 17 | headers['Encryption'] = `salt=${encryptedPayloadDetails.salt}` 18 | headers['Crypto-Key'] = `dh=${encryptedPayloadDetails.publicServerKey};${headers['Crypto-Key']}` 19 | 20 | headers['Content-Encoding'] = 'aesgcm' 21 | headers['Content-Type'] = 'application/octet-stream' 22 | 23 | // setup message headers 24 | headers['TTL'] = `${message.ttl}` 25 | headers['Urgency'] = `${message.urgency}` 26 | 27 | const res = await fetch(deviceData.endpoint, { 28 | method: 'POST', 29 | headers, 30 | body: encryptedPayloadDetails.cipherText, 31 | }) 32 | 33 | switch (res.status) { 34 | case 200: // http ok 35 | case 201: // http created 36 | case 204: // http no content 37 | return WebPushResult.Success 38 | 39 | case 400: // http bad request 40 | case 401: // http unauthorized 41 | case 404: // http not found 42 | case 410: // http gone 43 | return WebPushResult.NotSubscribed 44 | } 45 | 46 | console.warn(`WebPush res: ${res.status} body: ${await res.text()}`) 47 | return WebPushResult.Error 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/webpush/jwk.ts: -------------------------------------------------------------------------------- 1 | export interface JWK { 2 | crv: string 3 | kty: string 4 | key_ops: string[] 5 | ext: boolean 6 | d: string 7 | x: string 8 | y: string 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/webpush/vapid.ts: -------------------------------------------------------------------------------- 1 | import type { JWK } from './jwk' 2 | import { arrayBufferToBase64, b64ToUrlEncoded, exportPublicKeyPair, stringToU8Array } from './util' 3 | 4 | const objToUrlB64 = (obj: { [key: string]: string | number | null }) => b64ToUrlEncoded(btoa(JSON.stringify(obj))) 5 | 6 | async function signData(token: string, applicationKeys: JWK): Promise { 7 | const key = await crypto.subtle.importKey('jwk', applicationKeys, { name: 'ECDSA', namedCurve: 'P-256' }, true, [ 8 | 'sign', 9 | ]) 10 | 11 | const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, key, stringToU8Array(token)) 12 | 13 | return b64ToUrlEncoded(arrayBufferToBase64(sig)) 14 | } 15 | 16 | async function generateHeaders( 17 | endpoint: string, 18 | applicationServerKeys: JWK, 19 | sub: string 20 | ): Promise<{ token: string; serverKey: string }> { 21 | const serverKey = b64ToUrlEncoded(exportPublicKeyPair(applicationServerKeys)) 22 | const pushService = new URL(endpoint) 23 | 24 | const header = { 25 | typ: 'JWT', 26 | alg: 'ES256', 27 | } 28 | 29 | const body = { 30 | aud: `${pushService.protocol}//${pushService.host}`, 31 | exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60, 32 | sub: 'mailto:' + sub, 33 | } 34 | 35 | const unsignedToken = objToUrlB64(header) + '.' + objToUrlB64(body) 36 | const signature = await signData(unsignedToken, applicationServerKeys) 37 | const token = `${unsignedToken}.${signature}` 38 | return { token, serverKey } 39 | } 40 | 41 | export async function generateV1Headers( 42 | endpoint: string, 43 | applicationServerKeys: JWK, 44 | sub: string 45 | ): Promise<{ [headerName in 'Crypto-Key' | 'Authorization']: string }> { 46 | const headers = await generateHeaders(endpoint, applicationServerKeys, sub) 47 | return { Authorization: `WebPush ${headers.token}`, 'Crypto-Key': `p256ecdsa=${headers.serverKey}` } 48 | } 49 | -------------------------------------------------------------------------------- /backend/src/webpush/webpushinfos.ts: -------------------------------------------------------------------------------- 1 | export interface WebPushInfos { 2 | endpoint: string 3 | key: string 4 | auth: string 5 | 6 | // supportedAlgorithms: string[]; // this will be used in future 7 | } 8 | 9 | type Urgency = 'very-low' | 'low' | 'normal' | 'high' 10 | 11 | export interface WebPushMessage { 12 | data: string 13 | urgency: Urgency 14 | sub: string 15 | ttl: number 16 | } 17 | 18 | export enum WebPushResult { 19 | Success = 0, 20 | Error = 1, 21 | NotSubscribed = 2, 22 | } 23 | -------------------------------------------------------------------------------- /backend/test/mastodon/instance.spec.ts: -------------------------------------------------------------------------------- 1 | import { addPeer } from 'wildebeest/backend/src/activitypub/peers' 2 | import { strict as assert } from 'node:assert/strict' 3 | import * as peers from 'wildebeest/functions/api/v1/instance/peers' 4 | import { makeDB } from '../utils' 5 | 6 | describe('Mastodon APIs', () => { 7 | describe('instance', () => { 8 | test('returns peers', async () => { 9 | const db = await makeDB() 10 | await addPeer(db, 'a') 11 | await addPeer(db, 'b') 12 | 13 | const res = await peers.handleRequest(db) 14 | assert.equal(res.status, 200) 15 | 16 | const data = await res.json>() 17 | assert.equal(data.length, 2) 18 | assert.equal(data[0], 'a') 19 | assert.equal(data[1], 'b') 20 | }) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /backend/test/mastodon/tags.spec.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert/strict' 2 | import { createPerson } from 'wildebeest/backend/src/activitypub/actors' 3 | import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' 4 | import { makeDB, assertCORS, isUrlValid } from '../utils' 5 | import * as tag_id from 'wildebeest/functions/api/v1/tags/[tag]' 6 | import { insertHashtags } from 'wildebeest/backend/src/mastodon/hashtag' 7 | 8 | const domain = 'cloudflare.com' 9 | const userKEK = 'test_kek20' 10 | 11 | describe('Mastodon APIs', () => { 12 | describe('tags', () => { 13 | test('return 404 when non existent tag', async () => { 14 | const db = await makeDB() 15 | const res = await tag_id.handleRequestGet(db, domain, 'non-existent-tag') 16 | assertCORS(res) 17 | assert.equal(res.status, 404) 18 | }) 19 | 20 | test('return tag', async () => { 21 | const db = await makeDB() 22 | const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') 23 | 24 | const note = await createPublicNote(domain, db, 'my localnote status', actor) 25 | await insertHashtags(db, note, ['test']) 26 | 27 | const res = await tag_id.handleRequestGet(db, domain, 'test') 28 | assertCORS(res) 29 | assert.equal(res.status, 200) 30 | 31 | const data = await res.json() 32 | assert.equal(data.name, 'test') 33 | assert(isUrlValid(data.url)) 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /backend/test/mastodon/trends.spec.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert/strict' 2 | import * as trends_statuses from 'wildebeest/functions/api/v1/trends/statuses' 3 | import * as trends_links from 'wildebeest/functions/api/v1/trends/links' 4 | import { assertJSON } from '../utils' 5 | 6 | describe('Mastodon APIs', () => { 7 | describe('trends', () => { 8 | test('trending statuses return empty array', async () => { 9 | const res = await trends_statuses.onRequest() 10 | assert.equal(res.status, 200) 11 | assertJSON(res) 12 | 13 | const data = await res.json() 14 | assert.equal(data.length, 0) 15 | }) 16 | 17 | test('trending links return empty array', async () => { 18 | const res = await trends_links.onRequest() 19 | assert.equal(res.status, 200) 20 | assertJSON(res) 21 | 22 | const data = await res.json() 23 | assert.equal(data.length, 0) 24 | }) 25 | }) 26 | }) 27 | -------------------------------------------------------------------------------- /backend/test/nodeinfo.spec.ts: -------------------------------------------------------------------------------- 1 | import { strict as assert } from 'node:assert/strict' 2 | import * as nodeinfo_21 from 'wildebeest/functions/nodeinfo/2.1' 3 | import * as nodeinfo_20 from 'wildebeest/functions/nodeinfo/2.0' 4 | import * as nodeinfo from 'wildebeest/functions/.well-known/nodeinfo' 5 | import { assertCORS } from './utils' 6 | 7 | const domain = 'example.com' 8 | 9 | describe('NodeInfo', () => { 10 | test('well-known returns links', async () => { 11 | const res = await nodeinfo.handleRequest(domain) 12 | assert.equal(res.status, 200) 13 | assertCORS(res) 14 | 15 | const data = await res.json() 16 | assert.equal(data.links.length, 2) 17 | }) 18 | 19 | test('expose NodeInfo version 2.0', async () => { 20 | const res = await nodeinfo_20.handleRequest() 21 | assert.equal(res.status, 200) 22 | assertCORS(res) 23 | 24 | const data = await res.json() 25 | assert.equal(data.version, '2.0') 26 | }) 27 | 28 | test('expose NodeInfo version 2.1', async () => { 29 | const res = await nodeinfo_21.handleRequest() 30 | assert.equal(res.status, 200) 31 | assertCORS(res) 32 | 33 | const data = await res.json() 34 | assert.equal(data.version, '2.1') 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /backend/test/shared.utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file contains test utils that are also shared with the frontend code, these could not 3 | * be in the utils.ts file since it containing nodejs imports would cause the frontend to failing 4 | * building. 5 | */ 6 | 7 | import { type Database } from 'wildebeest/backend/src/database' 8 | import type { Actor } from '../src/activitypub/actors' 9 | import { addObjectInOutbox } from '../src/activitypub/actors/outbox' 10 | import { type Note, createPublicNote } from '../src/activitypub/objects/note' 11 | import { insertReply } from '../src/mastodon/reply' 12 | 13 | /** 14 | * Creates a reply and inserts it in the reply author's outbox 15 | * 16 | * @param domain the domain to use 17 | * @param db Database 18 | * @param actor Author of the reply 19 | * @param originalNote The original note 20 | * @param replyContent content of the reply 21 | */ 22 | export async function createReply( 23 | domain: string, 24 | db: Database, 25 | actor: Actor, 26 | originalNote: Note, 27 | replyContent: string 28 | ) { 29 | const inReplyTo = originalNote.id 30 | const replyNote = await createPublicNote(domain, db, replyContent, actor, [], { inReplyTo }) 31 | await addObjectInOutbox(db, actor, replyNote) 32 | await insertReply(db, actor, replyNote, originalNote) 33 | } 34 | -------------------------------------------------------------------------------- /backend/test/webfinger.spec.ts: -------------------------------------------------------------------------------- 1 | import { makeDB, assertCache } from './utils' 2 | import { strict as assert } from 'node:assert/strict' 3 | 4 | import * as webfinger from 'wildebeest/functions/.well-known/webfinger' 5 | 6 | describe('WebFinger', () => { 7 | test('no resource queried', async () => { 8 | const db = await makeDB() 9 | 10 | const req = new Request('https://example.com/.well-known/webfinger') 11 | const res = await webfinger.handleRequest(req, db) 12 | assert.equal(res.status, 400) 13 | }) 14 | 15 | test('invalid resource', async () => { 16 | const db = await makeDB() 17 | 18 | const req = new Request('https://example.com/.well-known/webfinger?resource=hein:a') 19 | const res = await webfinger.handleRequest(req, db) 20 | assert.equal(res.status, 400) 21 | }) 22 | 23 | test('query local account', async () => { 24 | const db = await makeDB() 25 | 26 | const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven') 27 | const res = await webfinger.handleRequest(req, db) 28 | assert.equal(res.status, 400) 29 | }) 30 | 31 | test('query remote non-existing account', async () => { 32 | const db = await makeDB() 33 | 34 | const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven@example.com') 35 | const res = await webfinger.handleRequest(req, db) 36 | assert.equal(res.status, 404) 37 | }) 38 | 39 | test('query remote existing account', async () => { 40 | const db = await makeDB() 41 | await db 42 | .prepare('INSERT INTO actors (id, email, type) VALUES (?, ?, ?)') 43 | .bind('https://example.com/ap/users/sven', 'sven@cloudflare.com', 'Person') 44 | .run() 45 | 46 | const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven@example.com') 47 | const res = await webfinger.handleRequest(req, db) 48 | assert.equal(res.status, 200) 49 | assert.equal(res.headers.get('content-type'), 'application/jrd+json') 50 | assertCache(res, 3600) 51 | 52 | const data = await res.json() 53 | assert.equal(data.links.length, 1) 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /config/accounts.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultImages } from '../backend/src/types/configs' 2 | export const defaultImages: DefaultImages = { 3 | avatar: 'https://raw.githubusercontent.com/mastodon/mastodon/main/public/avatars/original/missing.png', 4 | header: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/header', 5 | } 6 | -------------------------------------------------------------------------------- /config/ua.ts: -------------------------------------------------------------------------------- 1 | import { WILDEBEEST_VERSION, MASTODON_API_VERSION } from 'wildebeest/config/versions' 2 | 3 | export function getFederationUA(domain: string): string { 4 | return `Wildebeest/${WILDEBEEST_VERSION} (Mastodon/${MASTODON_API_VERSION}; +${domain})` 5 | } 6 | -------------------------------------------------------------------------------- /config/versions.ts: -------------------------------------------------------------------------------- 1 | import * as packagejson from '../package.json' 2 | 3 | // https://github.com/mastodon/mastodon/blob/main/CHANGELOG.md 4 | export const MASTODON_API_VERSION = '4.0.2' 5 | 6 | export const WILDEBEEST_VERSION = packagejson.version 7 | 8 | export function getVersion(): string { 9 | return `${MASTODON_API_VERSION} (compatible; Wildebeest ${WILDEBEEST_VERSION})` 10 | } 11 | -------------------------------------------------------------------------------- /consumer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "consumer", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "@cloudflare/workers-types": "^4.20221111.1", 6 | "toucan-js": "^3.1.0", 7 | "typescript": "^4.9.4", 8 | "wrangler": "2.7.1" 9 | }, 10 | "private": true 11 | } 12 | -------------------------------------------------------------------------------- /consumer/src/deliver.ts: -------------------------------------------------------------------------------- 1 | import type { DeliverMessageBody } from 'wildebeest/backend/src/types/queue' 2 | import { getDatabase } from 'wildebeest/backend/src/database' 3 | import { getSigningKey } from 'wildebeest/backend/src/mastodon/account' 4 | import * as actors from 'wildebeest/backend/src/activitypub/actors' 5 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 6 | import type { Env } from './' 7 | import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver' 8 | 9 | export async function handleDeliverMessage(env: Env, actor: Actor, message: DeliverMessageBody) { 10 | const toActorId = new URL(message.toActorId) 11 | const targetActor = await actors.getAndCache(toActorId, await getDatabase(env)) 12 | if (targetActor === null) { 13 | console.warn(`actor ${toActorId} not found`) 14 | return 15 | } 16 | 17 | const signingKey = await getSigningKey(message.userKEK, await getDatabase(env), actor) 18 | await deliverToActor(signingKey, actor, targetActor, message.activity, env.DOMAIN) 19 | } 20 | -------------------------------------------------------------------------------- /consumer/src/inbox.ts: -------------------------------------------------------------------------------- 1 | import type { InboxMessageBody } from 'wildebeest/backend/src/types/queue' 2 | import { getDatabase } from 'wildebeest/backend/src/database' 3 | import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' 4 | import * as notification from 'wildebeest/backend/src/mastodon/notification' 5 | import * as timeline from 'wildebeest/backend/src/mastodon/timeline' 6 | import { cacheFromEnv } from 'wildebeest/backend/src/cache' 7 | import type { Actor } from 'wildebeest/backend/src/activitypub/actors' 8 | import type { Env } from './' 9 | 10 | export async function handleInboxMessage(env: Env, actor: Actor, message: InboxMessageBody) { 11 | const domain = env.DOMAIN 12 | const db = await getDatabase(env) 13 | const adminEmail = env.ADMIN_EMAIL 14 | const cache = cacheFromEnv(env) 15 | const activity = message.activity 16 | console.log(JSON.stringify(activity)) 17 | 18 | await activityHandler.handle(domain, activity, db, message.userKEK, adminEmail, message.vapidKeys) 19 | 20 | // Assuming we received new posts or a like, pregenerate the user's timelines 21 | // and notifications. 22 | await Promise.all([ 23 | timeline.pregenerateTimelines(domain, db, cache, actor), 24 | notification.pregenerateNotifications(db, cache, actor, domain), 25 | ]) 26 | } 27 | -------------------------------------------------------------------------------- /consumer/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { MessageBody, InboxMessageBody, DeliverMessageBody } from 'wildebeest/backend/src/types/queue' 2 | import { type Database, getDatabase } from 'wildebeest/backend/src/database' 3 | import * as actors from 'wildebeest/backend/src/activitypub/actors' 4 | import { MessageType } from 'wildebeest/backend/src/types/queue' 5 | import { initSentryQueue } from './sentry' 6 | import { handleInboxMessage } from './inbox' 7 | import { handleDeliverMessage } from './deliver' 8 | 9 | export type Env = { 10 | DATABASE: Database 11 | DOMAIN: string 12 | ADMIN_EMAIL: string 13 | DO_CACHE: DurableObjectNamespace 14 | 15 | SENTRY_DSN: string 16 | SENTRY_ACCESS_CLIENT_ID: string 17 | SENTRY_ACCESS_CLIENT_SECRET: string 18 | 19 | NEON_DATABASE_URL?: string 20 | } 21 | 22 | export default { 23 | async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) { 24 | const sentry = initSentryQueue(env, ctx) 25 | const db = await getDatabase(env) 26 | 27 | try { 28 | for (const message of batch.messages) { 29 | const actor = await actors.getActorById(db, new URL(message.body.actorId)) 30 | if (actor === null) { 31 | console.warn(`actor ${message.body.actorId} is missing`) 32 | return 33 | } 34 | 35 | switch (message.body.type) { 36 | case MessageType.Inbox: { 37 | await handleInboxMessage(env, actor, message.body as InboxMessageBody) 38 | break 39 | } 40 | case MessageType.Deliver: { 41 | await handleDeliverMessage(env, actor, message.body as DeliverMessageBody) 42 | break 43 | } 44 | default: 45 | throw new Error('unsupported message type: ' + message.body.type) 46 | } 47 | } 48 | } catch (err: any) { 49 | if (sentry !== null) { 50 | sentry.captureException(err) 51 | } 52 | console.error(err.stack, err.cause) 53 | } 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /consumer/src/sentry.ts: -------------------------------------------------------------------------------- 1 | import { Toucan } from 'toucan-js' 2 | import type { Env } from './' 3 | 4 | export function initSentryQueue(env: Env, context: any) { 5 | if (env.SENTRY_DSN === '') { 6 | return null 7 | } 8 | 9 | const headers: any = {} 10 | 11 | if (env.SENTRY_ACCESS_CLIENT_ID !== '' && env.SENTRY_ACCESS_CLIENT_SECRET !== '') { 12 | headers['CF-Access-Client-ID'] = env.SENTRY_ACCESS_CLIENT_ID 13 | headers['CF-Access-Client-Secret'] = env.SENTRY_ACCESS_CLIENT_SECRET 14 | } 15 | 16 | const sentry = new Toucan({ 17 | dsn: env.SENTRY_DSN, 18 | context, 19 | transportOptions: { headers }, 20 | }) 21 | 22 | return sentry 23 | } 24 | -------------------------------------------------------------------------------- /consumer/wrangler.toml: -------------------------------------------------------------------------------- 1 | compatibility_date = "2023-01-09" 2 | main = "./src/index.ts" 3 | usage_model = "unbound" 4 | node_compat = true 5 | -------------------------------------------------------------------------------- /do/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wildebeest-do", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "@cloudflare/workers-types": "^4.20221111.1", 6 | "typescript": "^4.9.4", 7 | "wrangler": "2.7.1" 8 | }, 9 | "private": true, 10 | "scripts": { 11 | "start": "wrangler dev", 12 | "deploy": "wrangler publish" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /do/src/index.ts: -------------------------------------------------------------------------------- 1 | export interface Env { 2 | DO: DurableObjectNamespace 3 | } 4 | 5 | export default { 6 | async fetch(request: Request, env: Env) { 7 | try { 8 | const id = env.DO.idFromName('default') 9 | const obj = env.DO.get(id) 10 | return obj.fetch(request) 11 | } catch (err: any) { 12 | return new Response(err.stack, { status: 500 }) 13 | } 14 | }, 15 | } 16 | 17 | export class WildebeestCache { 18 | private storage: DurableObjectStorage 19 | 20 | constructor(state: DurableObjectState) { 21 | this.storage = state.storage 22 | } 23 | 24 | async fetch(request: Request) { 25 | if (request.method === 'GET') { 26 | const { pathname } = new URL(request.url) 27 | const key = pathname.slice(1) // remove the leading slash from path 28 | 29 | const value = await this.storage.get(key) 30 | if (value === undefined) { 31 | console.log(`Get ${key} MISS`) 32 | return new Response('', { status: 404 }) 33 | } 34 | 35 | console.log(`Get ${key} HIT`) 36 | return new Response(JSON.stringify(value)) 37 | } 38 | 39 | if (request.method === 'PUT') { 40 | const { key, value } = await request.json() 41 | console.log(`Set ${key}`) 42 | 43 | await this.storage.put(key, value) 44 | return new Response('', { status: 201 }) 45 | } 46 | 47 | return new Response('', { status: 400 }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /do/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "wildebeest-do" 2 | main = "src/index.ts" 3 | compatibility_date = "2023-01-18" 4 | 5 | [[migrations]] 6 | tag = "v1" 7 | new_classes = ["WildebeestCache"] 8 | -------------------------------------------------------------------------------- /docs/other-services.md: -------------------------------------------------------------------------------- 1 | [Index](../README.md) ┊ [Back](updating.md) ┊ [Troubleshooting](troubleshooting.md) 2 | 3 | ## Additional Cloudflare services 4 | 5 | Since Wildebeest is a Cloudflare app running on Pages, you can seamlessly enable additional Cloudflare services to protect or improve your server. 6 | 7 | ### Protection 8 | 9 | Once Wildebeest is up and running, you can protect it from bad traffic and malicious actors. Cloudflare offers you [DDoS](https://www.cloudflare.com/en-gb/ddos/), [WAF](https://www.cloudflare.com/en-gb/waf/), and [Bot Management](https://www.cloudflare.com/en-gb/products/bot-management/) protection out of the box at a click's distance. 10 | 11 | ### Analytics 12 | 13 | Likewise, you'll get instant network and content delivery optimizations from our products and [analytics](https://www.cloudflare.com/en-gb/analytics/) on how your Wildebeest instance is performing and being used. 14 | 15 | ### Email Routing 16 | 17 | If you want to receive Email at your @social.example domain, you can enable [Email Routing](https://developers.cloudflare.com/email-routing/get-started/enable-email-routing/) for free and take advantage of sophisticated Email forwarding and protection features. Simply log in to your account, select the Wildebeest zone and then click on Email to enable. 18 | 19 | [Index](../README.md) ┊ [Back](updating.md) ┊ [Troubleshooting](troubleshooting.md) 20 | -------------------------------------------------------------------------------- /docs/updating.md: -------------------------------------------------------------------------------- 1 | [Index](../README.md) ┊ [Back](supported-clients.md) ┊ [Other Cloudflare services](other-services.md) 2 | 3 | ## Updating Wildebeest 4 | 5 | The deployment workflow runs automatically every time the main branch changes, so updating the Wildebeest is as easy as synchronizing the upstream official repository with the fork. You don't even need to use git commands for that; GitHub provides a convenient **_Sync fork_** button in the UI that you can simply click. 6 | 7 | ![configuration screen](https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/92ddc9f2-789b-454d-f6ca-2e9011613900/w=500) 8 | 9 | Once your fork is synchronized with the official repo, the GitHub Actions workflow is triggered and a new build will be deployed. 10 | 11 | Updates are incremental and non-destructive. When the GitHub Actions workflow redeploys Wildebeest, we only make the necessary changes to your configuration and nothing else. You don't lose your data; we don't need to delete your existing configurations. 12 | 13 | Data loss is not a problem either because D1 supports migrations. If we need to add a new column to a table or a new table, we don't need to destroy the database and create it again; we just apply the necessary SQL to that change. 14 | 15 | ![first login](https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/51a4767c-5d3d-4075-d17d-b8112432ca00/w=850) 16 | 17 | [Index](../README.md) ┊ [Back](supported-clients.md) ┊ [Other Cloudflare services](other-services.md) 18 | -------------------------------------------------------------------------------- /frontend/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | es2021: true, 6 | node: true, 7 | }, 8 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:qwik/recommended'], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | tsconfigRootDir: __dirname, 12 | project: ['./tsconfig.json'], 13 | ecmaVersion: 2021, 14 | sourceType: 'module', 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | }, 19 | plugins: ['@typescript-eslint'], 20 | rules: { 21 | '@typescript-eslint/no-explicit-any': 'error', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-inferrable-types': 'error', 24 | '@typescript-eslint/no-non-null-assertion': 'error', 25 | '@typescript-eslint/no-empty-interface': 'error', 26 | '@typescript-eslint/no-namespace': 'error', 27 | '@typescript-eslint/no-empty-function': 'error', 28 | '@typescript-eslint/no-this-alias': 'error', 29 | '@typescript-eslint/ban-types': 'error', 30 | '@typescript-eslint/ban-ts-comment': 'error', 31 | 'prefer-spread': 'error', 32 | 'no-case-declarations': 'error', 33 | 'no-console': ['error', { allow: ['warn', 'error']} ], 34 | '@typescript-eslint/no-unused-vars': ['error'], 35 | 'prefer-const': 'error', 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | /dist 3 | /lib 4 | /lib-types 5 | /server 6 | 7 | # Development 8 | node_modules 9 | 10 | # Cache 11 | .cache 12 | .mf 13 | .vscode 14 | .rollup.cache 15 | tsconfig.tsbuildinfo 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | pnpm-debug.log* 24 | lerna-debug.log* 25 | 26 | # Editor 27 | !.vscode/extensions.json 28 | .idea 29 | .DS_Store 30 | *.suo 31 | *.ntvs* 32 | *.njsproj 33 | *.sln 34 | *.sw? 35 | 36 | # Yarn 37 | .yarn/* 38 | !.yarn/releases 39 | 40 | # Cloudflare 41 | functions/**/*.js 42 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Wildebeest UI 2 | 3 | This directory contains a website that server-side renders a readonly public view of the data available via the REST APIs of the server. 4 | 5 | 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. 6 | 7 | In the top level of the repository run the following to build the app and host the whole server: 8 | 9 | ``` 10 | yarn dev 11 | ``` 12 | 13 | If you make a change to the Qwik application, you can open a new terminal and run the following to regenerate the website code: 14 | 15 | ``` 16 | yarn --cwd ui build 17 | ``` 18 | -------------------------------------------------------------------------------- /frontend/adaptors/cloudflare-pages/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { cloudflarePagesAdapter } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite' 2 | import { extendConfig } from '@builder.io/qwik-city/vite' 3 | import baseConfig from '../../vite.config' 4 | 5 | export default extendConfig(baseConfig, () => { 6 | return { 7 | build: { 8 | ssr: true, 9 | rollupOptions: { 10 | input: ['src/entry.cloudflare-pages.tsx', '@qwik-city-plan'], 11 | }, 12 | }, 13 | plugins: [ 14 | cloudflarePagesAdapter({ 15 | // Do not SSG as the D1 database is not available at build time, I think. 16 | // staticGenerate: true, 17 | }), 18 | ], 19 | } 20 | }) 21 | -------------------------------------------------------------------------------- /frontend/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | export default { 3 | preset: 'ts-jest', 4 | verbose: true, 5 | testMatch: ["/test/**/(*.)+(spec|test).[jt]s?(x)"], 6 | testTimeout:15000, 7 | } 8 | -------------------------------------------------------------------------------- /frontend/mock-db/run.mjs: -------------------------------------------------------------------------------- 1 | import console from 'console' 2 | import { dirname, resolve } from 'path' 3 | import process from 'process' 4 | import { fileURLToPath } from 'url' 5 | import { unstable_dev } from 'wrangler' 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)) 8 | 9 | /** 10 | * A simple utility to run a Cloudflare Worker that will populate a local D1 database with mock data. 11 | * 12 | * Uses Wrangler's `unstable_dev()` helper to execute the Worker and exit cleanly; 13 | * this is much harder to do with the command line Wrangler binary. 14 | */ 15 | async function main() { 16 | const options = { 17 | local: true, 18 | persist: true, 19 | nodeCompat: true, 20 | config: resolve(__dirname, '../../wrangler.toml'), 21 | tsconfig: resolve(__dirname, '../../tsconfig.json'), 22 | define: ['jest:{}'], 23 | } 24 | const workerPath = resolve(__dirname, './worker.ts') 25 | const worker = await unstable_dev(workerPath, { ...options, experimental: { disableExperimentalWarning: true } }) 26 | await worker.fetch() 27 | await worker.stop() 28 | } 29 | 30 | main().catch((e) => { 31 | console.error(e) 32 | process.exitCode = 1 33 | }) 34 | -------------------------------------------------------------------------------- /frontend/mock-db/worker.ts: -------------------------------------------------------------------------------- 1 | import { init } from './init' 2 | import { type Database } from 'wildebeest/backend/src/database' 3 | 4 | interface Env { 5 | DATABASE: Database 6 | } 7 | 8 | /** 9 | * A Cloudflare Worker that will run helpers against a D1 database to populate it with mock data. 10 | */ 11 | const handler: ExportedHandler = { 12 | async fetch(req, { DATABASE }) { 13 | const domain = new URL(req.url).hostname 14 | try { 15 | await init(domain, DATABASE) 16 | // eslint-disable-next-line no-console 17 | console.log('Database initialized.') 18 | } catch (e) { 19 | if (isD1ConstraintError(e)) { 20 | // eslint-disable-next-line no-console 21 | console.log('Database already initialized.') 22 | } else { 23 | throw e 24 | } 25 | } 26 | return new Response('OK') 27 | }, 28 | } 29 | 30 | /** 31 | * Check whether the error is because of a SQL constraint, 32 | * which will indicate that the database was already populated. 33 | */ 34 | function isD1ConstraintError(e: unknown) { 35 | return ( 36 | (e as { message: string }).message === 'D1_RUN_ERROR' && 37 | (e as { cause?: { code: string } }).cause?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY' 38 | ) 39 | } 40 | 41 | export default handler 42 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-qwik-basic-starter", 3 | "description": "App with Routing built-in (recommended)", 4 | "type": "module", 5 | "engines": { 6 | "node": ">=15.0.0" 7 | }, 8 | "private": true, 9 | "scripts": { 10 | "pretypes-check": "yarn build", 11 | "types-check": "tsc", 12 | "lint": "eslint src mock-db adaptors", 13 | "build": "vite build && vite build -c adaptors/cloudflare-pages/vite.config.ts", 14 | "dev": "vite --mode ssr", 15 | "watch": "nodemon -w ./src --ext tsx,ts --exec npm run build" 16 | }, 17 | "devDependencies": { 18 | "@builder.io/qwik": "0.21.0", 19 | "@builder.io/qwik-city": "0.4.0", 20 | "@types/eslint": "8.4.10", 21 | "@types/jest": "^29.2.4", 22 | "@types/node": "^18.11.16", 23 | "@types/node-fetch": "latest", 24 | "@typescript-eslint/eslint-plugin": "5.46.1", 25 | "@typescript-eslint/parser": "5.46.1", 26 | "autoprefixer": "10.4.11", 27 | "eslint": "8.30.0", 28 | "eslint-plugin-qwik": "0.16.1", 29 | "jest": "^29.3.1", 30 | "lorem-ipsum": "^2.0.8", 31 | "node-fetch": "3.3.0", 32 | "nodemon": "^2.0.20", 33 | "postcss": "^8.4.16", 34 | "prettier": "2.8.1", 35 | "sass": "^1.57.0", 36 | "tailwindcss": "^3.1.8", 37 | "ts-jest": "^29.0.3", 38 | "typescript": "4.9.4", 39 | "undici": "5.19.1", 40 | "vite": "4.0.1", 41 | "vite-tsconfig-paths": "3.5.0" 42 | }, 43 | "dependencies": { 44 | "modern-normalize": "^1.1.0" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /frontend/public/_headers: -------------------------------------------------------------------------------- 1 | # https://developers.cloudflare.com/pages/platform/headers/ 2 | 3 | /build/* 4 | Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable 5 | -------------------------------------------------------------------------------- /frontend/public/_redirects: -------------------------------------------------------------------------------- 1 | # https://developers.cloudflare.com/pages/platform/redirects/ 2 | -------------------------------------------------------------------------------- /frontend/public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": ["/build/*", "/assets/*"] 5 | } 6 | -------------------------------------------------------------------------------- /frontend/public/assets/wildebeest-splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/wildebeest/b056670a7204bc4d852c8a0cda9a3c9e39f8a0e1/frontend/public/assets/wildebeest-splash.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/web-manifest-combined.json", 3 | "name": "Wildebeest (Mastodon on Cloudflare)", 4 | "short_name": "Wildebeest", 5 | "start_url": ".", 6 | "display": "standalone", 7 | "background_color": "#fff", 8 | "description": "A mastodon server deployed on Cloudflare." 9 | } 10 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cloudflare/wildebeest/b056670a7204bc4d852c8a0cda9a3c9e39f8a0e1/frontend/public/robots.txt -------------------------------------------------------------------------------- /frontend/src/components/Accordion/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { component$, Slot, useSignal } from '@builder.io/qwik' 2 | 3 | type Props = { 4 | title: string 5 | } 6 | 7 | export const Accordion = component$(({ title }) => { 8 | const headerId = useSignal( 9 | `accordion-${title.replace(/\s/g, '_')}-${`${Math.round(Math.random() * 99999)}`.padStart(5, '0')}` 10 | ).value 11 | 12 | const expanded = useSignal(false) 13 | 14 | return ( 15 |
16 |
17 | 24 |
25 | {expanded.value && ( 26 |
27 | 28 |
29 | )} 30 |
31 | ) 32 | }) 33 | -------------------------------------------------------------------------------- /frontend/src/components/AccountCard/AccountCard.tsx: -------------------------------------------------------------------------------- 1 | import { component$ } from '@builder.io/qwik' 2 | import { Link } from '@builder.io/qwik-city' 3 | import { type Account } from '~/types' 4 | import { getDisplayNameElement } from '~/utils/getDisplayNameElement' 5 | import { useAccountUrl } from '~/utils/useAccountUrl' 6 | import { Avatar, type AvatarDetails } from '../avatar' 7 | 8 | export const AccountCard = component$<{ 9 | account: Account 10 | subText: 'username' | 'acct' 11 | secondaryAvatar?: AvatarDetails | null 12 | }>(({ account, subText, secondaryAvatar }) => { 13 | const accountUrl = useAccountUrl(account) 14 | 15 | return ( 16 | 17 |
18 | 19 |
20 |
21 |
{getDisplayNameElement(account)}
22 |
@{subText === 'username' ? account.username : account.acct}
23 |
24 | 25 | ) 26 | }) 27 | -------------------------------------------------------------------------------- /frontend/src/components/HtmlContent/HtmlContent.scss: -------------------------------------------------------------------------------- 1 | // Important: The rules present in this file apply to dynamic content 2 | // defined by the client (set using dangerouslySetInnerHTML) 3 | // (thus wee cannot rely on Tailwind for such content) 4 | 5 | .inner-html-content { 6 | :global(a) { 7 | text-decoration: none; 8 | color: var(--wildebeest-vibrant-color-400); 9 | &.mention { 10 | color: var(--wildebeest-vibrant-color-200); 11 | } 12 | &.hashtag { 13 | color: var(--wildebeest-vibrant-color-200); 14 | } 15 | } 16 | 17 | :global(:is(b, h1, h2, h3, h4, h5, h6)) { 18 | font-weight: 700; 19 | } 20 | 21 | :global(h1) { 22 | font-size: 1.5em; 23 | margin-top: 0; 24 | margin-bottom: 1em; 25 | line-height: 1.33; 26 | } 27 | 28 | :global(h2) { 29 | font-size: 1.25em; 30 | margin-top: 1.6em; 31 | margin-bottom: 0.6em; 32 | line-height: 1.6; 33 | } 34 | 35 | :global(:is(h3, h4, h5, h6)) { 36 | margin-top: 1.5em; 37 | margin-bottom: 0.5em; 38 | line-height: 1.5; 39 | } 40 | 41 | :global(p) { 42 | margin-bottom: theme('spacing.4'); 43 | overflow-wrap: break-word; 44 | white-space: pre-wrap; 45 | } 46 | 47 | :global(.invisible) { 48 | font-size: 0; 49 | line-height: 0; 50 | display: inline-block; 51 | width: 0; 52 | height: 0; 53 | } 54 | 55 | :global(.ellipsis) { 56 | text-overflow: ellipsis; 57 | white-space: nowrap; 58 | overflow: hidden; 59 | text-decoration: none; 60 | display: inline-block; 61 | vertical-align: middle; 62 | max-width: 50%; 63 | 64 | &:global(::after) { 65 | content: '…'; 66 | } 67 | 68 | @screen sm { 69 | & { 70 | display: inline; 71 | } 72 | } 73 | } 74 | 75 | :global(.status-link) { 76 | color: var(--wildebeest-vibrant-color-200); 77 | text-decoration: none; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /frontend/src/components/HtmlContent/HtmlContent.tsx: -------------------------------------------------------------------------------- 1 | import { component$, useStylesScoped$ } from '@builder.io/qwik' 2 | import styles from './HtmlContent.scss?inline' 3 | 4 | export const HtmlContent = component$<{ 5 | html: string 6 | }>(({ html }) => { 7 | useStylesScoped$(styles) 8 | 9 | return
10 | }) 11 | -------------------------------------------------------------------------------- /frontend/src/components/MediaGallery.tsx/Image.tsx: -------------------------------------------------------------------------------- 1 | import { component$, useStore, PropFunction } from '@builder.io/qwik' 2 | import { MediaAttachment } from '~/types' 3 | 4 | type Props = { 5 | mediaAttachment: MediaAttachment 6 | onOpenImagesModal$: PropFunction<(id: string) => void> 7 | } 8 | 9 | export const focusToObjectFit = (focus: { x: number; y: number }) => { 10 | const xShift = ((1 - Math.abs(focus.x)) / 2) * 100 11 | const yShift = ((1 - Math.abs(focus.y)) / 2) * 100 12 | 13 | const x2 = focus.x < 0 ? xShift : 100 - xShift 14 | const y2 = focus.y > 0 ? yShift : 100 - yShift 15 | 16 | return { x: Math.floor(x2 * 100) / 100, y: Math.floor(y2 * 100) / 100 } 17 | } 18 | 19 | export default component$(({ mediaAttachment, onOpenImagesModal$ }) => { 20 | const store = useStore({ 21 | isModalOpen: false, 22 | }) 23 | 24 | let objectFit: { x: number; y: number } | undefined 25 | if (mediaAttachment.meta.focus) { 26 | objectFit = focusToObjectFit(mediaAttachment.meta.focus) 27 | } 28 | 29 | return ( 30 | <> 31 |
32 | onOpenImagesModal$(mediaAttachment.id)} 39 | /> 40 |
41 | 42 | ) 43 | }) 44 | -------------------------------------------------------------------------------- /frontend/src/components/MediaGallery.tsx/ImagesModal.tsx: -------------------------------------------------------------------------------- 1 | import { component$, useSignal, PropFunction } from '@builder.io/qwik' 2 | import { MediaAttachment } from '~/types' 3 | 4 | type Props = { 5 | images: MediaAttachment[] 6 | idxOfCurrentImage: number 7 | onCloseImagesModal$: PropFunction<() => void> 8 | } 9 | 10 | export const ImagesModal = component$(({ images, idxOfCurrentImage: initialIdx, onCloseImagesModal$ }) => { 11 | const idxOfCurrentImage = useSignal(initialIdx) 12 | 13 | return ( 14 |
18 |
onCloseImagesModal$()}>
19 | {images.length > 1 && ( 20 | 30 | )} 31 | 32 | {images.length > 1 && ( 33 | 42 | )} 43 | 50 |
51 | ) 52 | }) 53 | -------------------------------------------------------------------------------- /frontend/src/components/MediaGallery.tsx/Video.tsx: -------------------------------------------------------------------------------- 1 | import { component$ } from '@builder.io/qwik' 2 | import { MediaAttachment } from '~/types' 3 | 4 | type Props = { 5 | mediaAttachment: MediaAttachment 6 | } 7 | 8 | export default component$(({ mediaAttachment }) => { 9 | return ( 10 |
11 | 14 |
15 | ) 16 | }) 17 | -------------------------------------------------------------------------------- /frontend/src/components/MediaGallery.tsx/index.scss: -------------------------------------------------------------------------------- 1 | .media-gallery:has(:nth-child(1)) { 2 | grid-template-columns: 1fr; 3 | grid-template-rows: 1fr; 4 | } 5 | 6 | .media-gallery:has(:nth-child(2)) { 7 | grid-template-columns: 1fr 1fr; 8 | } 9 | 10 | .media-gallery:has(:nth-child(3)) { 11 | grid-template-rows: 1fr 1fr; 12 | 13 | &:not(:has(:nth-child(4))) { 14 | :nth-child(1) { 15 | grid-column: 1; 16 | grid-row: 1 / -1; 17 | } 18 | 19 | :nth-child(2) { 20 | grid-column: 2; 21 | grid-row: 1; 22 | } 23 | 24 | :nth-child(3) { 25 | grid-column: 2; 26 | grid-row: 2; 27 | } 28 | } 29 | } 30 | 31 | .media-gallery:has(:nth-child(4)) { 32 | grid-template-columns: 1fr 1fr; 33 | grid-template-rows: 1fr 1fr; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/src/components/MediaGallery.tsx/index.tsx: -------------------------------------------------------------------------------- 1 | import { component$, useStylesScoped$, $, useStore } from '@builder.io/qwik' 2 | import { MediaAttachment } from '~/types' 3 | import Image from './Image' 4 | import Video from './Video' 5 | import styles from './index.scss?inline' 6 | import { ImagesModal } from './ImagesModal' 7 | 8 | type Props = { 9 | medias: MediaAttachment[] 10 | } 11 | 12 | export const MediaGallery = component$(({ medias }) => { 13 | useStylesScoped$(styles) 14 | 15 | const images = medias.filter((media) => media.type === 'image') 16 | 17 | const imagesModalState = useStore<{ isOpen: boolean; idxOfCurrentImage: number }>({ 18 | isOpen: false, 19 | idxOfCurrentImage: 0, 20 | }) 21 | 22 | const onOpenImagesModal = $((imgId: string) => { 23 | document.body.style.overflowY = 'hidden' 24 | imagesModalState.isOpen = true 25 | const idx = images.findIndex(({ id }) => id === imgId) 26 | imagesModalState.idxOfCurrentImage = idx === -1 ? 0 : idx 27 | }) 28 | 29 | const onCloseImagesModal = $(() => { 30 | document.body.style.overflowY = 'scroll' 31 | imagesModalState.isOpen = false 32 | }) 33 | 34 | return ( 35 | <> 36 | {!!medias.length && ( 37 | 48 | )} 49 | {imagesModalState.isOpen && ( 50 | 55 | )} 56 | 57 | ) 58 | }) 59 | -------------------------------------------------------------------------------- /frontend/src/components/ResultMessage/index.tsx: -------------------------------------------------------------------------------- 1 | import { component$ } from '@builder.io/qwik' 2 | 3 | type Props = { 4 | type: 'success' | 'failure' 5 | message: string 6 | } 7 | 8 | export default component$(({ type, message }: Props) => { 9 | const colorClasses = getColorClasses(type) 10 | return

{message}

11 | }) 12 | 13 | export function getColorClasses(type: Props['type']): string { 14 | switch (type) { 15 | case 'success': 16 | return 'bg-green-800 border-green-700 text-green-100' 17 | case 'failure': 18 | return 'bg-red-800 border-red-700 text-red-100 text-green-100' 19 | default: 20 | return 'bg-green-800 border-green-700 text-green-100' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/src/components/Settings/SubmitButton.tsx: -------------------------------------------------------------------------------- 1 | import { component$ } from '@builder.io/qwik' 2 | import Spinner from '../Spinner' 3 | 4 | type Props = { 5 | loading: boolean 6 | text: string 7 | } 8 | 9 | export const SubmitButton = component$(({ text, loading }) => { 10 | return ( 11 | 22 | ) 23 | }) 24 | -------------------------------------------------------------------------------- /frontend/src/components/Settings/TextArea.tsx: -------------------------------------------------------------------------------- 1 | import { component$, useSignal } from '@builder.io/qwik' 2 | 3 | type Props = { 4 | label: string 5 | name?: string 6 | description?: string 7 | class?: string 8 | invalid?: boolean 9 | value?: string 10 | required?: boolean 11 | } 12 | 13 | export const TextArea = component$( 14 | ({ class: className, label, name, description, invalid, value, required }) => { 15 | const inputId = useSignal(`${label.replace(/\s+/g, '_')}___${crypto.randomUUID()}`).value 16 | return ( 17 |
18 | 22 | {!!description &&
{description}
} 23 |