kopia lustrzana https://github.com/cloudflare/wildebeest
Merge branch 'main' into docs
commit
bd277c95d4
|
@ -18,6 +18,10 @@ module.exports = {
|
|||
'@typescript-eslint/no-unused-vars': 'error',
|
||||
'no-console': 'off',
|
||||
'no-constant-condition': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'error',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
/*
|
||||
Note: the following rules have been set to off so that linting
|
||||
can pass with the current code, but we need to gradually
|
||||
|
@ -25,13 +29,9 @@ module.exports = {
|
|||
*/
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/restrict-plus-operands': 'off',
|
||||
'@typescript-eslint/await-thenable': 'off',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
'@typescript-eslint/restrict-template-expressions': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-inferrable-types': 'off',
|
||||
|
|
|
@ -18,19 +18,41 @@ jobs:
|
|||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.13.x
|
||||
|
||||
- name: Install
|
||||
run: yarn
|
||||
|
||||
- name: Build
|
||||
run: yarn build
|
||||
- name: Check formatting
|
||||
run: yarn pretty
|
||||
- name: Check backend linting
|
||||
run: yarn lint:backend
|
||||
- name: Check functions linting
|
||||
run: yarn lint:functions
|
||||
|
||||
- name: Run API tests
|
||||
run: yarn test
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.13.x
|
||||
|
||||
- name: Install
|
||||
run: yarn && yarn --cwd frontend
|
||||
|
||||
- name: Check formatting
|
||||
run: yarn pretty
|
||||
|
||||
- name: Check backend linting
|
||||
run: yarn lint:backend
|
||||
|
||||
- name: Check functions linting
|
||||
run: yarn lint:functions
|
||||
|
||||
- name: Check frontend linting
|
||||
run: yarn lint:frontend
|
||||
|
||||
test-ui:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
@ -40,11 +62,17 @@ jobs:
|
|||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16.13.x
|
||||
- name: Install
|
||||
run: yarn && yarn --cwd frontend
|
||||
- name: Initialize local database
|
||||
run: yarn database:create-mock
|
||||
- name: Check frontend linting
|
||||
run: yarn lint:frontend
|
||||
- name: Run UI tests
|
||||
run: yarn test:ui
|
||||
- name: Install dependencies
|
||||
run: yarn
|
||||
- name: Run App in the background
|
||||
run: yarn ci-dev-test-ui &
|
||||
- name: Install Playwright Browsers
|
||||
run: yarn playwright install --with-deps
|
||||
- name: Run Playwright tests
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
|
|
@ -26,12 +26,19 @@ jobs:
|
|||
CF_DEPLOY_DOMAIN: ${{ vars.CF_DEPLOY_DOMAIN }}
|
||||
|
||||
# this is needed to get the lowercase version of the repository_owner name
|
||||
# TODO: switch to some lowercase function in the future when Actions supports it
|
||||
- name: Set lowercase repository_owner name
|
||||
# and being able to override the suffix when mutliple instances are hosted
|
||||
# by the same GitHub account.
|
||||
- name: Set name suffix
|
||||
run: |
|
||||
echo $GH_OWNER | awk '{ print "OWNER_LOWER=" tolower($0) }' >> ${GITHUB_ENV}
|
||||
if [ -z "$OVERRIDE_NAME_SUFFIX" ]
|
||||
then
|
||||
echo $GH_OWNER | awk '{ print "NAME_SUFFIX=" tolower($0) }' >> ${GITHUB_ENV}
|
||||
else
|
||||
echo $OVERRIDE_NAME_SUFFIX | awk '{ print "NAME_SUFFIX=" tolower($0) }' >> ${GITHUB_ENV}
|
||||
fi
|
||||
env:
|
||||
GH_OWNER: ${{ github.repository_owner }}
|
||||
OVERRIDE_NAME_SUFFIX: ${{ vars.OVERRIDE_NAME_SUFFIX }}
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
|
@ -45,10 +52,43 @@ jobs:
|
|||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Configure Cloudflare Images variants
|
||||
run: |
|
||||
curl -XPOST https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/images/v1/variants \
|
||||
-d '{
|
||||
"id": "avatar",
|
||||
"options": {
|
||||
"metadata": "copyright",
|
||||
"width": 400,
|
||||
"height": 400
|
||||
}
|
||||
}' \
|
||||
-H 'Authorization: Bearer ${{ secrets.CF_API_TOKEN }}'
|
||||
|
||||
curl -XPOST https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/images/v1/variants \
|
||||
-d '{
|
||||
"id": "header",
|
||||
"options": {
|
||||
"metadata": "copyright",
|
||||
"width": 1500,
|
||||
"height": 500
|
||||
}
|
||||
}' \
|
||||
-H 'Authorization: Bearer ${{ secrets.CF_API_TOKEN }}'
|
||||
|
||||
curl -XPOST https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/images/v1/variants \
|
||||
-d '{
|
||||
"id": "usercontent",
|
||||
"options": {
|
||||
"metadata": "copyright"
|
||||
}
|
||||
}' \
|
||||
-H 'Authorization: Bearer ${{ secrets.CF_API_TOKEN }}'
|
||||
|
||||
- name: Create D1 database
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
command: d1 create wildebeest-${{ env.OWNER_LOWER }}
|
||||
command: d1 create wildebeest-${{ env.NAME_SUFFIX }}
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
|
@ -57,7 +97,7 @@ jobs:
|
|||
- name: retrieve D1 database
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
command: d1 list | grep wildebeest-${{ env.OWNER_LOWER }} | awk '{print "d1_id="$2}' >> $GITHUB_ENV
|
||||
command: d1 list | grep "wildebeest-${{ env.NAME_SUFFIX }}\s" | awk '{print "d1_id="$2}' >> $GITHUB_ENV
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
@ -65,11 +105,11 @@ jobs:
|
|||
- name: migrate D1 database
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
command: d1 migrations apply wildebeest-${{ env.OWNER_LOWER }}
|
||||
command: d1 migrations apply wildebeest-${{ env.NAME_SUFFIX }}
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
preCommands: |
|
||||
echo "*** pre commands ***"
|
||||
echo -e "[[d1_databases]]\nbinding=\"DATABASE\"\ndatabase_name=\"wildebeest-${{ env.OWNER_LOWER }}\"\ndatabase_id=\"${{ env.d1_id }}\"" >> wrangler.toml
|
||||
echo -e "[[d1_databases]]\nbinding=\"DATABASE\"\ndatabase_name=\"wildebeest-${{ env.NAME_SUFFIX }}\"\ndatabase_id=\"${{ env.d1_id }}\"" >> wrangler.toml
|
||||
echo "******"
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
@ -83,7 +123,7 @@ jobs:
|
|||
- name: retrieve Terraform state KV namespace
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
command: kv:namespace list | jq -r '.[] | select( .title == "wildebeest-terraform-${{ env.OWNER_LOWER }}-state" ) | .id' | awk '{print "tfstate_kv="$1}' >> $GITHUB_ENV
|
||||
command: kv:namespace list | jq -r '.[] | select( .title == "wildebeest-terraform-${{ env.NAME_SUFFIX }}-state" ) | .id' | awk '{print "tfstate_kv="$1}' >> $GITHUB_ENV
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
preCommands: |
|
||||
echo "*** pre commands ***"
|
||||
|
@ -133,17 +173,31 @@ jobs:
|
|||
echo "VAPID keys generated"
|
||||
fi
|
||||
|
||||
- name: Publish DO
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
command: publish --config do/wrangler.toml
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
- name: Retrieve DO namespace
|
||||
run: |
|
||||
curl https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/workers/durable_objects/namespaces \
|
||||
-H 'Authorization: Bearer ${{ secrets.CF_API_TOKEN }}' \
|
||||
| jq -r '.result[] | select( .script == "wildebeest-do" ) | .id' | awk '{print "do_cache_id="$1}' >> $GITHUB_ENV
|
||||
|
||||
- name: Configure
|
||||
run: terraform plan && terraform apply -auto-approve
|
||||
continue-on-error: true
|
||||
working-directory: ./tf
|
||||
env:
|
||||
TF_VAR_cloudflare_account_id: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
TF_VAR_cloudflare_api_token: ${{ secrets.CF_API_TOKEN }}
|
||||
TF_VAR_cloudflare_zone_id: ${{ vars.CF_ZONE_ID }}
|
||||
TF_VAR_cloudflare_deploy_domain: ${{ vars.CF_DEPLOY_DOMAIN }}
|
||||
TF_VAR_gh_username: ${{ env.OWNER_LOWER }}
|
||||
TF_VAR_name_suffix: ${{ env.NAME_SUFFIX }}
|
||||
TF_VAR_d1_id: ${{ env.d1_id }}
|
||||
TF_VAR_do_cache_id: ${{ env.do_cache_id }}
|
||||
TF_VAR_access_auth_domain: ${{ env.auth_domain }}
|
||||
TF_VAR_wd_instance_title: ${{ vars.INSTANCE_TITLE }}
|
||||
TF_VAR_wd_admin_email: ${{ vars.ADMIN_EMAIL }}
|
||||
|
@ -155,7 +209,7 @@ jobs:
|
|||
- name: retrieve Terraform state KV namespace
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
command: kv:namespace list | jq -r '.[] | select( .title == "wildebeest-terraform-${{ env.OWNER_LOWER }}-state" ) | .id' | awk '{print "tfstate_kv="$1}' >> $GITHUB_ENV
|
||||
command: kv:namespace list | jq -r '.[] | select( .title == "wildebeest-terraform-${{ env.NAME_SUFFIX }}-state" ) | .id' | awk '{print "tfstate_kv="$1}' >> $GITHUB_ENV
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
preCommands: |
|
||||
echo "*** pre commands ***"
|
||||
|
@ -181,6 +235,63 @@ jobs:
|
|||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
- name: Create Queue
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
command: queues create wildebeest-${{ env.NAME_SUFFIX }}
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
- name: Publish consumer
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
preCommands: |
|
||||
echo "*** pre commands ***"
|
||||
echo -e "name = \"wildebeest-consumer-${{ env.NAME_SUFFIX }}\"\n" >> consumer/wrangler.toml
|
||||
|
||||
echo -e "[[queues.consumers]]\n" >> consumer/wrangler.toml
|
||||
echo -e "max_batch_size = 10\n" >> consumer/wrangler.toml
|
||||
echo -e "max_batch_timeout = 30\n" >> consumer/wrangler.toml
|
||||
echo -e "max_retries = 10\n" >> consumer/wrangler.toml
|
||||
echo -e "queue = \"wildebeest-${{ env.NAME_SUFFIX }}\"\n" >> consumer/wrangler.toml
|
||||
|
||||
echo -e "[[d1_databases]]\nbinding=\"DATABASE\"\ndatabase_name=\"wildebeest-${{ env.NAME_SUFFIX }}\"\ndatabase_id=\"${{ env.d1_id }}\"\n" >> consumer/wrangler.toml
|
||||
|
||||
echo -e "[durable_objects]\n" >> consumer/wrangler.toml
|
||||
echo -e "bindings=[" >> consumer/wrangler.toml
|
||||
echo -e "{name=\"DO_CACHE\",class_name=\"WildebeestCache\",script_name=\"wildebeest-do\"}," >> consumer/wrangler.toml
|
||||
echo -e "]" >> consumer/wrangler.toml
|
||||
|
||||
echo -e "[vars]\n" >> consumer/wrangler.toml
|
||||
echo -e "DOMAIN=\"${{ vars.CF_DEPLOY_DOMAIN }}\"\n" >> consumer/wrangler.toml
|
||||
echo -e "ADMIN_EMAIL=\"${{ vars.ADMIN_EMAIL }}\"\n" >> consumer/wrangler.toml
|
||||
|
||||
yarn --cwd consumer/
|
||||
echo "******"
|
||||
command: publish --config consumer/wrangler.toml
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
- name: add Queue producer to Pages
|
||||
run: |
|
||||
curl https://api.cloudflare.com/client/v4/accounts/${{ secrets.CF_ACCOUNT_ID }}/pages/projects/wildebeest-${{ env.NAME_SUFFIX }} \
|
||||
-XPATCH \
|
||||
-H 'Authorization: Bearer ${{ secrets.CF_API_TOKEN }}' \
|
||||
-d '{
|
||||
"deployment_configs": {
|
||||
"production": {
|
||||
"queue_producers": {
|
||||
"QUEUE": {
|
||||
"name": "wildebeest-${{ env.NAME_SUFFIX }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}' > /dev/null
|
||||
|
||||
- name: Publish
|
||||
uses: cloudflare/wrangler-action@2.0.0
|
||||
with:
|
||||
|
@ -191,9 +302,9 @@ jobs:
|
|||
yarn build
|
||||
cp -rv ./frontend/dist/* .
|
||||
# remove folder that aren't needed in Pages before we upload
|
||||
rm -rf ./tf ./scripts ./.github ./.npm
|
||||
rm -rf ./tf ./scripts ./.github ./.npm ./consumer ./*.md
|
||||
echo "******"
|
||||
command: pages publish --project-name=wildebeest-${{ env.OWNER_LOWER }} .
|
||||
command: pages publish --project-name=wildebeest-${{ env.NAME_SUFFIX }} .
|
||||
env:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
COMMIT_HASH: ${{ github.sha }}
|
||||
|
|
|
@ -2,4 +2,7 @@ node_modules/
|
|||
yarn-error.log
|
||||
package-lock.json
|
||||
.wrangler/state/d1/*.sqlite3
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"recommendations": ["ms-playwright.playwright"]
|
||||
}
|
|
@ -30,5 +30,4 @@ Follow this tutorial to deploy Wildebeest:
|
|||
- [Supported clients](docs/supported-clients.md)
|
||||
- [Updating Wildebeest](docs/updating.md)
|
||||
- [Other Cloudflare services](docs/other-services.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
|
@ -121,7 +121,7 @@ export const generateValidator =
|
|||
|
||||
const unroundedSecondsSinceEpoch = Date.now() / 1000
|
||||
|
||||
const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload)))
|
||||
const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload))) as JWTPayload
|
||||
|
||||
// For testing disable JWT checks.
|
||||
// Ideally we match the production behavior in testing but that
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
// https://docs.joinmastodon.org/methods/accounts/#get
|
||||
|
||||
import { actorURL, getActorById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
|
||||
import type { Handle } from 'wildebeest/backend/src/utils/parse'
|
||||
import { queryAcct } from 'wildebeest/backend/src/webfinger/index'
|
||||
import { loadExternalMastodonAccount, loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
|
||||
import { MastodonAccount } from '../types'
|
||||
|
||||
export async function getAccount(domain: string, accountId: string, db: D1Database): Promise<MastodonAccount | null> {
|
||||
const handle = parseHandle(accountId)
|
||||
|
||||
if (handle.domain === null || (handle.domain !== null && handle.domain === domain)) {
|
||||
// Retrieve the statuses from a local user
|
||||
return getLocalAccount(domain, db, handle)
|
||||
} else if (handle.domain !== null) {
|
||||
// Retrieve the statuses of a remote actor
|
||||
const acct = `${handle.localPart}@${handle.domain}`
|
||||
return getRemoteAccount(handle, acct)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getRemoteAccount(handle: Handle, acct: string): Promise<MastodonAccount | null> {
|
||||
// TODO: using webfinger isn't the optimal implementation. We could cache
|
||||
// the object in D1 and directly query the remote API, indicated by the actor's
|
||||
// url field. For now, let's keep it simple.
|
||||
const actor = await queryAcct(handle.domain!, acct)
|
||||
if (actor === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await loadExternalMastodonAccount(acct, actor, true)
|
||||
}
|
||||
|
||||
async function getLocalAccount(domain: string, db: D1Database, handle: Handle): Promise<MastodonAccount | null> {
|
||||
const actorId = actorURL(adjustLocalHostDomain(domain), handle.localPart)
|
||||
|
||||
const actor = await getActorById(db, actorId)
|
||||
if (actor === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return await loadLocalMastodonAccount(db, actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* checks if a domain is a localhost one ('localhost' or '127.x.x.x') and
|
||||
* in that case replaces it with '0.0.0.0' (which is what we use for our local data)
|
||||
*
|
||||
* Note: only needed for local development
|
||||
*
|
||||
* @param domain the potentially localhost domain
|
||||
* @returns the adjusted domain if it was a localhost one, the original domain otherwise
|
||||
*/
|
||||
function adjustLocalHostDomain(domain: string) {
|
||||
return domain.replace(/^localhost$|^127(\.(?:\d){1,3}){3}$/, '0.0.0.0')
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import type { Object } from '../objects'
|
||||
import type { APObject } from '../objects'
|
||||
import type { Actor } from '../actors'
|
||||
import type { Activity } from '.'
|
||||
|
||||
const ACCEPT = 'Accept'
|
||||
|
||||
export function create(actor: Actor, object: Object): Activity {
|
||||
export function create(actor: Actor, object: APObject): Activity {
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: ACCEPT,
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import type { APObject } from '../objects'
|
||||
import type { Actor } from '../actors'
|
||||
import type { Activity } from '.'
|
||||
import * as activity from '.'
|
||||
|
||||
const DELETE = 'Delete'
|
||||
|
||||
export function create(domain: string, actor: Actor, object: APObject): Activity {
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||
id: activity.uri(domain),
|
||||
type: DELETE,
|
||||
actor: actor.id,
|
||||
object,
|
||||
}
|
||||
}
|
|
@ -1,10 +1,10 @@
|
|||
import type { Object } from '../objects'
|
||||
import type { APObject } from '../objects'
|
||||
import type { Actor } from '../actors'
|
||||
import type { Activity } from '.'
|
||||
|
||||
const FOLLOW = 'Follow'
|
||||
|
||||
export function create(actor: Actor, object: Object): Activity {
|
||||
export function create(actor: Actor, object: APObject): Activity {
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: FOLLOW,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
|
||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
|
||||
|
@ -9,26 +10,28 @@ import {
|
|||
sendMentionNotification,
|
||||
sendLikeNotification,
|
||||
sendFollowNotification,
|
||||
sendReblogNotification,
|
||||
createNotification,
|
||||
insertFollowNotification,
|
||||
sendReblogNotification,
|
||||
} from 'wildebeest/backend/src/mastodon/notification'
|
||||
import { type Object, updateObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { type APObject, updateObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
|
||||
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver'
|
||||
import { getSigningKey } from 'wildebeest/backend/src/mastodon/account'
|
||||
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
|
||||
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import { createReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
|
||||
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
|
||||
import { originalActorIdSymbol, deleteObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { hasReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
|
||||
function extractID(domain: string, s: string | URL): string {
|
||||
return s.toString().replace(`https://${domain}/ap/users/`, '')
|
||||
}
|
||||
|
||||
export function makeGetObjectAsId(activity: Activity): Function {
|
||||
export function makeGetObjectAsId(activity: Activity) {
|
||||
return () => {
|
||||
let url: any = null
|
||||
if (activity.object.id !== undefined) {
|
||||
|
@ -54,7 +57,7 @@ export function makeGetObjectAsId(activity: Activity): Function {
|
|||
}
|
||||
}
|
||||
|
||||
export function makeGetActorAsId(activity: Activity): Function {
|
||||
export function makeGetActorAsId(activity: Activity) {
|
||||
return () => {
|
||||
let url: any = null
|
||||
if (activity.actor.id !== undefined) {
|
||||
|
@ -99,20 +102,24 @@ export async function handle(
|
|||
const getObjectAsId = makeGetObjectAsId(activity)
|
||||
const getActorAsId = makeGetActorAsId(activity)
|
||||
|
||||
console.log(activity)
|
||||
switch (activity.type) {
|
||||
case 'Update': {
|
||||
requireComplexObject()
|
||||
const actorId = getActorAsId()
|
||||
const objectId = getObjectAsId()
|
||||
|
||||
if (!['Note', 'Person', 'Service'].includes(activity.object.type)) {
|
||||
console.warn('unsupported Update for Object type: ' + activity.object.type)
|
||||
return
|
||||
}
|
||||
|
||||
// check current object
|
||||
const object = await objects.getObjectBy(db, 'original_object_id', objectId.toString())
|
||||
if (object === null) {
|
||||
throw new Error(`object ${objectId} does not exist`)
|
||||
}
|
||||
|
||||
if (actorId.toString() !== object.originalActorId) {
|
||||
if (actorId.toString() !== object[originalActorIdSymbol]) {
|
||||
throw new Error('actorid mismatch when updating object')
|
||||
}
|
||||
|
||||
|
@ -131,11 +138,17 @@ export async function handle(
|
|||
// FIXME: download any attachment Objects
|
||||
|
||||
let recipients: Array<string> = []
|
||||
let target = PUBLIC_GROUP
|
||||
|
||||
if (Array.isArray(activity.to)) {
|
||||
if (Array.isArray(activity.to) && activity.to.length > 0) {
|
||||
recipients = [...recipients, ...activity.to]
|
||||
|
||||
if (activity.to.length !== 1) {
|
||||
console.warn("multiple `Activity.to` isn't supported")
|
||||
}
|
||||
target = activity.to[0]
|
||||
}
|
||||
if (Array.isArray(activity.cc)) {
|
||||
if (Array.isArray(activity.cc) && activity.cc.length > 0) {
|
||||
recipients = [...recipients, ...activity.cc]
|
||||
}
|
||||
|
||||
|
@ -172,16 +185,22 @@ export async function handle(
|
|||
const fromActor = await actors.getAndCache(getActorAsId(), db)
|
||||
// Add the object in the originating actor's outbox, allowing other
|
||||
// actors on this instance to see the note in their timelines.
|
||||
await addObjectInOutbox(db, fromActor, obj, activity.published)
|
||||
await addObjectInOutbox(db, fromActor, obj, activity.published, target)
|
||||
|
||||
for (let i = 0, len = recipients.length; i < len; i++) {
|
||||
const url = new URL(recipients[i])
|
||||
if (url.hostname !== domain) {
|
||||
console.warn('recipients is not for this instance')
|
||||
continue
|
||||
}
|
||||
|
||||
const handle = parseHandle(extractID(domain, recipients[i]))
|
||||
if (handle.domain !== null && handle.domain !== domain) {
|
||||
console.warn('activity not for current instance')
|
||||
continue
|
||||
}
|
||||
|
||||
const person = await actors.getPersonById(db, actorURL(domain, handle.localPart))
|
||||
const person = await actors.getActorById(db, actorURL(domain, handle.localPart))
|
||||
if (person === null) {
|
||||
console.warn(`person ${recipients[i]} not found`)
|
||||
continue
|
||||
|
@ -203,7 +222,7 @@ export async function handle(
|
|||
requireComplexObject()
|
||||
const actorId = getActorAsId()
|
||||
|
||||
const actor = await actors.getPersonById(db, activity.object.actor)
|
||||
const actor = await actors.getActorById(db, activity.object.actor)
|
||||
if (actor !== null) {
|
||||
const follower = await actors.getAndCache(new URL(actorId), db)
|
||||
await acceptFollowing(db, actor, follower)
|
||||
|
@ -219,20 +238,18 @@ export async function handle(
|
|||
const objectId = getObjectAsId()
|
||||
const actorId = getActorAsId()
|
||||
|
||||
const receiver = await actors.getPersonById(db, objectId)
|
||||
const receiver = await actors.getActorById(db, objectId)
|
||||
if (receiver !== null) {
|
||||
const originalActor = await actors.getAndCache(new URL(actorId), db)
|
||||
const receiverAcct = `${receiver.preferredUsername}@${domain}`
|
||||
|
||||
await Promise.all([
|
||||
addFollowing(db, originalActor, receiver, receiverAcct),
|
||||
acceptFollowing(db, originalActor, receiver),
|
||||
])
|
||||
await addFollowing(db, originalActor, receiver, receiverAcct)
|
||||
|
||||
// Automatically send the Accept reply
|
||||
await acceptFollowing(db, originalActor, receiver)
|
||||
const reply = accept.create(receiver, activity)
|
||||
const signingKey = await getSigningKey(userKEK, db, receiver)
|
||||
await deliverToActor(signingKey, receiver, originalActor, reply)
|
||||
await deliverToActor(signingKey, receiver, originalActor, reply, domain)
|
||||
|
||||
// Notify the user
|
||||
const notifId = await insertFollowNotification(db, receiver, originalActor)
|
||||
|
@ -273,8 +290,14 @@ export async function handle(
|
|||
|
||||
const fromActor = await actors.getAndCache(actorId, db)
|
||||
|
||||
if (await hasReblog(db, fromActor, obj)) {
|
||||
// A reblog already exists. To avoid dulicated reblog we ignore.
|
||||
console.warn('probably duplicated Announce message')
|
||||
break
|
||||
}
|
||||
|
||||
// notify the user
|
||||
const targetActor = await actors.getPersonById(db, new URL(obj.originalActorId))
|
||||
const targetActor = await actors.getActorById(db, new URL(obj[originalActorIdSymbol]))
|
||||
if (targetActor === null) {
|
||||
console.warn('object actor not found')
|
||||
break
|
||||
|
@ -283,15 +306,10 @@ export async function handle(
|
|||
const notifId = await createNotification(db, 'reblog', targetActor, fromActor, obj)
|
||||
|
||||
await Promise.all([
|
||||
// Add the object in the originating actor's outbox, allowing other
|
||||
// actors on this instance to see the note in their timelines.
|
||||
addObjectInOutbox(db, fromActor, obj, activity.published),
|
||||
|
||||
// Store the reblog for counting
|
||||
insertReblog(db, fromActor, obj),
|
||||
|
||||
createReblog(db, fromActor, obj),
|
||||
sendReblogNotification(db, fromActor, targetActor, notifId, adminEmail, vapidKeys),
|
||||
])
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
|
@ -301,13 +319,13 @@ export async function handle(
|
|||
const objectId = getObjectAsId()
|
||||
|
||||
const obj = await objects.getObjectById(db, objectId)
|
||||
if (obj === null || !obj.originalActorId) {
|
||||
if (obj === null || !obj[originalActorIdSymbol]) {
|
||||
console.warn('unknown object')
|
||||
break
|
||||
}
|
||||
|
||||
const fromActor = await actors.getAndCache(actorId, db)
|
||||
const targetActor = await actors.getPersonById(db, new URL(obj.originalActorId))
|
||||
const targetActor = await actors.getActorById(db, new URL(obj[originalActorIdSymbol]))
|
||||
if (targetActor === null) {
|
||||
console.warn('object actor not found')
|
||||
break
|
||||
|
@ -324,6 +342,31 @@ export async function handle(
|
|||
break
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete
|
||||
case 'Delete': {
|
||||
const objectId = getObjectAsId()
|
||||
const actorId = getActorAsId()
|
||||
|
||||
const obj = await objects.getObjectByOriginalId(db, objectId)
|
||||
if (obj === null || !obj[originalActorIdSymbol]) {
|
||||
console.warn('unknown object or missing originalActorId')
|
||||
break
|
||||
}
|
||||
|
||||
if (actorId.toString() !== obj[originalActorIdSymbol]) {
|
||||
console.warn(`authorized Delete (${actorId} vs ${obj[originalActorIdSymbol]})`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!['Note'].includes(obj.type)) {
|
||||
console.warn('unsupported Update for Object type: ' + activity.object.type)
|
||||
return
|
||||
}
|
||||
|
||||
await deleteObject(db, obj)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
console.warn(`Unsupported activity: ${activity.type}`)
|
||||
}
|
||||
|
@ -331,11 +374,11 @@ export async function handle(
|
|||
|
||||
async function cacheObject(
|
||||
domain: string,
|
||||
obj: Object,
|
||||
obj: APObject,
|
||||
db: D1Database,
|
||||
originalActorId: URL,
|
||||
originalObjectId: URL
|
||||
): Promise<{ created: boolean; object: Object } | null> {
|
||||
): Promise<{ created: boolean; object: APObject } | null> {
|
||||
switch (obj.type) {
|
||||
case 'Note': {
|
||||
return objects.cacheObject(domain, db, obj, originalActorId, originalObjectId, false)
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
export type Activity = any
|
||||
|
||||
export const PUBLIC_GROUP = 'https://www.w3.org/ns/activitystreams#Public'
|
||||
|
||||
// Generate a unique ID. Note that currently the generated URL aren't routable.
|
||||
export function uri(domain: string): URL {
|
||||
const id = crypto.randomUUID()
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import type { Object } from '../objects'
|
||||
import type { APObject } from '../objects'
|
||||
import type { Actor } from '../actors'
|
||||
import type { Activity } from '.'
|
||||
import * as follow from './follow'
|
||||
|
||||
const UNDO = 'Undo'
|
||||
|
||||
export function create(actor: Actor, object: Object): Activity {
|
||||
export function create(actor: Actor, object: APObject): Activity {
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: UNDO,
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import type { Object } from '../objects'
|
||||
import type { APObject } from '../objects'
|
||||
import type { Actor } from '../actors'
|
||||
import type { Activity } from '.'
|
||||
import * as activity from '.'
|
||||
|
||||
const UPDATE = 'Update'
|
||||
|
||||
export function create(domain: string, actor: Actor, object: Object): Activity {
|
||||
export function create(domain: string, actor: Actor, object: APObject): Activity {
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/activitystreams'],
|
||||
id: activity.uri(domain),
|
||||
|
|
|
@ -1,24 +1,35 @@
|
|||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/core'
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/objects/collection'
|
||||
import { getMetadata, loadItems } from 'wildebeest/backend/src/activitypub/objects/collection'
|
||||
|
||||
const headers = {
|
||||
accept: 'application/activity+json',
|
||||
export async function countFollowing(actor: Actor): Promise<number> {
|
||||
const collection = await getMetadata(actor.following)
|
||||
return collection.totalItems
|
||||
}
|
||||
|
||||
export async function getFollowingMetadata(actor: Actor): Promise<OrderedCollection<unknown>> {
|
||||
const res = await fetch(actor.following, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`${actor.following} returned ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json<OrderedCollection<unknown>>()
|
||||
export async function countFollowers(actor: Actor): Promise<number> {
|
||||
const collection = await getMetadata(actor.followers)
|
||||
return collection.totalItems
|
||||
}
|
||||
|
||||
export async function getFollowersMetadata(actor: Actor): Promise<OrderedCollection<unknown>> {
|
||||
const res = await fetch(actor.followers, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`${actor.followers} returned ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json<OrderedCollection<unknown>>()
|
||||
export async function getFollowers(actor: Actor): Promise<OrderedCollection<string>> {
|
||||
const collection = await getMetadata(actor.followers)
|
||||
collection.items = await loadItems<string>(collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
export async function getFollowing(actor: Actor): Promise<OrderedCollection<string>> {
|
||||
const collection = await getMetadata(actor.following)
|
||||
collection.items = await loadItems<string>(collection)
|
||||
return collection
|
||||
}
|
||||
|
||||
export async function loadActors(db: D1Database, collection: OrderedCollection<string>): Promise<Array<Actor>> {
|
||||
const promises = collection.items.map((item) => {
|
||||
const actorId = new URL(item)
|
||||
return actors.getAndCache(actorId, db)
|
||||
})
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
|
||||
export async function addObjectInInbox(db: D1Database, actor: Actor, obj: Object) {
|
||||
export async function addObjectInInbox(db: D1Database, actor: Actor, obj: APObject) {
|
||||
const id = crypto.randomUUID()
|
||||
const out = await db
|
||||
.prepare('INSERT INTO inbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)')
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { defaultImages } from 'wildebeest/config/accounts'
|
||||
import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops'
|
||||
import type { Object } from '../objects'
|
||||
import { type APObject, sanitizeContent, getTextContent } from '../objects'
|
||||
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
|
||||
|
||||
const PERSON = 'Person'
|
||||
const isTesting = typeof jest !== 'undefined'
|
||||
|
@ -27,7 +28,7 @@ export function followersURL(id: URL): URL {
|
|||
}
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
|
||||
export interface Actor extends Object {
|
||||
export interface Actor extends APObject {
|
||||
inbox: URL
|
||||
outbox: URL
|
||||
following: URL
|
||||
|
@ -58,6 +59,16 @@ export async function get(url: string | URL): Promise<Actor> {
|
|||
const actor: Actor = { ...data }
|
||||
actor.id = new URL(data.id)
|
||||
|
||||
if (data.content) {
|
||||
actor.content = await sanitizeContent(data.content)
|
||||
}
|
||||
if (data.name) {
|
||||
actor.name = await getTextContent(data.name)
|
||||
}
|
||||
if (data.preferredUsername) {
|
||||
actor.preferredUsername = await getTextContent(data.preferredUsername)
|
||||
}
|
||||
|
||||
// This is mostly for testing where for convenience not all values
|
||||
// are provided.
|
||||
// TODO: eventually clean that to better match production.
|
||||
|
@ -77,10 +88,13 @@ export async function get(url: string | URL): Promise<Actor> {
|
|||
return actor
|
||||
}
|
||||
|
||||
// Get and cache the Actor locally
|
||||
export async function getAndCache(url: URL, db: D1Database): Promise<Actor> {
|
||||
const person = await getPersonById(db, url)
|
||||
if (person !== null) {
|
||||
return person
|
||||
{
|
||||
const actor = await getActorById(db, url)
|
||||
if (actor !== null) {
|
||||
return actor
|
||||
}
|
||||
}
|
||||
|
||||
const actor = await get(url)
|
||||
|
@ -102,6 +116,12 @@ export async function getAndCache(url: URL, db: D1Database): Promise<Actor> {
|
|||
if (!success) {
|
||||
throw new Error('SQL error: ' + error)
|
||||
}
|
||||
|
||||
// Add peer
|
||||
{
|
||||
const domain = actor.id.host
|
||||
await addPeer(db, domain)
|
||||
}
|
||||
return actor
|
||||
}
|
||||
|
||||
|
@ -180,8 +200,8 @@ export async function updateActorProperty(db: D1Database, actorId: URL, key: str
|
|||
}
|
||||
}
|
||||
|
||||
export async function getPersonById(db: D1Database, id: URL): Promise<Person | null> {
|
||||
const stmt = db.prepare('SELECT * FROM actors WHERE id=? AND type=?').bind(id.toString(), PERSON)
|
||||
export async function getActorById(db: D1Database, id: URL): Promise<Actor | null> {
|
||||
const stmt = db.prepare('SELECT * FROM actors WHERE id=?').bind(id.toString())
|
||||
const { results } = await stmt.all()
|
||||
if (!results || results.length === 0) {
|
||||
return null
|
||||
|
|
|
@ -1,21 +1,29 @@
|
|||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core'
|
||||
import type { OrderedCollection } from 'wildebeest/backend/src/activitypub/objects/collection'
|
||||
import { getMetadata, loadItems } from 'wildebeest/backend/src/activitypub/objects/collection'
|
||||
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
|
||||
|
||||
export async function addObjectInOutbox(db: D1Database, actor: Actor, obj: Object, published_date?: string) {
|
||||
export async function addObjectInOutbox(
|
||||
db: D1Database,
|
||||
actor: Actor,
|
||||
obj: APObject,
|
||||
published_date?: string,
|
||||
target: string = PUBLIC_GROUP
|
||||
) {
|
||||
const id = crypto.randomUUID()
|
||||
let out: any = null
|
||||
|
||||
if (published_date !== undefined) {
|
||||
out = await db
|
||||
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date) VALUES(?, ?, ?, ?)')
|
||||
.bind(id, actor.id.toString(), obj.id.toString(), published_date)
|
||||
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date, target) VALUES(?, ?, ?, ?, ?)')
|
||||
.bind(id, actor.id.toString(), obj.id.toString(), published_date, target)
|
||||
.run()
|
||||
} else {
|
||||
out = await db
|
||||
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)')
|
||||
.bind(id, actor.id.toString(), obj.id.toString())
|
||||
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, target) VALUES(?, ?, ?, ?)')
|
||||
.bind(id, actor.id.toString(), obj.id.toString(), target)
|
||||
.run()
|
||||
}
|
||||
if (!out.success) {
|
||||
|
@ -23,35 +31,14 @@ export async function addObjectInOutbox(db: D1Database, actor: Actor, obj: Objec
|
|||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
accept: 'application/activity+json',
|
||||
}
|
||||
|
||||
export async function getMetadata(actor: Actor): Promise<OrderedCollection<unknown>> {
|
||||
const res = await fetch(actor.outbox, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`${actor.outbox} returned ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json<OrderedCollection<unknown>>()
|
||||
}
|
||||
|
||||
export async function get(actor: Actor): Promise<OrderedCollection<Activity>> {
|
||||
const collection = await getMetadata(actor)
|
||||
const collection = await getMetadata(actor.outbox)
|
||||
collection.items = await loadItems(collection, 20)
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async function loadItems<T>(collection: OrderedCollection<T>, max: number): Promise<Array<T>> {
|
||||
// FIXME: implement max and multi page support
|
||||
|
||||
const res = await fetch(collection.first, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`${collection.first} returned ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json<OrderedCollectionPage<T>>()
|
||||
return data.orderedItems
|
||||
export async function countStatuses(actor: Actor): Promise<number> {
|
||||
const metadata = await getMetadata(actor.outbox)
|
||||
return metadata.totalItems
|
||||
}
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
export interface Collection<T> extends Object {
|
||||
totalItems: number
|
||||
current?: string
|
||||
first: URL
|
||||
last: URL
|
||||
items: Array<T>
|
||||
}
|
||||
|
||||
export interface OrderedCollection<T> extends Collection<T> {}
|
||||
|
||||
export interface OrderedCollectionPage<T> extends Object {
|
||||
orderedItems: Array<T>
|
||||
}
|
|
@ -1,17 +1,28 @@
|
|||
// https://www.w3.org/TR/activitypub/#delivery
|
||||
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { MessageSendRequest, Queue, DeliverMessageBody } from 'wildebeest/backend/src/types/queue'
|
||||
import { MessageType } from 'wildebeest/backend/src/types/queue'
|
||||
import type { Activity } from './activities'
|
||||
import type { Actor } from './actors'
|
||||
import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage'
|
||||
import { signRequest } from 'wildebeest/backend/src/utils/http-signing'
|
||||
import { getFollowers } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import { getFederationUA } from 'wildebeest/config/ua'
|
||||
|
||||
const headers = {
|
||||
'content-type': 'application/activity+json',
|
||||
}
|
||||
const MAX_BATCH_SIZE = 100
|
||||
|
||||
export async function deliverToActor(
|
||||
signingKey: CryptoKey,
|
||||
from: Actor,
|
||||
to: Actor,
|
||||
activity: Activity,
|
||||
domain: string
|
||||
) {
|
||||
const headers = {
|
||||
Accept: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
'User-Agent': getFederationUA(domain),
|
||||
}
|
||||
|
||||
export async function deliverToActor(signingKey: CryptoKey, from: Actor, to: Actor, activity: Activity) {
|
||||
const body = JSON.stringify(activity)
|
||||
console.log({ body })
|
||||
const req = new Request(to.inbox, {
|
||||
|
@ -28,48 +39,47 @@ export async function deliverToActor(signingKey: CryptoKey, from: Actor, to: Act
|
|||
const body = await res.text()
|
||||
throw new Error(`delivery to ${to.inbox} returned ${res.status}: ${body}`)
|
||||
}
|
||||
{
|
||||
const body = await res.text()
|
||||
console.log(`${to.inbox} returned 200: ${body}`)
|
||||
}
|
||||
console.log(`${to.inbox} returned 200`)
|
||||
}
|
||||
|
||||
export async function deliverFollowers(db: D1Database, signingKey: CryptoKey, from: Actor, activity: Activity) {
|
||||
const body = JSON.stringify(activity)
|
||||
// TODO: eventually move this to the queue worker, the backend can send a message
|
||||
// to a collection (followers) and the worker creates the indivual messages. More
|
||||
// reliable and scalable.
|
||||
export async function deliverFollowers(
|
||||
db: D1Database,
|
||||
userKEK: string,
|
||||
from: Actor,
|
||||
activity: Activity,
|
||||
queue: Queue<DeliverMessageBody>
|
||||
) {
|
||||
const followers = await getFollowers(db, from)
|
||||
if (followers.length === 0) {
|
||||
// No one is following the user so no updates to send. Sad.
|
||||
return
|
||||
}
|
||||
|
||||
const promises = followers.map(async (id) => {
|
||||
const follower = new URL(id)
|
||||
const messages: Array<MessageSendRequest<DeliverMessageBody>> = followers.map((id) => {
|
||||
const body = {
|
||||
// Make sure the object is supported by `structuredClone()`, ie
|
||||
// removing the URL objects as they aren't clonabled.
|
||||
activity: JSON.parse(JSON.stringify(activity)),
|
||||
|
||||
// FIXME: When an actor follows another Actor we should download its object
|
||||
// locally, so we can retrieve the Actor's inbox without a request.
|
||||
|
||||
const targetActor = await actors.getAndCache(follower, db)
|
||||
if (targetActor === null) {
|
||||
console.warn(`actor ${follower} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const req = new Request(targetActor.inbox, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers,
|
||||
})
|
||||
const digest = await generateDigestHeader(body)
|
||||
req.headers.set('Digest', digest)
|
||||
await signRequest(req, signingKey, new URL(from.id))
|
||||
|
||||
const res = await fetch(req)
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
console.error(`delivery to ${targetActor.inbox} returned ${res.status}: ${body}`)
|
||||
return
|
||||
}
|
||||
{
|
||||
const body = await res.text()
|
||||
console.log(`${targetActor.inbox} returned 200: ${body}`)
|
||||
actorId: from.id.toString(),
|
||||
toActorId: id,
|
||||
type: MessageType.Deliver,
|
||||
userKEK,
|
||||
}
|
||||
return { body }
|
||||
})
|
||||
|
||||
const promises = []
|
||||
|
||||
// Send the messages as batch in the queue. Since queue support up to 100
|
||||
// messages per batch, send multiple batches.
|
||||
while (messages.length > 0) {
|
||||
const batch = messages.splice(0, MAX_BATCH_SIZE)
|
||||
promises.push(queue.sendBatch(batch))
|
||||
}
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
export interface Collection<T> extends APObject {
|
||||
totalItems: number
|
||||
current?: string
|
||||
first: URL
|
||||
last: URL
|
||||
items: Array<T>
|
||||
}
|
||||
|
||||
export interface OrderedCollection<T> extends Collection<T> {}
|
||||
|
||||
export interface OrderedCollectionPage<T> extends APObject {
|
||||
orderedItems: Array<T>
|
||||
}
|
||||
|
||||
const headers = {
|
||||
accept: 'application/activity+json',
|
||||
}
|
||||
|
||||
export async function getMetadata(url: URL): Promise<OrderedCollection<any>> {
|
||||
const res = await fetch(url, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`${url} returned ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json<OrderedCollection<any>>()
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export async function loadItems<T>(collection: OrderedCollection<T>, max?: number): Promise<Array<T>> {
|
||||
// FIXME: implement max and multi page support
|
||||
|
||||
const res = await fetch(collection.first, { headers })
|
||||
if (!res.ok) {
|
||||
throw new Error(`${collection.first} returned ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json<OrderedCollectionPage<T>>()
|
||||
return data.orderedItems
|
||||
}
|
|
@ -4,7 +4,9 @@ import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
|||
export const IMAGE = 'Image'
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image
|
||||
export interface Image extends objects.Document {}
|
||||
export interface Image extends objects.Document {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function createImage(domain: string, db: D1Database, actor: Actor, properties: any): Promise<Image> {
|
||||
const actorId = new URL(actor.id)
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
import type { UUID } from 'wildebeest/backend/src/types'
|
||||
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
|
||||
|
||||
export const originalActorIdSymbol = Symbol()
|
||||
export const originalObjectIdSymbol = Symbol()
|
||||
export const mastodonIdSymbol = Symbol()
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
||||
export interface Object {
|
||||
export interface APObject {
|
||||
type: string
|
||||
// ObjectId, URL used for federation. Called `uri` in Mastodon APIs.
|
||||
// https://www.w3.org/TR/activitypub/#obj-id
|
||||
id: URL
|
||||
// Link to the HTML representation of the object
|
||||
url: URL
|
||||
published?: string
|
||||
icon?: Object
|
||||
image?: Object
|
||||
icon?: APObject
|
||||
image?: APObject
|
||||
summary?: string
|
||||
name?: string
|
||||
mediaType?: string
|
||||
|
@ -17,45 +25,46 @@ export interface Object {
|
|||
// Extension
|
||||
preferredUsername?: string
|
||||
// Internal
|
||||
originalActorId?: string
|
||||
originalObjectId?: string
|
||||
mastodonId?: UUID
|
||||
[originalActorIdSymbol]?: string
|
||||
[originalObjectIdSymbol]?: string
|
||||
[mastodonIdSymbol]?: UUID
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
|
||||
export interface Document extends Object {}
|
||||
export interface Document extends APObject {}
|
||||
|
||||
export function uri(domain: string, id: string): URL {
|
||||
return new URL('/ap/o/' + id, 'https://' + domain)
|
||||
}
|
||||
|
||||
export async function createObject(
|
||||
export async function createObject<Type extends APObject>(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
type: string,
|
||||
properties: any,
|
||||
originalActorId: URL,
|
||||
local: boolean
|
||||
): Promise<Object> {
|
||||
): Promise<Type> {
|
||||
const uuid = crypto.randomUUID()
|
||||
const apId = uri(domain, uuid).toString()
|
||||
const sanitizedProperties = await sanitizeObjectProperties(properties)
|
||||
|
||||
const row: any = await db
|
||||
.prepare(
|
||||
'INSERT INTO objects(id, type, properties, original_actor_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?) RETURNING *'
|
||||
)
|
||||
.bind(apId, type, JSON.stringify(properties), originalActorId.toString(), local ? 1 : 0, uuid)
|
||||
.bind(apId, type, JSON.stringify(sanitizedProperties), originalActorId.toString(), local ? 1 : 0, uuid)
|
||||
.first()
|
||||
|
||||
return {
|
||||
...properties,
|
||||
|
||||
...sanitizedProperties,
|
||||
type,
|
||||
id: new URL(row.id),
|
||||
mastodonId: row.mastodon_id,
|
||||
published: new Date(row.cdate).toISOString(),
|
||||
originalActorId: row.original_actor_id,
|
||||
} as Object
|
||||
|
||||
[mastodonIdSymbol]: row.mastodon_id,
|
||||
[originalActorIdSymbol]: row.original_actor_id,
|
||||
} as Type
|
||||
}
|
||||
|
||||
export async function get<T>(url: URL): Promise<T> {
|
||||
|
@ -72,17 +81,19 @@ export async function get<T>(url: URL): Promise<T> {
|
|||
|
||||
type CacheObjectRes = {
|
||||
created: boolean
|
||||
object: Object
|
||||
object: APObject
|
||||
}
|
||||
|
||||
export async function cacheObject(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
properties: any,
|
||||
properties: unknown,
|
||||
originalActorId: URL,
|
||||
originalObjectId: URL,
|
||||
local: boolean
|
||||
): Promise<CacheObjectRes> {
|
||||
const sanitizedProperties = await sanitizeObjectProperties(properties)
|
||||
|
||||
const cachedObject = await getObjectBy(db, 'original_object_id', originalObjectId.toString())
|
||||
if (cachedObject !== null) {
|
||||
return {
|
||||
|
@ -100,8 +111,8 @@ export async function cacheObject(
|
|||
)
|
||||
.bind(
|
||||
apId,
|
||||
properties.type,
|
||||
JSON.stringify(properties),
|
||||
sanitizedProperties.type,
|
||||
JSON.stringify(sanitizedProperties),
|
||||
originalActorId.toString(),
|
||||
originalObjectId.toString(),
|
||||
local ? 1 : 0,
|
||||
|
@ -109,6 +120,12 @@ export async function cacheObject(
|
|||
)
|
||||
.first()
|
||||
|
||||
// Add peer
|
||||
{
|
||||
const domain = originalObjectId.host
|
||||
await addPeer(db, domain)
|
||||
}
|
||||
|
||||
{
|
||||
const properties = JSON.parse(row.properties)
|
||||
const object = {
|
||||
|
@ -117,10 +134,11 @@ export async function cacheObject(
|
|||
|
||||
type: row.type,
|
||||
id: new URL(row.id),
|
||||
mastodonId: row.mastodon_id,
|
||||
originalActorId: row.original_actor_id,
|
||||
originalObjectId: row.original_object_id,
|
||||
} as Object
|
||||
|
||||
[mastodonIdSymbol]: row.mastodon_id,
|
||||
[originalActorIdSymbol]: row.original_actor_id,
|
||||
[originalObjectIdSymbol]: row.original_object_id,
|
||||
} as APObject
|
||||
|
||||
return { object, created: true }
|
||||
}
|
||||
|
@ -138,19 +156,29 @@ export async function updateObject(db: D1Database, properties: any, id: URL): Pr
|
|||
return true
|
||||
}
|
||||
|
||||
export async function getObjectById(db: D1Database, id: string | URL): Promise<Object | null> {
|
||||
export async function updateObjectProperty(db: D1Database, obj: APObject, key: string, value: string) {
|
||||
const { success, error } = await db
|
||||
.prepare(`UPDATE objects SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`)
|
||||
.bind(value, obj.id.toString())
|
||||
.run()
|
||||
if (!success) {
|
||||
throw new Error('SQL error: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getObjectById(db: D1Database, id: string | URL): Promise<APObject | null> {
|
||||
return getObjectBy(db, 'id', id.toString())
|
||||
}
|
||||
|
||||
export async function getObjectByOriginalId(db: D1Database, id: string | URL): Promise<Object | null> {
|
||||
export async function getObjectByOriginalId(db: D1Database, id: string | URL): Promise<APObject | null> {
|
||||
return getObjectBy(db, 'original_object_id', id.toString())
|
||||
}
|
||||
|
||||
export async function getObjectByMastodonId(db: D1Database, id: UUID): Promise<Object | null> {
|
||||
export async function getObjectByMastodonId(db: D1Database, id: UUID): Promise<APObject | null> {
|
||||
return getObjectBy(db, 'mastodon_id', id)
|
||||
}
|
||||
|
||||
export async function getObjectBy(db: D1Database, key: string, value: string): Promise<Object | null> {
|
||||
export async function getObjectBy(db: D1Database, key: string, value: string) {
|
||||
const query = `
|
||||
SELECT *
|
||||
FROM objects
|
||||
|
@ -174,8 +202,111 @@ WHERE objects.${key}=?
|
|||
|
||||
type: result.type,
|
||||
id: new URL(result.id),
|
||||
mastodonId: result.mastodon_id,
|
||||
originalActorId: result.original_actor_id,
|
||||
originalObjectId: result.original_object_id,
|
||||
} as Object
|
||||
|
||||
[mastodonIdSymbol]: result.mastodon_id,
|
||||
[originalActorIdSymbol]: result.original_actor_id,
|
||||
[originalObjectIdSymbol]: result.original_object_id,
|
||||
} as APObject
|
||||
}
|
||||
|
||||
/** Is the given `value` an ActivityPub Object? */
|
||||
export function isAPObject(value: unknown): value is APObject {
|
||||
return value !== null && typeof value === 'object'
|
||||
}
|
||||
|
||||
/** Sanitizes the ActivityPub Object `properties` prior to being stored in the DB. */
|
||||
export async function sanitizeObjectProperties(properties: unknown): Promise<APObject> {
|
||||
if (!isAPObject(properties)) {
|
||||
throw new Error('Invalid object properties. Expected an object but got ' + JSON.stringify(properties))
|
||||
}
|
||||
const sanitized: APObject = {
|
||||
...properties,
|
||||
}
|
||||
if ('content' in properties) {
|
||||
sanitized.content = await sanitizeContent(properties.content as string)
|
||||
}
|
||||
if ('name' in properties) {
|
||||
sanitized.name = await getTextContent(properties.name as string)
|
||||
}
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given string as ActivityPub Object content.
|
||||
*
|
||||
* This sanitization follows that of Mastodon
|
||||
* - convert all elements to `<p>` unless they are recognized as one of `<p>`, `<span>`, `<br>` or `<a>`.
|
||||
* - remove all CSS classes that are not micro-formats or semantic.
|
||||
*
|
||||
* See https://docs.joinmastodon.org/spec/activitypub/#sanitization
|
||||
*/
|
||||
export async function sanitizeContent(unsafeContent: string): Promise<string> {
|
||||
return await getContentRewriter().transform(new Response(unsafeContent)).text()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method removes all HTML elements from the string leaving only the text content.
|
||||
*/
|
||||
export async function getTextContent(unsafeName: string): Promise<string> {
|
||||
const rawContent = getTextContentRewriter().transform(new Response(unsafeName))
|
||||
const text = await rawContent.text()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
function getContentRewriter() {
|
||||
const contentRewriter = new HTMLRewriter()
|
||||
contentRewriter.on('*', {
|
||||
element(el) {
|
||||
if (!['p', 'span', 'br', 'a'].includes(el.tagName)) {
|
||||
el.tagName = 'p'
|
||||
}
|
||||
|
||||
if (el.hasAttribute('class')) {
|
||||
const classes = el.getAttribute('class')!.split(/\s+/)
|
||||
const sanitizedClasses = classes.filter((c) =>
|
||||
/^(h|p|u|dt|e)-|^mention$|^hashtag$|^ellipsis$|^invisible$/.test(c)
|
||||
)
|
||||
el.setAttribute('class', sanitizedClasses.join(' '))
|
||||
}
|
||||
},
|
||||
})
|
||||
return contentRewriter
|
||||
}
|
||||
|
||||
function getTextContentRewriter() {
|
||||
const textContentRewriter = new HTMLRewriter()
|
||||
textContentRewriter.on('*', {
|
||||
element(el) {
|
||||
el.removeAndKeepContent()
|
||||
if (['p', 'br'].includes(el.tagName)) {
|
||||
el.after(' ')
|
||||
}
|
||||
},
|
||||
})
|
||||
return textContentRewriter
|
||||
}
|
||||
|
||||
// TODO: eventually use SQLite's `ON DELETE CASCADE` but requires writing the DB
|
||||
// schema directly into D1, which D1 disallows at the moment.
|
||||
// Some context at: https://stackoverflow.com/questions/13150075/add-on-delete-cascade-behavior-to-an-sqlite3-table-after-it-has-been-created
|
||||
export async function deleteObject<T extends APObject>(db: D1Database, note: T) {
|
||||
const nodeId = note.id.toString()
|
||||
const batch = [
|
||||
db.prepare('DELETE FROM outbox_objects WHERE object_id=?').bind(nodeId),
|
||||
db.prepare('DELETE FROM inbox_objects WHERE object_id=?').bind(nodeId),
|
||||
db.prepare('DELETE FROM actor_notifications WHERE object_id=?').bind(nodeId),
|
||||
db.prepare('DELETE FROM actor_favourites WHERE object_id=?').bind(nodeId),
|
||||
db.prepare('DELETE FROM actor_reblogs WHERE object_id=?').bind(nodeId),
|
||||
db.prepare('DELETE FROM actor_replies WHERE object_id=?1 OR in_reply_to_object_id=?1').bind(nodeId),
|
||||
db.prepare('DELETE FROM idempotency_keys WHERE object_id=?').bind(nodeId),
|
||||
db.prepare('DELETE FROM objects WHERE id=?').bind(nodeId),
|
||||
]
|
||||
|
||||
const res = await db.batch(batch)
|
||||
|
||||
for (let i = 0, len = res.length; i < len; i++) {
|
||||
if (!res[i].success) {
|
||||
throw new Error('SQL error: ' + res[i].error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
export interface Link extends APObject {
|
||||
href: URL
|
||||
name: string
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import type { Link } from 'wildebeest/backend/src/activitypub/objects/link'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
|
||||
|
||||
export interface Mention extends Link {}
|
||||
|
||||
export function newMention(actor: Actor): Mention {
|
||||
return {
|
||||
type: 'Mention',
|
||||
id: actor.id,
|
||||
url: actor.id,
|
||||
|
||||
href: actor.id,
|
||||
name: urlToHandle(actor.id),
|
||||
}
|
||||
}
|
|
@ -1,24 +1,24 @@
|
|||
// https://www.w3.org/TR/activitystreams-vocabulary/#object-types
|
||||
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { Document } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Link } from 'wildebeest/backend/src/activitypub/objects/link'
|
||||
import { followersURL } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
|
||||
import * as objects from '.'
|
||||
|
||||
const NOTE = 'Note'
|
||||
export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
|
||||
|
||||
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
|
||||
export interface Note extends objects.Object {
|
||||
export interface Note extends objects.APObject {
|
||||
content: string
|
||||
attributedTo?: string
|
||||
summary?: string
|
||||
inReplyTo?: string
|
||||
replies?: string
|
||||
to: Array<string>
|
||||
attachment: Array<Document>
|
||||
cc?: Array<string>
|
||||
tag?: Array<string>
|
||||
attachment: Array<objects.APObject>
|
||||
cc: Array<string>
|
||||
tag: Array<Link>
|
||||
}
|
||||
|
||||
export async function createPublicNote(
|
||||
|
@ -26,7 +26,7 @@ export async function createPublicNote(
|
|||
db: D1Database,
|
||||
content: string,
|
||||
actor: Actor,
|
||||
attachment: Array<Document> = [],
|
||||
attachments: Array<objects.APObject> = [],
|
||||
extraProperties: any = {}
|
||||
): Promise<Note> {
|
||||
const actorId = new URL(actor.id)
|
||||
|
@ -34,9 +34,40 @@ export async function createPublicNote(
|
|||
const properties = {
|
||||
attributedTo: actorId,
|
||||
content,
|
||||
to: [PUBLIC],
|
||||
to: [PUBLIC_GROUP],
|
||||
cc: [followersURL(actorId)],
|
||||
|
||||
// FIXME: stub values
|
||||
replies: null,
|
||||
sensitive: false,
|
||||
summary: null,
|
||||
tag: [],
|
||||
|
||||
attachment: attachments,
|
||||
inReplyTo: null,
|
||||
...extraProperties,
|
||||
}
|
||||
|
||||
return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note
|
||||
}
|
||||
|
||||
export async function createPrivateNote(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
content: string,
|
||||
actor: Actor,
|
||||
targetActor: Actor,
|
||||
attachment: Array<objects.APObject> = [],
|
||||
extraProperties: any = {}
|
||||
): Promise<Note> {
|
||||
const actorId = new URL(actor.id)
|
||||
|
||||
const properties = {
|
||||
attributedTo: actorId,
|
||||
content,
|
||||
to: [targetActor.id.toString()],
|
||||
cc: [],
|
||||
|
||||
// FIXME: stub values
|
||||
inReplyTo: null,
|
||||
replies: null,
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { getResultsField } from 'wildebeest/backend/src/mastodon/utils'
|
||||
|
||||
export async function getPeers(db: D1Database): Promise<Array<String>> {
|
||||
const query = `SELECT domain FROM peers `
|
||||
const statement = db.prepare(query)
|
||||
|
||||
return getResultsField(statement, 'domain')
|
||||
}
|
||||
|
||||
export async function addPeer(db: D1Database, domain: string): Promise<void> {
|
||||
const query = `
|
||||
INSERT OR IGNORE INTO peers (domain)
|
||||
VALUES (?)
|
||||
`
|
||||
|
||||
const out = await db.prepare(query).bind(domain).run()
|
||||
if (!out.success) {
|
||||
throw new Error('SQL error: ' + out.error)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import type { Env } from 'wildebeest/consumer/src'
|
||||
|
||||
const CACHE_DO_NAME = 'cachev1'
|
||||
|
||||
export interface Cache {
|
||||
get<T>(key: string): Promise<T | null>
|
||||
put<T>(key: string, value: T): Promise<void>
|
||||
}
|
||||
|
||||
export function cacheFromEnv(env: Env): Cache {
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const id = env.DO_CACHE.idFromName(CACHE_DO_NAME)
|
||||
const stub = env.DO_CACHE.get(id)
|
||||
|
||||
const res = await stub.fetch('http://cache/' + key)
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
throw new Error(`DO cache returned ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
|
||||
return (await res.json()) as T
|
||||
},
|
||||
|
||||
async put<T>(key: string, value: T): Promise<void> {
|
||||
const id = env.DO_CACHE.idFromName(CACHE_DO_NAME)
|
||||
const stub = env.DO_CACHE.get(id)
|
||||
|
||||
const res = await stub.fetch('http://cache/', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`DO cache returned ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
|
@ -1,11 +1,12 @@
|
|||
import { cors } from 'wildebeest/backend/src/utils/cors'
|
||||
|
||||
type ErrorResponse = {
|
||||
error: string
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'content-type, authorization',
|
||||
...cors(),
|
||||
'content-type': 'application/json',
|
||||
} as const
|
||||
|
||||
|
@ -36,3 +37,15 @@ export function clientUnknown(): Response {
|
|||
export function internalServerError(): Response {
|
||||
return generateErrorResponse('Internal Server Error', 500)
|
||||
}
|
||||
|
||||
export function statusNotFound(id: string): Response {
|
||||
return generateErrorResponse('Resource not found', 404, `Status "${id}" not found`)
|
||||
}
|
||||
|
||||
export function mediaNotFound(id: string): Response {
|
||||
return generateErrorResponse('Resource not found', 404, `Media "${id}" not found`)
|
||||
}
|
||||
|
||||
export function exceededLimit(detail: string): Response {
|
||||
return generateErrorResponse('Limit exceeded', 400, detail)
|
||||
}
|
||||
|
|
|
@ -32,20 +32,24 @@ function toMastodonAccount(acct: string, res: Actor): MastodonAccount {
|
|||
|
||||
emojis: [],
|
||||
fields: [],
|
||||
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
statuses_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Load an external user, using ActivityPub queries, and return it as a MastodonAccount
|
||||
export async function loadExternalMastodonAccount(
|
||||
acct: string,
|
||||
res: Actor,
|
||||
actor: Actor,
|
||||
loadStats: boolean = false
|
||||
): Promise<MastodonAccount> {
|
||||
const account = toMastodonAccount(acct, res)
|
||||
const account = toMastodonAccount(acct, actor)
|
||||
if (loadStats === true) {
|
||||
account.statuses_count = (await apOutbox.getMetadata(res)).totalItems
|
||||
account.followers_count = (await apFollow.getFollowersMetadata(res)).totalItems
|
||||
account.following_count = (await apFollow.getFollowingMetadata(res)).totalItems
|
||||
account.statuses_count = await apOutbox.countStatuses(actor)
|
||||
account.followers_count = await apFollow.countFollowers(actor)
|
||||
account.following_count = await apFollow.countFollowing(actor)
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ export async function addFollowing(db: D1Database, actor: Actor, target: Actor,
|
|||
const id = crypto.randomUUID()
|
||||
|
||||
const query = `
|
||||
INSERT INTO actor_following (id, actor_id, target_actor_id, state, target_actor_acct)
|
||||
INSERT OR IGNORE INTO actor_following (id, actor_id, target_actor_id, state, target_actor_acct)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import {
|
||||
mastodonIdSymbol,
|
||||
originalActorIdSymbol,
|
||||
originalObjectIdSymbol,
|
||||
} from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
export async function insertKey(db: D1Database, key: string, obj: APObject): Promise<void> {
|
||||
const query = `
|
||||
INSERT INTO idempotency_keys (key, object_id, expires_at)
|
||||
VALUES (?1, ?2, datetime('now', '+1 hour'))
|
||||
`
|
||||
|
||||
const { success, error } = await db.prepare(query).bind(key, obj.id.toString()).run()
|
||||
if (!success) {
|
||||
throw new Error('SQL error: ' + error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasKey(db: D1Database, key: string): Promise<APObject | null> {
|
||||
const query = `
|
||||
SELECT objects.*
|
||||
FROM idempotency_keys
|
||||
INNER JOIN objects ON objects.id = idempotency_keys.object_id
|
||||
WHERE idempotency_keys.key = ?1 AND expires_at >= datetime()
|
||||
`
|
||||
|
||||
const { results, success, error } = await db.prepare(query).bind(key).all<any>()
|
||||
if (!success) {
|
||||
throw new Error('SQL error: ' + error)
|
||||
}
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = results[0]
|
||||
const properties = JSON.parse(result.properties)
|
||||
|
||||
return {
|
||||
published: new Date(result.cdate).toISOString(),
|
||||
...properties,
|
||||
|
||||
type: result.type,
|
||||
id: new URL(result.id),
|
||||
|
||||
[mastodonIdSymbol]: result.mastodon_id,
|
||||
[originalActorIdSymbol]: result.original_actor_id,
|
||||
[originalObjectIdSymbol]: result.original_object_id,
|
||||
} as APObject
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { getResultsField } from './utils'
|
||||
|
||||
export async function insertLike(db: D1Database, actor: Actor, obj: Object) {
|
||||
export async function insertLike(db: D1Database, actor: Actor, obj: APObject) {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const query = `
|
||||
|
@ -16,7 +16,7 @@ export async function insertLike(db: D1Database, actor: Actor, obj: Object) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getLikes(db: D1Database, obj: Object): Promise<Array<string>> {
|
||||
export function getLikes(db: D1Database, obj: APObject): Promise<Array<string>> {
|
||||
const query = `
|
||||
SELECT actor_id FROM actor_favourites WHERE object_id=?
|
||||
`
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
|
||||
|
||||
function tag(name: string, content: string, attrs: Record<string, string> = {}): string {
|
||||
let htmlAttrs = ''
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
htmlAttrs += ` ${key}="${value}"`
|
||||
}
|
||||
|
||||
return `<${name}${htmlAttrs}>${content}</${name}>`
|
||||
}
|
||||
|
||||
const linkRegex = /(^|\s|\b)(https?:\/\/[-\w@:%._+~#=]{2,256}\.[a-z]{2,6}\b(?:[-\w@:%_+.~#?&/=]*))(\b|\s|$)/g
|
||||
const mentionedEmailRegex = /(^|\s|\b|\W)@(\w+(?:[.-]?\w+)+@\w+(?:[.-]?\w+)+(?:\.\w{2,63})+)(\b|\s|$)/g
|
||||
|
||||
/// Transform a text status into a HTML status; enriching it with links / mentions.
|
||||
export function enrichStatus(status: string): string {
|
||||
const enrichedStatus = status
|
||||
.replace(
|
||||
linkRegex,
|
||||
(_, matchPrefix: string, link: string, matchSuffix: string) =>
|
||||
`${matchPrefix}${getLinkAnchor(link)}${matchSuffix}`
|
||||
)
|
||||
.replace(
|
||||
mentionedEmailRegex,
|
||||
(_, matchPrefix: string, email: string, matchSuffix: string) =>
|
||||
`${matchPrefix}${getMentionSpan(email)}${matchSuffix}`
|
||||
)
|
||||
|
||||
return tag('p', enrichedStatus)
|
||||
}
|
||||
|
||||
function getMentionSpan(mentionedEmail: string) {
|
||||
const handle = parseHandle(mentionedEmail)
|
||||
|
||||
// TODO: the link to the profile is a guess, we could rely on
|
||||
// the cached Actors to find the right link.
|
||||
const linkToProfile = `https://${handle.domain}/@${handle.localPart}`
|
||||
|
||||
const mention = `@${tag('span', handle.localPart)}`
|
||||
return tag('span', tag('a', mention, { href: linkToProfile, class: 'u-url mention' }), {
|
||||
class: 'h-card',
|
||||
})
|
||||
}
|
||||
|
||||
function getLinkAnchor(link: string) {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
|
||||
return tag('a', url.hostname + url.pathname, { href: link })
|
||||
} catch (err: unknown) {
|
||||
console.warn('failed to parse link', err)
|
||||
return link
|
||||
}
|
||||
}
|
|
@ -1,32 +1,38 @@
|
|||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { defaultImages } from 'wildebeest/config/accounts'
|
||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
|
||||
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
|
||||
import { generateWebPushMessage } from 'wildebeest/backend/src/webpush'
|
||||
import { getPersonById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { getActorById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { WebPushInfos, WebPushMessage } from 'wildebeest/backend/src/webpush/webpushinfos'
|
||||
import { WebPushResult } from 'wildebeest/backend/src/webpush/webpushinfos'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { NotificationType, Notification } from 'wildebeest/backend/src/types/notification'
|
||||
import type {
|
||||
NotificationType,
|
||||
Notification,
|
||||
NotificationsQueryResult,
|
||||
} from 'wildebeest/backend/src/types/notification'
|
||||
import { getSubscriptionForAllClients } from 'wildebeest/backend/src/mastodon/subscription'
|
||||
import type { Cache } from 'wildebeest/backend/src/cache'
|
||||
|
||||
export async function createNotification(
|
||||
db: D1Database,
|
||||
type: NotificationType,
|
||||
actor: Actor,
|
||||
fromActor: Actor,
|
||||
obj: Object
|
||||
obj: APObject
|
||||
): Promise<string> {
|
||||
const query = `
|
||||
INSERT INTO actor_notifications (type, actor_id, from_actor_id, object_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
RETURNING id
|
||||
`
|
||||
const row: { id: string } = await db
|
||||
const row = await db
|
||||
.prepare(query)
|
||||
.bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString())
|
||||
.first()
|
||||
.first<{ id: string }>()
|
||||
return row.id
|
||||
}
|
||||
|
||||
|
@ -38,7 +44,7 @@ export async function insertFollowNotification(db: D1Database, actor: Actor, fro
|
|||
VALUES (?, ?, ?)
|
||||
RETURNING id
|
||||
`
|
||||
const row: { id: string } = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first()
|
||||
const row = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first<{ id: string }>()
|
||||
return row.id
|
||||
}
|
||||
|
||||
|
@ -50,11 +56,16 @@ export async function sendFollowNotification(
|
|||
adminEmail: string,
|
||||
vapidKeys: JWK
|
||||
) {
|
||||
let icon = new URL(defaultImages.avatar)
|
||||
if (follower.icon) {
|
||||
icon = follower.icon.url
|
||||
}
|
||||
|
||||
const data = {
|
||||
preferred_locale: 'en',
|
||||
notification_type: 'follow',
|
||||
notification_id: notificationId,
|
||||
icon: follower.icon!.url,
|
||||
icon,
|
||||
title: 'New follower',
|
||||
body: `${follower.name} is now following you`,
|
||||
}
|
||||
|
@ -77,11 +88,16 @@ export async function sendLikeNotification(
|
|||
adminEmail: string,
|
||||
vapidKeys: JWK
|
||||
) {
|
||||
let icon = new URL(defaultImages.avatar)
|
||||
if (fromActor.icon) {
|
||||
icon = fromActor.icon.url
|
||||
}
|
||||
|
||||
const data = {
|
||||
preferred_locale: 'en',
|
||||
notification_type: 'favourite',
|
||||
notification_id: notificationId,
|
||||
icon: fromActor.icon!.url,
|
||||
icon,
|
||||
title: 'New favourite',
|
||||
body: `${fromActor.name} favourited your status`,
|
||||
}
|
||||
|
@ -104,11 +120,16 @@ export async function sendMentionNotification(
|
|||
adminEmail: string,
|
||||
vapidKeys: JWK
|
||||
) {
|
||||
let icon = new URL(defaultImages.avatar)
|
||||
if (fromActor.icon) {
|
||||
icon = fromActor.icon.url
|
||||
}
|
||||
|
||||
const data = {
|
||||
preferred_locale: 'en',
|
||||
notification_type: 'mention',
|
||||
notification_id: notificationId,
|
||||
icon: fromActor.icon!.url,
|
||||
icon,
|
||||
title: 'New mention',
|
||||
body: `You were mentioned by ${fromActor.name}`,
|
||||
}
|
||||
|
@ -131,11 +152,16 @@ export async function sendReblogNotification(
|
|||
adminEmail: string,
|
||||
vapidKeys: JWK
|
||||
) {
|
||||
let icon = new URL(defaultImages.avatar)
|
||||
if (fromActor.icon) {
|
||||
icon = fromActor.icon.url
|
||||
}
|
||||
|
||||
const data = {
|
||||
preferred_locale: 'en',
|
||||
notification_type: 'reblog',
|
||||
notification_id: notificationId,
|
||||
icon: fromActor.icon!.url,
|
||||
icon,
|
||||
title: 'New boost',
|
||||
body: `${fromActor.name} boosted your status`,
|
||||
}
|
||||
|
@ -169,7 +195,7 @@ async function sendNotification(db: D1Database, actor: Actor, message: WebPushMe
|
|||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
export async function getNotifications(db: D1Database, actor: Actor): Promise<Array<Notification>> {
|
||||
export async function getNotifications(db: D1Database, actor: Actor, domain: string): Promise<Array<Notification>> {
|
||||
const query = `
|
||||
SELECT
|
||||
objects.*,
|
||||
|
@ -186,7 +212,7 @@ export async function getNotifications(db: D1Database, actor: Actor): Promise<Ar
|
|||
`
|
||||
|
||||
const stmt = db.prepare(query).bind(actor.id.toString())
|
||||
const { results, success, error } = await stmt.all()
|
||||
const { results, success, error } = await stmt.all<NotificationsQueryResult>()
|
||||
if (!success) {
|
||||
throw new Error('SQL error: ' + error)
|
||||
}
|
||||
|
@ -197,11 +223,11 @@ export async function getNotifications(db: D1Database, actor: Actor): Promise<Ar
|
|||
}
|
||||
|
||||
for (let i = 0, len = results.length; i < len; i++) {
|
||||
const result = results[i] as any
|
||||
const result = results[i]
|
||||
const properties = JSON.parse(result.properties)
|
||||
const notifFromActorId = new URL(result.notif_from_actor_id)
|
||||
|
||||
const notifFromActor = await getPersonById(db, notifFromActorId)
|
||||
const notifFromActor = await getActorById(db, notifFromActorId)
|
||||
if (!notifFromActor) {
|
||||
console.warn('unknown actor')
|
||||
continue
|
||||
|
@ -228,6 +254,7 @@ export async function getNotifications(db: D1Database, actor: Actor): Promise<Ar
|
|||
id: result.mastodon_id,
|
||||
content: properties.content,
|
||||
uri: result.id,
|
||||
url: new URL('/statuses/' + result.mastodon_id, 'https://' + domain),
|
||||
created_at: new Date(result.cdate).toISOString(),
|
||||
|
||||
emojis: [],
|
||||
|
@ -249,7 +276,7 @@ export async function getNotifications(db: D1Database, actor: Actor): Promise<Ar
|
|||
return out
|
||||
}
|
||||
|
||||
export async function pregenerateNotifications(db: D1Database, cache: KVNamespace, actor: Actor) {
|
||||
const notifications = await getNotifications(db, actor)
|
||||
await cache.put(actor.id + '/notifications', JSON.stringify(notifications))
|
||||
export async function pregenerateNotifications(db: D1Database, cache: Cache, actor: Actor, domain: string) {
|
||||
const notifications = await getNotifications(db, actor, domain)
|
||||
await cache.put(actor.id + '/notifications', notifications)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,22 @@
|
|||
// Also known as boost.
|
||||
|
||||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { getResultsField } from './utils'
|
||||
import { addObjectInOutbox } from '../activitypub/actors/outbox'
|
||||
|
||||
export async function insertReblog(db: D1Database, actor: Actor, obj: Object) {
|
||||
/**
|
||||
* Creates a reblog and inserts it in the reblog author's outbox
|
||||
*
|
||||
* @param db D1Database
|
||||
* @param actor Reblogger
|
||||
* @param obj ActivityPub object to reblog
|
||||
*/
|
||||
export async function createReblog(db: D1Database, actor: Actor, obj: APObject) {
|
||||
await Promise.all([addObjectInOutbox(db, actor, obj), insertReblog(db, actor, obj)])
|
||||
}
|
||||
|
||||
export async function insertReblog(db: D1Database, actor: Actor, obj: APObject) {
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
const query = `
|
||||
|
@ -18,7 +30,7 @@ export async function insertReblog(db: D1Database, actor: Actor, obj: Object) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getReblogs(db: D1Database, obj: Object): Promise<Array<string>> {
|
||||
export function getReblogs(db: D1Database, obj: APObject): Promise<Array<string>> {
|
||||
const query = `
|
||||
SELECT actor_id FROM actor_reblogs WHERE object_id=?
|
||||
`
|
||||
|
@ -27,3 +39,12 @@ export function getReblogs(db: D1Database, obj: Object): Promise<Array<string>>
|
|||
|
||||
return getResultsField(statement, 'actor_id')
|
||||
}
|
||||
|
||||
export async function hasReblog(db: D1Database, actor: Actor, obj: APObject): Promise<boolean> {
|
||||
const query = `
|
||||
SELECT count(*) as count FROM actor_reblogs WHERE object_id=?1 AND actor_id=?2
|
||||
`
|
||||
|
||||
const { count } = await db.prepare(query).bind(obj.id.toString(), actor.id.toString()).first<{ count: number }>()
|
||||
return count > 0
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { toMastodonStatusFromRow } from './status'
|
||||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
|
||||
|
||||
export async function insertReply(db: D1Database, actor: Actor, obj: Object, inReplyToObj: Object) {
|
||||
export async function insertReply(db: D1Database, actor: Actor, obj: APObject, inReplyToObj: APObject) {
|
||||
const id = crypto.randomUUID()
|
||||
const query = `
|
||||
INSERT INTO actor_replies (id, actor_id, object_id, in_reply_to_object_id)
|
||||
|
@ -18,7 +18,7 @@ export async function insertReply(db: D1Database, actor: Actor, obj: Object, inR
|
|||
}
|
||||
}
|
||||
|
||||
export async function getReplies(domain: string, db: D1Database, obj: Object): Promise<Array<MastodonStatus>> {
|
||||
export async function getReplies(domain: string, db: D1Database, obj: APObject): Promise<Array<MastodonStatus>> {
|
||||
const QUERY = `
|
||||
SELECT objects.*,
|
||||
actors.id as actor_id,
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
import type { Handle } from '../utils/parse'
|
||||
import { queryAcct } from 'wildebeest/backend/src/webfinger'
|
||||
import type { MediaAttachment } from 'wildebeest/backend/src/types/media'
|
||||
import type { UUID } from 'wildebeest/backend/src/types'
|
||||
import { getObjectByMastodonId, getObjectById } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import {
|
||||
getObjectByMastodonId,
|
||||
mastodonIdSymbol,
|
||||
originalActorIdSymbol,
|
||||
} from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { createPublicNote, type Note } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import * as media from 'wildebeest/backend/src/media/'
|
||||
import type { MastodonStatus } from 'wildebeest/backend/src/types'
|
||||
import { parseHandle } from '../utils/parse'
|
||||
import { urlToHandle } from '../utils/handle'
|
||||
import type { Person } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { addObjectInOutbox } from '../activitypub/actors/outbox'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
|
||||
export function getMentions(input: string): Array<Handle> {
|
||||
const mentions: Array<Handle> = []
|
||||
export async function getMentions(input: string, instanceDomain: string): Promise<Array<Actor>> {
|
||||
const mentions: Array<Actor> = []
|
||||
|
||||
for (let i = 0, len = input.length; i < len; i++) {
|
||||
if (input[i] === '@') {
|
||||
|
@ -22,20 +30,32 @@ export function getMentions(input: string): Array<Handle> {
|
|||
i++
|
||||
}
|
||||
|
||||
mentions.push(parseHandle(buffer))
|
||||
const handle = parseHandle(buffer)
|
||||
const domain = handle.domain ? handle.domain : instanceDomain
|
||||
const acct = `${handle.localPart}@${domain}`
|
||||
const targetActor = await queryAcct(domain!, acct)
|
||||
if (targetActor === null) {
|
||||
console.warn(`actor ${acct} not found`)
|
||||
continue
|
||||
}
|
||||
mentions.push(targetActor)
|
||||
}
|
||||
}
|
||||
|
||||
return mentions
|
||||
}
|
||||
|
||||
export async function toMastodonStatusFromObject(db: D1Database, obj: Note): Promise<MastodonStatus | null> {
|
||||
if (obj.originalActorId === undefined) {
|
||||
export async function toMastodonStatusFromObject(
|
||||
db: D1Database,
|
||||
obj: Note,
|
||||
domain: string
|
||||
): Promise<MastodonStatus | null> {
|
||||
if (obj[originalActorIdSymbol] === undefined) {
|
||||
console.warn('missing `obj.originalActorId`')
|
||||
return null
|
||||
}
|
||||
|
||||
const actorId = new URL(obj.originalActorId)
|
||||
const actorId = new URL(obj[originalActorIdSymbol])
|
||||
const actor = await actors.getAndCache(actorId, db)
|
||||
|
||||
const acct = urlToHandle(actorId)
|
||||
|
@ -47,22 +67,10 @@ export async function toMastodonStatusFromObject(db: D1Database, obj: Note): Pro
|
|||
// const favourites = await getLikes(db, obj)
|
||||
// const reblogs = await getReblogs(db, obj)
|
||||
|
||||
const mediaAttachments: Array<MediaAttachment> = []
|
||||
let mediaAttachments: Array<MediaAttachment> = []
|
||||
|
||||
if (Array.isArray(obj.attachment)) {
|
||||
for (let i = 0, len = obj.attachment.length; i < len; i++) {
|
||||
if (obj.attachment[i].id) {
|
||||
const document = await getObjectById(db, obj.attachment[i].id)
|
||||
if (document === null) {
|
||||
console.warn('missing attachment object: ' + obj.attachment[i].id)
|
||||
continue
|
||||
}
|
||||
|
||||
mediaAttachments.push(media.fromObject(document))
|
||||
} else {
|
||||
console.warn('attachment has no id')
|
||||
}
|
||||
}
|
||||
mediaAttachments = obj.attachment.map(media.fromObject)
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -77,8 +85,9 @@ export async function toMastodonStatusFromObject(db: D1Database, obj: Note): Pro
|
|||
|
||||
media_attachments: mediaAttachments,
|
||||
content: obj.content || '',
|
||||
id: obj.mastodonId || '',
|
||||
id: obj[mastodonIdSymbol] || '',
|
||||
uri: obj.id,
|
||||
url: new URL('/statuses/' + obj[mastodonIdSymbol], 'https://' + domain),
|
||||
created_at: obj.published || '',
|
||||
account,
|
||||
|
||||
|
@ -126,6 +135,7 @@ export async function toMastodonStatusFromRow(
|
|||
|
||||
const status: MastodonStatus = {
|
||||
id: row.mastodon_id,
|
||||
url: new URL('/statuses/' + row.mastodon_id, 'https://' + domain),
|
||||
uri: row.id,
|
||||
created_at: new Date(row.cdate).toISOString(),
|
||||
emojis: [],
|
||||
|
@ -170,10 +180,34 @@ export async function toMastodonStatusFromRow(
|
|||
return status
|
||||
}
|
||||
|
||||
export async function getMastodonStatusById(db: D1Database, id: UUID): Promise<MastodonStatus | null> {
|
||||
export async function getMastodonStatusById(db: D1Database, id: UUID, domain: string): Promise<MastodonStatus | null> {
|
||||
const obj = await getObjectByMastodonId(db, id)
|
||||
if (obj === null) {
|
||||
return null
|
||||
}
|
||||
return toMastodonStatusFromObject(db, obj as Note)
|
||||
return toMastodonStatusFromObject(db, obj as Note, domain)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a status object in the given actor's outbox.
|
||||
*
|
||||
* @param domain the domain to use
|
||||
* @param db D1Database
|
||||
* @param actor Author of the reply
|
||||
* @param content content of the reply
|
||||
* @param attachments optional attachments for the status
|
||||
* @param extraProperties optional extra properties for the status
|
||||
* @returns the created Note for the status
|
||||
*/
|
||||
export async function createStatus(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
actor: Person,
|
||||
content: string,
|
||||
attachments?: APObject[],
|
||||
extraProperties?: any
|
||||
) {
|
||||
const note = await createPublicNote(domain, db, content, actor, attachments, extraProperties)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
return note
|
||||
}
|
||||
|
|
|
@ -1,17 +1,47 @@
|
|||
import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
|
||||
import { getFollowingId } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors/'
|
||||
import { toMastodonStatusFromRow } from './status'
|
||||
import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities'
|
||||
import type { Cache } from 'wildebeest/backend/src/cache'
|
||||
|
||||
export async function pregenerateTimelines(domain: string, db: D1Database, cache: KVNamespace, actor: Actor) {
|
||||
export async function pregenerateTimelines(domain: string, db: D1Database, cache: Cache, actor: Actor) {
|
||||
const timeline = await getHomeTimeline(domain, db, actor)
|
||||
await cache.put(actor.id + '/timeline/home', JSON.stringify(timeline))
|
||||
await cache.put(actor.id + '/timeline/home', timeline)
|
||||
}
|
||||
|
||||
export async function getHomeTimeline(domain: string, db: D1Database, actor: Actor): Promise<Array<MastodonStatus>> {
|
||||
const following = await getFollowingId(db, actor)
|
||||
const { results: following } = await db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
actor_following.target_actor_id as id,
|
||||
json_extract(actors.properties, '$.followers') as actorFollowersURL
|
||||
FROM actor_following
|
||||
INNER JOIN actors ON actors.id = actor_following.target_actor_id
|
||||
WHERE actor_id=? AND state='accepted'
|
||||
`
|
||||
)
|
||||
.bind(actor.id.toString())
|
||||
.all<{ id: string; actorFollowersURL: string | null }>()
|
||||
|
||||
let followingIds: string[] = []
|
||||
let followingFollowersURLs: string[] = []
|
||||
|
||||
if (following) {
|
||||
followingIds = following.map((row) => row.id)
|
||||
followingFollowersURLs = following.map((row) => {
|
||||
if (row.actorFollowersURL) {
|
||||
return row.actorFollowersURL
|
||||
} else {
|
||||
// We don't have the Actor's followers URL stored, we'll guess
|
||||
// one.
|
||||
return row.id + '/followers'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// follow ourself to see our statuses in the our home timeline
|
||||
following.push(actor.id.toString())
|
||||
followingIds.push(actor.id.toString())
|
||||
|
||||
const QUERY = `
|
||||
SELECT objects.*,
|
||||
|
@ -22,23 +52,25 @@ SELECT objects.*,
|
|||
(SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count,
|
||||
(SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count,
|
||||
(SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count,
|
||||
(SELECT count(*) > 0 FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id AND actor_reblogs.actor_id=?) as reblogged,
|
||||
(SELECT count(*) > 0 FROM actor_favourites WHERE actor_favourites.object_id=objects.id AND actor_favourites.actor_id=?) as favourited
|
||||
(SELECT count(*) > 0 FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id AND actor_reblogs.actor_id=?1) as reblogged,
|
||||
(SELECT count(*) > 0 FROM actor_favourites WHERE actor_favourites.object_id=objects.id AND actor_favourites.actor_id=?1) as favourited
|
||||
FROM outbox_objects
|
||||
INNER JOIN objects ON objects.id = outbox_objects.object_id
|
||||
INNER JOIN actors ON actors.id = outbox_objects.actor_id
|
||||
WHERE
|
||||
objects.type = 'Note'
|
||||
AND outbox_objects.actor_id IN (SELECT value FROM json_each(?))
|
||||
AND outbox_objects.actor_id IN (SELECT value FROM json_each(?2))
|
||||
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
|
||||
AND (outbox_objects.target = '${PUBLIC_GROUP}' OR outbox_objects.target IN (SELECT value FROM json_each(?3)))
|
||||
GROUP BY objects.id
|
||||
ORDER by outbox_objects.published_date DESC
|
||||
LIMIT ?
|
||||
LIMIT ?4
|
||||
`
|
||||
const DEFAULT_LIMIT = 20
|
||||
|
||||
const { success, error, results } = await db
|
||||
.prepare(QUERY)
|
||||
.bind(actor.id.toString(), actor.id.toString(), JSON.stringify(following), DEFAULT_LIMIT)
|
||||
.bind(actor.id.toString(), JSON.stringify(followingIds), JSON.stringify(followingFollowersURLs), DEFAULT_LIMIT)
|
||||
.all()
|
||||
if (!success) {
|
||||
throw new Error('SQL error: ' + error)
|
||||
|
@ -97,6 +129,8 @@ INNER JOIN actors ON actors.id=outbox_objects.actor_id
|
|||
WHERE objects.type='Note'
|
||||
AND ${localPreferenceQuery(localPreference)}
|
||||
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
|
||||
AND outbox_objects.target = '${PUBLIC_GROUP}'
|
||||
GROUP BY objects.id
|
||||
ORDER by outbox_objects.published_date DESC
|
||||
LIMIT ?1 OFFSET ?2
|
||||
`
|
||||
|
|
|
@ -19,7 +19,15 @@ type UploadResult = {
|
|||
uploaded: string
|
||||
}
|
||||
|
||||
export async function uploadImage(file: File, config: Config): Promise<URL> {
|
||||
// https://docs.joinmastodon.org/user/profile/#avatar
|
||||
const AVATAR_VARIANT = 'avatar'
|
||||
|
||||
// https://docs.joinmastodon.org/user/profile/#header
|
||||
const HEADER_VARIANT = 'header'
|
||||
|
||||
const USER_CONTENT_VARIANT = 'usercontent'
|
||||
|
||||
async function upload(file: File, config: Config): Promise<UploadResult> {
|
||||
const formData = new FormData()
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1`
|
||||
|
||||
|
@ -43,7 +51,46 @@ export async function uploadImage(file: File, config: Config): Promise<URL> {
|
|||
throw new Error(`Cloudflare Images returned ${res.status}: ${body}`)
|
||||
}
|
||||
|
||||
// We assume there's only one variant for now.
|
||||
const variant = data.result.variants[0]
|
||||
return new URL(variant)
|
||||
return data.result
|
||||
}
|
||||
|
||||
function selectVariant(res: UploadResult, name: string): URL {
|
||||
for (let i = 0, len = res.variants.length; i < len; i++) {
|
||||
const variant = res.variants[i]
|
||||
if (variant.endsWith(`/${name}`)) {
|
||||
return new URL(variant)
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`variant "${name}" not found`)
|
||||
}
|
||||
|
||||
export async function uploadAvatar(file: File, config: Config): Promise<URL> {
|
||||
const result = await upload(file, config)
|
||||
return selectVariant(result, AVATAR_VARIANT)
|
||||
}
|
||||
|
||||
export async function uploadHeader(file: File, config: Config): Promise<URL> {
|
||||
const result = await upload(file, config)
|
||||
return selectVariant(result, HEADER_VARIANT)
|
||||
}
|
||||
|
||||
export async function uploadUserContent(request: Request, config: Config): Promise<URL> {
|
||||
const url = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1`
|
||||
const newRequest = new Request(url, request)
|
||||
newRequest.headers.set('authorization', 'Bearer ' + config.apiToken)
|
||||
|
||||
const res = await fetch(newRequest)
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Cloudflare Images returned ${res.status}: ${body}`)
|
||||
}
|
||||
|
||||
const data = await res.json<APIResult<UploadResult>>()
|
||||
if (!data.success) {
|
||||
const body = await res.text()
|
||||
throw new Error(`Cloudflare Images returned ${res.status}: ${body}`)
|
||||
}
|
||||
|
||||
return selectVariant(data.result, USER_CONTENT_VARIANT)
|
||||
}
|
||||
|
|
|
@ -1,59 +1,35 @@
|
|||
import type { MediaAttachment } from 'wildebeest/backend/src/types/media'
|
||||
import { IMAGE } from 'wildebeest/backend/src/activitypub/objects/image'
|
||||
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import type { Document } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { IMAGE, type Image } from 'wildebeest/backend/src/activitypub/objects/image'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { mastodonIdSymbol } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
export function fromObject(obj: Object): MediaAttachment {
|
||||
export function fromObject(obj: APObject): MediaAttachment {
|
||||
if (obj.type === IMAGE) {
|
||||
return fromObjectImage(obj)
|
||||
return fromObjectImage(obj as Image)
|
||||
} else if (obj.type === 'Video') {
|
||||
return fromObjectVideo(obj)
|
||||
} else if (obj.type === 'Document') {
|
||||
if (obj.mediaType === 'image/jpeg' || obj.mediaType === 'image/png') {
|
||||
return fromObjectImage(obj)
|
||||
} else if (obj.mediaType === 'video/mp4') {
|
||||
return {
|
||||
url: new URL(obj.url),
|
||||
preview_url: new URL(obj.url),
|
||||
id: obj.url.toString(),
|
||||
type: 'video',
|
||||
meta: {
|
||||
length: '0:01:28.65',
|
||||
duration: 88.65,
|
||||
fps: 24,
|
||||
size: '1280x720',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
aspect: 1.7777777777777777,
|
||||
audio_encode: 'aac (LC) (mp4a / 0x6134706D)',
|
||||
audio_bitrate: '44100 Hz',
|
||||
audio_channels: 'stereo',
|
||||
original: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frame_rate: '6159375/249269',
|
||||
duration: 88.654,
|
||||
bitrate: 862056,
|
||||
},
|
||||
small: {
|
||||
width: 400,
|
||||
height: 225,
|
||||
size: '400x225',
|
||||
aspect: 1.7777777777777777,
|
||||
},
|
||||
},
|
||||
description: 'test media description',
|
||||
blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}',
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unsupported media type ${obj.type}: ${JSON.stringify(obj)}`)
|
||||
}
|
||||
return fromObjectDocument(obj as Document)
|
||||
} else {
|
||||
throw new Error(`unsupported media type ${obj.type}: ${JSON.stringify(obj)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function fromObjectImage(obj: Object): MediaAttachment {
|
||||
export function fromObjectDocument(obj: Document): MediaAttachment {
|
||||
if (obj.mediaType === 'image/jpeg' || obj.mediaType === 'image/png') {
|
||||
return fromObjectImage(obj)
|
||||
} else if (obj.mediaType === 'video/mp4') {
|
||||
return fromObjectVideo(obj)
|
||||
} else {
|
||||
throw new Error(`unsupported media Document type: ${JSON.stringify(obj)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function fromObjectImage(obj: Image): MediaAttachment {
|
||||
return {
|
||||
url: new URL(obj.url),
|
||||
id: obj.mastodonId || obj.url.toString(),
|
||||
id: obj[mastodonIdSymbol] || obj.url.toString(),
|
||||
preview_url: new URL(obj.url),
|
||||
type: 'image',
|
||||
meta: {
|
||||
|
@ -74,6 +50,42 @@ function fromObjectImage(obj: Object): MediaAttachment {
|
|||
y: 0.51,
|
||||
},
|
||||
},
|
||||
description: obj.description || '',
|
||||
blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}',
|
||||
}
|
||||
}
|
||||
|
||||
function fromObjectVideo(obj: APObject): MediaAttachment {
|
||||
return {
|
||||
url: new URL(obj.url),
|
||||
preview_url: new URL(obj.url),
|
||||
id: obj.url.toString(),
|
||||
type: 'video',
|
||||
meta: {
|
||||
length: '0:01:28.65',
|
||||
duration: 88.65,
|
||||
fps: 24,
|
||||
size: '1280x720',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
aspect: 1.7777777777777777,
|
||||
audio_encode: 'aac (LC) (mp4a / 0x6134706D)',
|
||||
audio_bitrate: '44100 Hz',
|
||||
audio_channels: 'stereo',
|
||||
original: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
frame_rate: '6159375/249269',
|
||||
duration: 88.654,
|
||||
bitrate: 862056,
|
||||
},
|
||||
small: {
|
||||
width: 400,
|
||||
height: 225,
|
||||
size: '400x225',
|
||||
aspect: 1.7777777777777777,
|
||||
},
|
||||
},
|
||||
description: 'test media description',
|
||||
blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}',
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ export async function errorHandling(context: EventContext<Env, any, any>) {
|
|||
if (sentry !== null) {
|
||||
sentry.captureException(err)
|
||||
}
|
||||
console.error(err)
|
||||
console.error(err.stack, err.cause)
|
||||
return internalServerError()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as access from 'wildebeest/backend/src/access'
|
|||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { Env } from 'wildebeest/backend/src/types/env'
|
||||
import * as errors from 'wildebeest/backend/src/errors'
|
||||
import { cors } from 'wildebeest/backend/src/utils/cors'
|
||||
|
||||
async function loadContextData(db: D1Database, clientId: string, email: string, ctx: any): Promise<boolean> {
|
||||
const query = `
|
||||
|
@ -38,9 +39,7 @@ async function loadContextData(db: D1Database, clientId: string, email: string,
|
|||
export async function main(context: EventContext<Env, any, any>) {
|
||||
if (context.request.method === 'OPTIONS') {
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'content-type, authorization',
|
||||
'Access-Control-Allow-Methods': 'GET, PUT, POST',
|
||||
...cors(),
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
return new Response('', { headers })
|
||||
|
@ -52,6 +51,7 @@ export async function main(context: EventContext<Env, any, any>) {
|
|||
url.pathname === '/oauth/authorize' || // Cloudflare Access runs on /oauth/authorize
|
||||
url.pathname === '/api/v1/instance' ||
|
||||
url.pathname === '/api/v2/instance' ||
|
||||
url.pathname === '/api/v1/instance/peers' ||
|
||||
url.pathname === '/api/v1/apps' ||
|
||||
url.pathname === '/api/v1/timelines/public' ||
|
||||
url.pathname === '/api/v1/custom_emojis' ||
|
||||
|
|
|
@ -21,9 +21,9 @@ export interface MastodonAccount {
|
|||
discoverable?: boolean
|
||||
group?: boolean
|
||||
|
||||
followers_count?: number
|
||||
following_count?: number
|
||||
statuses_count?: number
|
||||
followers_count: number
|
||||
following_count: number
|
||||
statuses_count: number
|
||||
|
||||
emojis: Array<any>
|
||||
fields: Array<Field>
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import type { Queue, MessageBody } from 'wildebeest/backend/src/types/queue'
|
||||
|
||||
export interface Env {
|
||||
DATABASE: D1Database
|
||||
KV_CACHE: KVNamespace
|
||||
// FIXME: shouldn't it be USER_KEY?
|
||||
userKEK: string
|
||||
QUEUE: Queue<MessageBody>
|
||||
DO_CACHE: DurableObjectNamespace
|
||||
|
||||
CF_ACCOUNT_ID: string
|
||||
CF_API_TOKEN: string
|
||||
|
||||
// Configuration for Cloudflare Access
|
||||
DOMAIN: string
|
||||
ACCESS_AUD: string
|
||||
ACCESS_AUTH_DOMAIN: string
|
||||
|
||||
|
@ -16,6 +19,7 @@ export interface Env {
|
|||
ADMIN_EMAIL: string
|
||||
INSTANCE_DESCR: string
|
||||
VAPID_JWK: string
|
||||
DOMAIN: string
|
||||
|
||||
SENTRY_DSN: string
|
||||
SENTRY_ACCESS_CLIENT_ID: string
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { MastodonAccount } from 'wildebeest/backend/src/types/account'
|
||||
import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
|
||||
import type { ObjectsRow } from './objects'
|
||||
|
||||
export type NotificationType =
|
||||
| 'mention'
|
||||
|
@ -20,3 +21,12 @@ export type Notification = {
|
|||
account: MastodonAccount
|
||||
status?: MastodonStatus
|
||||
}
|
||||
|
||||
export interface NotificationsQueryResult extends ObjectsRow {
|
||||
type: NotificationType
|
||||
original_actor_id: URL
|
||||
notif_from_actor_id: URL
|
||||
notif_cdate: string
|
||||
notif_id: URL
|
||||
from_actor_id: string
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
export interface ObjectsRow {
|
||||
properties: string
|
||||
mastodon_id: string
|
||||
id: URL
|
||||
cdate: string
|
||||
}
|
|
@ -0,0 +1,42 @@
|
|||
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
|
||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
||||
|
||||
export enum MessageType {
|
||||
Inbox = 1,
|
||||
Deliver,
|
||||
}
|
||||
|
||||
export interface MessageBody {
|
||||
type: MessageType
|
||||
actorId: string
|
||||
}
|
||||
|
||||
// ActivityPub messages received by an Actor's Inbox are sent into the queue.
|
||||
export interface InboxMessageBody extends MessageBody {
|
||||
activity: Activity
|
||||
|
||||
// Send secrets as part of the message because it's too complicated
|
||||
// to bind them to the consumer worker.
|
||||
userKEK: string
|
||||
vapidKeys: JWK
|
||||
}
|
||||
|
||||
// ActivityPub message delivery job are sent to the queue and the consumer does
|
||||
// the actual delivery.
|
||||
export interface DeliverMessageBody extends MessageBody {
|
||||
activity: Activity
|
||||
toActorId: string
|
||||
|
||||
// Send secrets as part of the message because it's too complicated
|
||||
// to bind them to the consumer worker.
|
||||
userKEK: string
|
||||
}
|
||||
|
||||
export type MessageSendRequest<Body = MessageBody> = {
|
||||
body: Body
|
||||
}
|
||||
|
||||
export interface Queue<Body = MessageBody> {
|
||||
send(body: Body): Promise<void>
|
||||
sendBatch(messages: Iterable<MessageSendRequest<Body>>): Promise<void>
|
||||
}
|
|
@ -2,13 +2,14 @@ import type { MastodonAccount } from './account'
|
|||
import type { MediaAttachment } from './media'
|
||||
import type { UUID } from 'wildebeest/backend/src/types'
|
||||
|
||||
type Visibility = 'public' | 'unlisted' | 'private' | 'direct'
|
||||
export type Visibility = 'public' | 'unlisted' | 'private' | 'direct'
|
||||
|
||||
// https://docs.joinmastodon.org/entities/Status/
|
||||
// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java
|
||||
export type MastodonStatus = {
|
||||
id: UUID
|
||||
uri: URL
|
||||
url: URL
|
||||
created_at: string
|
||||
account: MastodonAccount
|
||||
content: string
|
||||
|
@ -25,6 +26,8 @@ export type MastodonStatus = {
|
|||
replies_count?: number
|
||||
reblogged?: boolean
|
||||
favourited?: boolean
|
||||
in_reply_to_id?: string
|
||||
in_reply_to_account_id?: string
|
||||
}
|
||||
|
||||
// https://docs.joinmastodon.org/entities/Context/
|
||||
|
|
|
@ -2,19 +2,235 @@
|
|||
// can be url encoded, form data or JSON. However, not working for formData
|
||||
// containing binary data (like File).
|
||||
export async function readBody<T>(request: Request): Promise<T> {
|
||||
let form = null
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType === null) {
|
||||
throw new Error('invalid request')
|
||||
}
|
||||
if (contentType.startsWith('application/json')) {
|
||||
return request.json<T>()
|
||||
} else if (
|
||||
contentType.includes('charset') &&
|
||||
contentType.includes('multipart/form-data') &&
|
||||
contentType.includes('boundary')
|
||||
) {
|
||||
form = await localFormDataParse(request)
|
||||
} else {
|
||||
const form = await request.formData()
|
||||
const out: any = {}
|
||||
form = await request.formData()
|
||||
}
|
||||
|
||||
for (const [key, value] of form) {
|
||||
const out: any = {}
|
||||
|
||||
for (const [key, value] of form) {
|
||||
if (key.endsWith('[]')) {
|
||||
// The `key[]` notiation is used when sending an array of values.
|
||||
|
||||
const key2 = key.replace('[]', '')
|
||||
const outArr: unknown[] = (out[key2] ??= [])
|
||||
outArr.push(value)
|
||||
} else {
|
||||
out[key] = value
|
||||
}
|
||||
return out as T
|
||||
}
|
||||
return out as T
|
||||
}
|
||||
|
||||
export async function localFormDataParse(request: Request): Promise<FormData> {
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType === null) {
|
||||
throw new Error('invalid request')
|
||||
}
|
||||
|
||||
console.log('will attempt local parse of form data')
|
||||
const rBody = await request.text()
|
||||
const enc = new TextEncoder()
|
||||
const bodyArr = enc.encode(rBody)
|
||||
const boundary = getBoundary(contentType)
|
||||
console.log(`Got boundary ${boundary}`)
|
||||
const parts = parse(bodyArr, boundary)
|
||||
console.log(`parsed ${parts.length} parts`)
|
||||
const dec = new TextDecoder()
|
||||
const form: FormData = new FormData()
|
||||
for (const part of parts) {
|
||||
const value = dec.decode(part.data)
|
||||
form.append(part.name || 'null', value)
|
||||
}
|
||||
|
||||
return form
|
||||
}
|
||||
|
||||
// temporary code to deal with EW bug
|
||||
/**
|
||||
* Multipart Parser (Finite State Machine)
|
||||
* usage:
|
||||
* const multipart = require('./multipart.js');
|
||||
* const body = multipart.DemoData(); // raw body
|
||||
* const body = Buffer.from(event['body-json'].toString(),'base64'); // AWS case
|
||||
* const boundary = multipart.getBoundary(event.params.header['content-type']);
|
||||
* const parts = multipart.Parse(body,boundary);
|
||||
* each part is:
|
||||
* { filename: 'A.txt', type: 'text/plain', data: <Buffer 41 41 41 41 42 42 42 42> }
|
||||
* or { name: 'key', data: <Buffer 41 41 41 41 42 42 42 42> }
|
||||
*/
|
||||
|
||||
type Part = {
|
||||
contentDispositionHeader: string
|
||||
contentTypeHeader: string
|
||||
part: number[]
|
||||
}
|
||||
|
||||
type Input = {
|
||||
filename?: string
|
||||
name?: string
|
||||
type: string
|
||||
data: Uint8Array
|
||||
}
|
||||
|
||||
enum ParsingState {
|
||||
INIT,
|
||||
READING_HEADERS,
|
||||
READING_DATA,
|
||||
READING_PART_SEPARATOR,
|
||||
}
|
||||
|
||||
export function parse(multipartBodyBuffer: Uint8Array, boundary: string): Input[] {
|
||||
let lastline = ''
|
||||
let contentDispositionHeader = ''
|
||||
let contentTypeHeader = ''
|
||||
let state: ParsingState = ParsingState.INIT
|
||||
let buffer: number[] = []
|
||||
const allParts: Input[] = []
|
||||
|
||||
let currentPartHeaders: string[] = []
|
||||
|
||||
for (let i = 0; i < multipartBodyBuffer.length; i++) {
|
||||
const oneByte: number = multipartBodyBuffer[i]
|
||||
const prevByte: number | null = i > 0 ? multipartBodyBuffer[i - 1] : null
|
||||
// 0x0a => \n
|
||||
// 0x0d => \r
|
||||
const newLineDetected: boolean = oneByte === 0x0a && prevByte === 0x0d
|
||||
const newLineChar: boolean = oneByte === 0x0a || oneByte === 0x0d
|
||||
|
||||
if (!newLineChar) lastline += String.fromCharCode(oneByte)
|
||||
if (ParsingState.INIT === state && newLineDetected) {
|
||||
// searching for boundary
|
||||
if ('--' + boundary === lastline) {
|
||||
state = ParsingState.READING_HEADERS // found boundary. start reading headers
|
||||
}
|
||||
lastline = ''
|
||||
} else if (ParsingState.READING_HEADERS === state && newLineDetected) {
|
||||
// parsing headers. Headers are separated by an empty line from the content. Stop reading headers when the line is empty
|
||||
if (lastline.length) {
|
||||
currentPartHeaders.push(lastline)
|
||||
} else {
|
||||
// found empty line. search for the headers we want and set the values
|
||||
for (const h of currentPartHeaders) {
|
||||
if (h.toLowerCase().startsWith('content-disposition:')) {
|
||||
contentDispositionHeader = h
|
||||
} else if (h.toLowerCase().startsWith('content-type:')) {
|
||||
contentTypeHeader = h
|
||||
}
|
||||
}
|
||||
state = ParsingState.READING_DATA
|
||||
buffer = []
|
||||
}
|
||||
lastline = ''
|
||||
} else if (ParsingState.READING_DATA === state) {
|
||||
// parsing data
|
||||
if (lastline.length > boundary.length + 4) {
|
||||
lastline = '' // mem save
|
||||
}
|
||||
if ('--' + boundary === lastline) {
|
||||
const j = buffer.length - lastline.length
|
||||
const part = buffer.slice(0, j - 1)
|
||||
|
||||
allParts.push(process({ contentDispositionHeader, contentTypeHeader, part }))
|
||||
buffer = []
|
||||
currentPartHeaders = []
|
||||
lastline = ''
|
||||
state = ParsingState.READING_PART_SEPARATOR
|
||||
contentDispositionHeader = ''
|
||||
contentTypeHeader = ''
|
||||
} else {
|
||||
buffer.push(oneByte)
|
||||
}
|
||||
if (newLineDetected) {
|
||||
lastline = ''
|
||||
}
|
||||
} else if (ParsingState.READING_PART_SEPARATOR === state) {
|
||||
if (newLineDetected) {
|
||||
state = ParsingState.READING_HEADERS
|
||||
}
|
||||
}
|
||||
}
|
||||
return allParts
|
||||
}
|
||||
|
||||
// read the boundary from the content-type header sent by the http client
|
||||
// this value may be similar to:
|
||||
// 'multipart/form-data; boundary=----WebKitFormBoundaryvm5A9tzU1ONaGP5B',
|
||||
export function getBoundary(header: string): string {
|
||||
const items = header.split(';')
|
||||
if (items) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = new String(items[i]).trim()
|
||||
if (item.indexOf('boundary') >= 0) {
|
||||
const k = item.split('=')
|
||||
return new String(k[1]).trim().replace(/^["']|["']$/g, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function process(part: Part): Input {
|
||||
// will transform this object:
|
||||
// { header: 'Content-Disposition: form-data; name="uploads[]"; filename="A.txt"',
|
||||
// info: 'Content-Type: text/plain',
|
||||
// part: 'AAAABBBB' }
|
||||
// into this one:
|
||||
// { filename: 'A.txt', type: 'text/plain', data: <Buffer 41 41 41 41 42 42 42 42> }
|
||||
const obj = function (str: string) {
|
||||
const k = str.split('=')
|
||||
const a = k[0].trim()
|
||||
|
||||
const b = JSON.parse(k[1].trim())
|
||||
const o = {}
|
||||
Object.defineProperty(o, a, {
|
||||
value: b,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
})
|
||||
return o
|
||||
}
|
||||
const header = part.contentDispositionHeader.split(';')
|
||||
|
||||
const filenameData = header[2]
|
||||
let input = {}
|
||||
if (filenameData) {
|
||||
input = obj(filenameData)
|
||||
const contentType = part.contentTypeHeader.split(':')[1].trim()
|
||||
Object.defineProperty(input, 'type', {
|
||||
value: contentType,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
// always process the name field
|
||||
Object.defineProperty(input, 'name', {
|
||||
value: header[1].split('=')[1].replace(/"/g, ''),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
Object.defineProperty(input, 'data', {
|
||||
value: Uint8Array.from(part.part),
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
})
|
||||
return input as Input
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
export function cors(): object {
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'content-type, authorization, idempotency-key',
|
||||
'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE',
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
// @ts-nocheck
|
||||
// Copyright 2012 Joyent, Inc. All rights reserved.
|
||||
|
||||
import { HEADER, HttpSignatureError, InvalidAlgorithmError, validateAlgorithm } from './utils'
|
||||
|
@ -61,6 +60,9 @@ export type ParsedSignature = {
|
|||
keyId: string
|
||||
signingString: string
|
||||
algorithm: string
|
||||
scheme: string
|
||||
params: Record<string, string | string[] | number>
|
||||
opaque: string
|
||||
}
|
||||
|
||||
///--- Exported API
|
||||
|
@ -141,10 +143,14 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
let tmpName = ''
|
||||
let tmpValue = ''
|
||||
|
||||
const parsed = {
|
||||
const parsed: ParsedSignature = {
|
||||
scheme: authz === request.headers.get(HEADER.SIG) ? 'Signature' : '',
|
||||
params: {},
|
||||
signingString: '',
|
||||
signature: '',
|
||||
keyId: '',
|
||||
algorithm: '',
|
||||
opaque: '',
|
||||
}
|
||||
|
||||
for (i = 0; i < authz.length; i++) {
|
||||
|
@ -234,14 +240,16 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
}
|
||||
}
|
||||
|
||||
let parsedHeaders: string[] = []
|
||||
|
||||
if (!parsed.params.headers || parsed.params.headers === '') {
|
||||
if (request.headers.has('x-date')) {
|
||||
parsed.params.headers = ['x-date']
|
||||
parsedHeaders = ['x-date']
|
||||
} else {
|
||||
parsed.params.headers = ['date']
|
||||
parsedHeaders = ['date']
|
||||
}
|
||||
} else {
|
||||
parsed.params.headers = parsed.params.headers.split(' ')
|
||||
} else if (typeof parsed.params.headers === 'string') {
|
||||
parsedHeaders = parsed.params.headers.split(' ')
|
||||
}
|
||||
|
||||
// Minimally validate the parsed object
|
||||
|
@ -253,13 +261,13 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
|
||||
if (!parsed.params.signature) throw new InvalidHeaderError('signature was not specified')
|
||||
|
||||
if (['date', 'x-date', '(created)'].every((hdr) => parsed.params.headers.indexOf(hdr) < 0)) {
|
||||
if (['date', 'x-date', '(created)'].every((hdr) => parsedHeaders.indexOf(hdr) < 0)) {
|
||||
throw new MissingHeaderError('no signed date header')
|
||||
}
|
||||
|
||||
// Check the algorithm against the official list
|
||||
try {
|
||||
validateAlgorithm(parsed.params.algorithm, 'rsa')
|
||||
validateAlgorithm(parsed.params.algorithm as string, 'rsa')
|
||||
} catch (e) {
|
||||
if (e instanceof InvalidAlgorithmError)
|
||||
throw new InvalidParamsError(parsed.params.algorithm + ' is not ' + 'supported')
|
||||
|
@ -267,9 +275,9 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
}
|
||||
|
||||
// Build the signingString
|
||||
for (i = 0; i < parsed.params.headers.length; i++) {
|
||||
const h = parsed.params.headers[i].toLowerCase()
|
||||
parsed.params.headers[i] = h
|
||||
for (i = 0; i < parsedHeaders.length; i++) {
|
||||
const h = parsedHeaders[i].toLowerCase()
|
||||
parsedHeaders[i] = h
|
||||
|
||||
if (h === 'request-line') {
|
||||
if (!options.strict) {
|
||||
|
@ -292,6 +300,7 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
} else if (h === '(opaque)') {
|
||||
const opaque = parsed.params.opaque
|
||||
if (opaque === undefined) {
|
||||
//@ts-expect-error -- authzHeaderName doesn't exist TOFIX
|
||||
throw new MissingHeaderError('opaque param was not in the ' + authzHeaderName + ' header')
|
||||
}
|
||||
parsed.signingString += '(opaque): ' + opaque
|
||||
|
@ -305,17 +314,17 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
parsed.signingString += h + ': ' + value
|
||||
}
|
||||
|
||||
if (i + 1 < parsed.params.headers.length) parsed.signingString += '\n'
|
||||
if (i + 1 < parsedHeaders.length) parsed.signingString += '\n'
|
||||
}
|
||||
|
||||
// Check against the constraints
|
||||
let date
|
||||
let skew
|
||||
if (request.headers.date || request.headers.has('x-date')) {
|
||||
if (request.headers.get('date') || request.headers.has('x-date')) {
|
||||
if (request.headers.has('x-date')) {
|
||||
date = new Date(request.headers.get('x-date') as string)
|
||||
} else {
|
||||
date = new Date(request.headers.date)
|
||||
date = new Date(request.headers.get('date') as string)
|
||||
}
|
||||
const now = new Date()
|
||||
skew = Math.abs(now.getTime() - date.getTime())
|
||||
|
@ -325,7 +334,7 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
}
|
||||
}
|
||||
|
||||
if (parsed.params.created) {
|
||||
if (parsed.params.created && typeof parsed.params.created === 'number') {
|
||||
skew = parsed.params.created - Math.floor(Date.now() / 1000)
|
||||
if (skew > options.clockSkew) {
|
||||
throw new ExpiredRequestError(
|
||||
|
@ -340,7 +349,7 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
}
|
||||
}
|
||||
|
||||
if (parsed.params.expires) {
|
||||
if (parsed.params.expires && typeof parsed.params.expires === 'number') {
|
||||
const expiredSince = Math.floor(Date.now() / 1000) - parsed.params.expires
|
||||
if (expiredSince > options.clockSkew) {
|
||||
throw new ExpiredRequestError(
|
||||
|
@ -352,14 +361,17 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu
|
|||
headers.forEach(function (hdr) {
|
||||
// Remember that we already checked any headers in the params
|
||||
// were in the request, so if this passes we're good.
|
||||
if (parsed.params.headers.indexOf(hdr.toLowerCase()) < 0)
|
||||
if (parsedHeaders.indexOf(hdr.toLowerCase()) < 0) {
|
||||
throw new MissingHeaderError(hdr + ' was not a signed header')
|
||||
}
|
||||
})
|
||||
|
||||
parsed.params.algorithm = parsed.params.algorithm.toLowerCase()
|
||||
parsed.algorithm = parsed.params.algorithm.toUpperCase()
|
||||
parsed.keyId = parsed.params.keyId
|
||||
parsed.opaque = parsed.params.opaque
|
||||
parsed.signature = parsed.params.signature
|
||||
const algorithm = parsed.params.algorithm as string
|
||||
parsed.params.algorithm = algorithm.toLowerCase()
|
||||
parsed.algorithm = algorithm.toUpperCase()
|
||||
parsed.keyId = parsed.params.keyId as string
|
||||
parsed.opaque = parsed.params.opaque as string
|
||||
parsed.signature = parsed.params.signature as string
|
||||
parsed.params.headers = parsedHeaders
|
||||
return parsed
|
||||
}
|
||||
|
|
|
@ -18,12 +18,16 @@ export async function verifySignature(parsedSignature: ParsedSignature, key: Cry
|
|||
)
|
||||
}
|
||||
|
||||
export async function fetchKey(parsedSignature: ParsedSignature): Promise<CryptoKey> {
|
||||
const response = await fetch(parsedSignature.keyId, {
|
||||
method: 'GET',
|
||||
export async function fetchKey(parsedSignature: ParsedSignature): Promise<CryptoKey | null> {
|
||||
const url = parsedSignature.keyId
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/activity+json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.warn(`failed to fetch keys from "${url}", returned ${res.status}.`)
|
||||
return null
|
||||
}
|
||||
|
||||
const parsedResponse = (await response.json()) as Profile
|
||||
const parsedResponse = (await res.json()) as Profile
|
||||
return importPublicKey(parsedResponse.publicKey.publicKeyPem)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ export type Handle = {
|
|||
domain: string | null
|
||||
}
|
||||
|
||||
// Parse a "handle" in the form: `[@] <local-part> '@' <domain>`
|
||||
export function parseHandle(query: string): Handle {
|
||||
// Remove the leading @, if there's one.
|
||||
if (query.startsWith('@')) {
|
||||
|
@ -13,9 +14,15 @@ export function parseHandle(query: string): Handle {
|
|||
query = decodeURIComponent(query)
|
||||
|
||||
const parts = query.split('@')
|
||||
const localPart = parts[0]
|
||||
|
||||
if (!/^[\w-.]+$/.test(localPart)) {
|
||||
throw new Error('invalid handle: localPart: ' + localPart)
|
||||
}
|
||||
|
||||
if (parts.length > 1) {
|
||||
return { localPart: parts[0], domain: parts[1] }
|
||||
return { localPart, domain: parts[1] }
|
||||
} else {
|
||||
return { localPart: parts[0], domain: null }
|
||||
return { localPart, domain: null }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
import { makeDB, isUrlValid } from './utils'
|
||||
import { MessageType } from 'wildebeest/backend/src/types/queue'
|
||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
||||
import { addFollowing } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { createPrivateNote, createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import { cacheObject } from 'wildebeest/backend/src/activitypub/objects/'
|
||||
|
||||
import * as ap_objects from 'wildebeest/functions/ap/o/[id]'
|
||||
import * as ap_users from 'wildebeest/functions/ap/users/[id]'
|
||||
import * as ap_outbox from 'wildebeest/functions/ap/users/[id]/outbox'
|
||||
import * as ap_inbox from 'wildebeest/functions/ap/users/[id]/inbox'
|
||||
import * as ap_outbox_page from 'wildebeest/functions/ap/users/[id]/outbox/page'
|
||||
import { createStatus } from '../src/mastodon/status'
|
||||
import { mastodonIdSymbol } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
const userKEK = 'test_kek5'
|
||||
const vapidKeys = {} as JWK
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
const vapidKeys = {} as JWK
|
||||
const domain = 'cloudflare.com'
|
||||
const adminEmail = 'admin@example.com'
|
||||
|
||||
describe('ActivityPub', () => {
|
||||
test('fetch non-existant user by id', async () => {
|
||||
|
@ -52,175 +54,13 @@ describe('ActivityPub', () => {
|
|||
assert.equal(data.publicKey.publicKeyPem, pubKey)
|
||||
})
|
||||
|
||||
describe('Accept', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
})
|
||||
|
||||
test('Accept follow request stores in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
await addFollowing(db, actor, actor2, 'not needed')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Accept',
|
||||
actor: { id: 'https://' + domain + '/ap/users/sven2' },
|
||||
object: {
|
||||
type: 'Follow',
|
||||
actor: actor.id,
|
||||
object: 'https://' + domain + '/ap/users/sven2',
|
||||
},
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const row = await db
|
||||
.prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`)
|
||||
.bind(actor.id.toString())
|
||||
.first()
|
||||
assert(row)
|
||||
assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2')
|
||||
assert.equal(row.state, 'accepted')
|
||||
})
|
||||
|
||||
test('Object must be an object', async () => {
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Accept',
|
||||
actor: 'https://example.com/actor',
|
||||
object: 'a',
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: '`activity.object` must be of type object',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create', () => {
|
||||
test('Object must be an object', async () => {
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Create',
|
||||
actor: 'https://example.com/actor',
|
||||
object: 'a',
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: '`activity.object` must be of type object',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update', () => {
|
||||
test('Object must be an object', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: 'https://example.com/actor',
|
||||
object: 'a',
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: '`activity.object` must be of type object',
|
||||
})
|
||||
})
|
||||
|
||||
test('Object must exist', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: 'https://example.com/actor',
|
||||
object: {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: 'object https://example.com/note2 does not exist',
|
||||
})
|
||||
})
|
||||
|
||||
test('Object must have the same origin', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const object = {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
}
|
||||
|
||||
const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false)
|
||||
assert.notEqual(obj, null, 'could not create object')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: 'https://example.com/actor',
|
||||
object: object,
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: 'actorid mismatch when updating object',
|
||||
})
|
||||
})
|
||||
|
||||
test('Object is updated', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const object = {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
}
|
||||
|
||||
const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false)
|
||||
assert.notEqual(obj, null, 'could not create object')
|
||||
|
||||
const newObject = {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'new test note',
|
||||
}
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: actor.id,
|
||||
object: newObject,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const updatedObject = await db.prepare('SELECT * FROM objects WHERE original_object_id=?').bind(object.id).first()
|
||||
assert(updatedObject)
|
||||
assert.equal(JSON.parse(updatedObject.properties).content, newObject.content)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Outbox', () => {
|
||||
test('return outbox', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my first status', actor))
|
||||
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor))
|
||||
await createStatus(domain, db, actor, 'my first status')
|
||||
await createStatus(domain, db, actor, 'my second status')
|
||||
|
||||
const res = await ap_outbox.handleRequest(domain, db, 'sven', userKEK)
|
||||
assert.equal(res.status, 200)
|
||||
|
@ -234,11 +74,11 @@ describe('ActivityPub', () => {
|
|||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my first status', actor))
|
||||
await createStatus(domain, db, actor, 'my first status')
|
||||
await sleep(10)
|
||||
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor))
|
||||
await createStatus(domain, db, actor, 'my second status')
|
||||
|
||||
const res = await ap_outbox_page.handleRequest(domain, db, 'sven', userKEK)
|
||||
const res = await ap_outbox_page.handleRequest(domain, db, 'sven')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
@ -247,59 +87,127 @@ describe('ActivityPub', () => {
|
|||
assert.equal(data.orderedItems[0].object.content, 'my second status')
|
||||
assert.equal(data.orderedItems[1].object.content, 'my first status')
|
||||
})
|
||||
|
||||
test("doesn't show private notes to anyone", async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPrivateNote(domain, db, 'DM', actorA, actorB)
|
||||
await addObjectInOutbox(db, actorA, note, undefined, actorB.id.toString())
|
||||
|
||||
{
|
||||
const res = await ap_outbox_page.handleRequest(domain, db, 'a')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.orderedItems.length, 0)
|
||||
}
|
||||
|
||||
{
|
||||
const res = await ap_outbox_page.handleRequest(domain, db, 'b')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.orderedItems.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test("doesn't show private note in target outbox", async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'target@cloudflare.com')
|
||||
|
||||
const note = await createPrivateNote(domain, db, 'DM', actorA, actorB)
|
||||
await addObjectInOutbox(db, actorA, note)
|
||||
|
||||
const res = await ap_outbox_page.handleRequest(domain, db, 'target')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.orderedItems.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Announce', () => {
|
||||
test('Announce objects are stored and added to the remote actors outbox', async () => {
|
||||
const remoteActorId = 'https://example.com/actor'
|
||||
const objectId = 'https://example.com/some-object'
|
||||
describe('Actors', () => {
|
||||
test('getAndCache adds peer', async () => {
|
||||
const actorId = new URL('https://example.com/user/foo')
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === remoteActorId) {
|
||||
if (input.toString() === actorId.toString()) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: remoteActorId,
|
||||
icon: { url: 'img.com' },
|
||||
id: actorId,
|
||||
type: 'Person',
|
||||
preferredUsername: 'sven',
|
||||
name: 'sven ssss',
|
||||
|
||||
icon: { url: 'icon.jpg' },
|
||||
image: { url: 'image.jpg' },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === objectId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: objectId,
|
||||
type: 'Note',
|
||||
content: 'foo',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
throw new Error(`unexpected request to "${input}"`)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: remoteActorId,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: objectId,
|
||||
await actors.getAndCache(actorId, db)
|
||||
|
||||
const { results } = (await db.prepare('SELECT domain from peers').all()) as any
|
||||
assert.equal(results.length, 1)
|
||||
assert.equal(results[0].domain, 'example.com')
|
||||
})
|
||||
|
||||
test('getAndCache supports any Actor types', async () => {
|
||||
// While Actor ObjectID MUST be globally unique, the Object can
|
||||
// change type and Mastodon uses this behavior as a feature.
|
||||
// We need to make sure our caching works with Actor that change
|
||||
// types.
|
||||
|
||||
const actorId = new URL('https://example.com/user/foo')
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === actorId.toString()) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: actorId,
|
||||
type: 'Service',
|
||||
preferredUsername: 'sven',
|
||||
name: 'sven ssss',
|
||||
|
||||
icon: { url: 'icon.jpg' },
|
||||
image: { url: 'image.jpg' },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === actorId.toString()) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: actorId,
|
||||
type: 'Person',
|
||||
preferredUsername: 'sven',
|
||||
name: 'sven ssss',
|
||||
|
||||
icon: { url: 'icon.jpg' },
|
||||
image: { url: 'image.jpg' },
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`unexpected request to "${input}"`)
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const object = await db.prepare('SELECT * FROM objects').bind(remoteActorId).first()
|
||||
assert(object)
|
||||
assert.equal(object.type, 'Note')
|
||||
assert.equal(object.original_actor_id, remoteActorId)
|
||||
const db = await makeDB()
|
||||
|
||||
const outbox_object = await db
|
||||
.prepare('SELECT * FROM outbox_objects WHERE actor_id=?')
|
||||
.bind(remoteActorId)
|
||||
.first()
|
||||
assert(outbox_object)
|
||||
assert.equal(outbox_object.actor_id, remoteActorId)
|
||||
await actors.getAndCache(actorId, db)
|
||||
|
||||
const { results } = (await db.prepare('SELECT * FROM actors').all()) as any
|
||||
assert.equal(results.length, 1)
|
||||
assert.equal(results[0].id, actorId.toString())
|
||||
assert.equal(results[0].type, 'Service')
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -333,5 +241,80 @@ describe('ActivityPub', () => {
|
|||
result = await db.prepare('SELECT count(*) as count from objects').first()
|
||||
assert.equal(result.count, 1)
|
||||
})
|
||||
|
||||
test('cacheObject adds peer', async () => {
|
||||
const db = await makeDB()
|
||||
const properties = { type: 'Note', a: 1, b: 2 }
|
||||
const actor = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const originalObjectId = new URL('https://example.com/object1')
|
||||
|
||||
await cacheObject(domain, db, properties, actor.id, originalObjectId, false)
|
||||
|
||||
const { results } = (await db.prepare('SELECT domain from peers').all()) as any
|
||||
assert.equal(results.length, 1)
|
||||
assert.equal(results[0].domain, 'example.com')
|
||||
})
|
||||
|
||||
test('serve unknown object', async () => {
|
||||
const db = await makeDB()
|
||||
const res = await ap_objects.handleRequest(domain, db, 'unknown id')
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('serve object', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'content', actor)
|
||||
|
||||
const res = await ap_objects.handleRequest(domain, db, note[mastodonIdSymbol]!)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.content, 'content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Inbox', () => {
|
||||
test('send Note to non existant user', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const queue = {
|
||||
async send() {},
|
||||
async sendBatch() {
|
||||
throw new Error('unimplemented')
|
||||
},
|
||||
}
|
||||
|
||||
const activity: any = {}
|
||||
const res = await ap_inbox.handleRequest(domain, db, 'sven', activity, queue, userKEK, vapidKeys)
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('send activity sends message in queue', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
let msg: any = null
|
||||
|
||||
const queue = {
|
||||
async send(v: any) {
|
||||
msg = v
|
||||
},
|
||||
async sendBatch() {
|
||||
throw new Error('unimplemented')
|
||||
},
|
||||
}
|
||||
|
||||
const activity: any = {
|
||||
type: 'some activity',
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(domain, db, 'sven', activity, queue, userKEK, vapidKeys)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert(msg)
|
||||
assert.equal(msg.type, MessageType.Inbox)
|
||||
assert.equal(msg.actorId, actor.id.toString())
|
||||
assert.equal(msg.activity.type, 'some activity')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -21,16 +21,16 @@ describe('ActivityPub', () => {
|
|||
beforeEach(() => {
|
||||
receivedActivity = null
|
||||
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (input.url === `https://${domain}/ap/users/sven2/inbox`) {
|
||||
assert.equal(input.method, 'POST')
|
||||
const data = await input.json()
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
const request = new Request(input)
|
||||
if (request.url === `https://${domain}/ap/users/sven2/inbox`) {
|
||||
assert.equal(request.method, 'POST')
|
||||
const data = await request.json()
|
||||
receivedActivity = data
|
||||
console.log({ receivedActivity })
|
||||
return new Response('')
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input.url)
|
||||
throw new Error('unexpected request to ' + request.url)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -51,14 +51,17 @@ describe('ActivityPub', () => {
|
|||
const row = await db
|
||||
.prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`)
|
||||
.bind(actor2.id.toString())
|
||||
.first()
|
||||
.first<{
|
||||
target_actor_id: object
|
||||
state: string
|
||||
}>()
|
||||
assert(row)
|
||||
assert.equal(row.target_actor_id.toString(), actor.id.toString())
|
||||
assert.equal(row.state, 'accepted')
|
||||
|
||||
assert(receivedActivity)
|
||||
assert.equal(receivedActivity.type, 'Accept')
|
||||
assert.equal(receivedActivity.actor.toString(), actor.id.toString())
|
||||
assert.equal((receivedActivity.actor as object).toString(), actor.id.toString())
|
||||
assert.equal(receivedActivity.object.actor, activity.actor)
|
||||
assert.equal(receivedActivity.object.type, activity.type)
|
||||
})
|
||||
|
@ -144,10 +147,35 @@ describe('ActivityPub', () => {
|
|||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first<{
|
||||
type: string
|
||||
actor_id: object
|
||||
from_actor_id: object
|
||||
}>()
|
||||
assert.equal(entry.type, 'follow')
|
||||
assert.equal(entry.actor_id.toString(), actor.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actor2.id.toString())
|
||||
})
|
||||
|
||||
test('ignore when trying to follow multiple times', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Follow',
|
||||
actor: actor2.id,
|
||||
object: actor.id,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
// Even if we followed multiple times, only one row should be present.
|
||||
const { count } = await db.prepare(`SELECT count(*) as count FROM actor_following`).first<{ count: number }>()
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,747 @@
|
|||
import { makeDB } from '../utils'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import { cacheObject, getObjectById } from 'wildebeest/backend/src/activitypub/objects/'
|
||||
import { addFollowing } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { ObjectsRow } from 'wildebeest/backend/src/types/objects'
|
||||
import { originalObjectIdSymbol } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
const adminEmail = 'admin@example.com'
|
||||
const domain = 'cloudflare.com'
|
||||
const userKEK = 'test_kek15'
|
||||
const vapidKeys = {} as JWK
|
||||
|
||||
describe('ActivityPub', () => {
|
||||
describe('handle Activity', () => {
|
||||
describe('Announce', () => {
|
||||
test('records reblog in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_reblogs').first<{
|
||||
actor_id: URL
|
||||
object_id: URL
|
||||
}>()
|
||||
assert.equal(entry.actor_id.toString(), actorB.id.toString())
|
||||
assert.equal(entry.object_id.toString(), note.id.toString())
|
||||
})
|
||||
|
||||
test('creates notification', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first<{
|
||||
type: string
|
||||
actor_id: URL
|
||||
from_actor_id: URL
|
||||
}>()
|
||||
assert(entry)
|
||||
assert.equal(entry.type, 'reblog')
|
||||
assert.equal(entry.actor_id.toString(), actorA.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Like', () => {
|
||||
test('records like in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Like',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ actor_id: URL; object_id: URL }>()
|
||||
assert.equal(entry.actor_id.toString(), actorB.id.toString())
|
||||
assert.equal(entry.object_id.toString(), note.id.toString())
|
||||
})
|
||||
|
||||
test('creates notification', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Like',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first<{
|
||||
type: string
|
||||
actor_id: URL
|
||||
from_actor_id: URL
|
||||
}>()
|
||||
assert.equal(entry.type, 'favourite')
|
||||
assert.equal(entry.actor_id.toString(), actorA.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
|
||||
})
|
||||
|
||||
test('records like in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Like',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_favourites').first<{
|
||||
actor_id: URL
|
||||
object_id: URL
|
||||
}>()
|
||||
assert.equal(entry.actor_id.toString(), actorB.id.toString())
|
||||
assert.equal(entry.object_id.toString(), note.id.toString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Accept', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
})
|
||||
|
||||
test('Accept follow request stores in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
await addFollowing(db, actor, actor2, 'not needed')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Accept',
|
||||
actor: { id: 'https://' + domain + '/ap/users/sven2' },
|
||||
object: {
|
||||
type: 'Follow',
|
||||
actor: actor.id,
|
||||
object: 'https://' + domain + '/ap/users/sven2',
|
||||
},
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const row = await db
|
||||
.prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`)
|
||||
.bind(actor.id.toString())
|
||||
.first<{
|
||||
target_actor_id: string
|
||||
state: string
|
||||
}>()
|
||||
assert(row)
|
||||
assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2')
|
||||
assert.equal(row.state, 'accepted')
|
||||
})
|
||||
|
||||
test('Object must be an object', async () => {
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Accept',
|
||||
actor: 'https://example.com/actor',
|
||||
object: 'a',
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: '`activity.object` must be of type object',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Create', () => {
|
||||
test('Object must be an object', async () => {
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Create',
|
||||
actor: 'https://example.com/actor',
|
||||
object: 'a',
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: '`activity.object` must be of type object',
|
||||
})
|
||||
})
|
||||
|
||||
test('Note to inbox stores in DB', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: [actor.id.toString()],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db
|
||||
.prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id')
|
||||
.first<ObjectsRow>()
|
||||
const properties = JSON.parse(entry.properties)
|
||||
assert.equal(properties.content, 'test note')
|
||||
})
|
||||
|
||||
test("Note adds in remote actor's outbox", async () => {
|
||||
const remoteActorId = 'https://example.com/actor'
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === remoteActorId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: remoteActorId,
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: remoteActorId,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db
|
||||
.prepare('SELECT * FROM outbox_objects WHERE actor_id=?')
|
||||
.bind(remoteActorId)
|
||||
.first<{ actor_id: string }>()
|
||||
assert.equal(entry.actor_id, remoteActorId)
|
||||
})
|
||||
|
||||
test('local actor sends Note with mention create notification', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actorB.id.toString(),
|
||||
to: [actorA.id.toString()],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first<{
|
||||
type: string
|
||||
actor_id: URL
|
||||
from_actor_id: URL
|
||||
}>()
|
||||
assert(entry)
|
||||
assert.equal(entry.type, 'mention')
|
||||
assert.equal(entry.actor_id.toString(), actorA.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
|
||||
})
|
||||
|
||||
test('Note records reply', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
{
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: [actor.id.toString()],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'post',
|
||||
},
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
}
|
||||
|
||||
{
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: [actor.id.toString()],
|
||||
object: {
|
||||
inReplyTo: 'https://example.com/note1',
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'reply',
|
||||
},
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
}
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_replies').first<{
|
||||
actor_id: string
|
||||
object_id: string
|
||||
in_reply_to_object_id: string
|
||||
}>()
|
||||
assert.equal(entry.actor_id, actor.id.toString().toString())
|
||||
|
||||
const obj: any = await getObjectById(db, entry.object_id)
|
||||
assert(obj)
|
||||
assert.equal(obj[originalObjectIdSymbol], 'https://example.com/note2')
|
||||
|
||||
const inReplyTo: any = await getObjectById(db, entry.in_reply_to_object_id)
|
||||
assert(inReplyTo)
|
||||
assert.equal(inReplyTo[originalObjectIdSymbol], 'https://example.com/note1')
|
||||
})
|
||||
|
||||
test('preserve Note sent with `to`', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: ['https://example.com/some-actor'],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const row = await db.prepare('SELECT * FROM outbox_objects').first<{ target: string }>()
|
||||
assert.equal(row.target, 'https://example.com/some-actor')
|
||||
})
|
||||
|
||||
test('Object props get sanitized', async () => {
|
||||
const db = await makeDB()
|
||||
const person = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Create',
|
||||
actor: person,
|
||||
object: {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
name: '<script>Dr Evil</script>',
|
||||
content:
|
||||
'<div><span class="bad h-10 p-100\tu-22\r\ndt-xi e-bam mention hashtag ellipsis invisible o-bad">foo</span><br/><p><a href="blah"><b>bold</b></a></p><script>alert("evil")</script></div>',
|
||||
},
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const row = await db.prepare(`SELECT * from objects`).first<ObjectsRow>()
|
||||
const { content, name } = JSON.parse(row.properties)
|
||||
assert.equal(
|
||||
content,
|
||||
'<p><span class="h-10 p-100 u-22 dt-xi e-bam mention hashtag ellipsis invisible">foo</span><br/><p><a href="blah"><p>bold</p></a></p><p>alert("evil")</p></p>'
|
||||
)
|
||||
assert.equal(name, 'Dr Evil')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update', () => {
|
||||
test('Object must be an object', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: 'https://example.com/actor',
|
||||
object: 'a',
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: '`activity.object` must be of type object',
|
||||
})
|
||||
})
|
||||
|
||||
test('Object must exist', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: 'https://example.com/actor',
|
||||
object: {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: 'object https://example.com/note2 does not exist',
|
||||
})
|
||||
})
|
||||
|
||||
test('Object must have the same origin', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const object = {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
}
|
||||
|
||||
const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false)
|
||||
assert.notEqual(obj, null, 'could not create object')
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: 'https://example.com/actor',
|
||||
object: object,
|
||||
}
|
||||
|
||||
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), {
|
||||
message: 'actorid mismatch when updating object',
|
||||
})
|
||||
})
|
||||
|
||||
test('Object is updated', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const object = {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
}
|
||||
|
||||
const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false)
|
||||
assert.notEqual(obj, null, 'could not create object')
|
||||
|
||||
const newObject = {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'new test note',
|
||||
}
|
||||
|
||||
const activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Update',
|
||||
actor: actor.id,
|
||||
object: newObject,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const updatedObject = await db
|
||||
.prepare('SELECT * FROM objects WHERE original_object_id=?')
|
||||
.bind(object.id)
|
||||
.first<ObjectsRow>()
|
||||
assert(updatedObject)
|
||||
assert.equal(JSON.parse(updatedObject.properties).content, newObject.content)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Announce', () => {
|
||||
test('Announce objects are stored and added to the remote actors outbox', async () => {
|
||||
const remoteActorId = 'https://example.com/actor'
|
||||
const objectId = 'https://example.com/some-object'
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === remoteActorId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: remoteActorId,
|
||||
icon: { url: 'img.com' },
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === objectId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: objectId,
|
||||
type: 'Note',
|
||||
content: 'foo',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: remoteActorId,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: objectId,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const object = await db.prepare('SELECT * FROM objects').first<{
|
||||
type: string
|
||||
original_actor_id: string
|
||||
}>()
|
||||
assert(object)
|
||||
assert.equal(object.type, 'Note')
|
||||
assert.equal(object.original_actor_id, remoteActorId)
|
||||
|
||||
const outbox_object = await db
|
||||
.prepare('SELECT * FROM outbox_objects WHERE actor_id=?')
|
||||
.bind(remoteActorId)
|
||||
.first<{ actor_id: string }>()
|
||||
assert(outbox_object)
|
||||
assert.equal(outbox_object.actor_id, remoteActorId)
|
||||
})
|
||||
|
||||
test('duplicated announce', async () => {
|
||||
const remoteActorId = 'https://example.com/actor'
|
||||
const objectId = 'https://example.com/some-object'
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === remoteActorId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: remoteActorId,
|
||||
icon: { url: 'img.com' },
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === objectId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: objectId,
|
||||
type: 'Note',
|
||||
content: 'foo',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: remoteActorId,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: objectId,
|
||||
}
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
// Handle the same Activity
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
// Ensure only one reblog is kept
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM outbox_objects').first<{ count: number }>()
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Delete', () => {
|
||||
test('delete Note', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const originalObjectId = 'https://example.com/note123'
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)'
|
||||
)
|
||||
.bind(
|
||||
'https://example.com/object1',
|
||||
'Note',
|
||||
JSON.stringify({ content: 'my first status' }),
|
||||
actorA.id.toString(),
|
||||
originalObjectId,
|
||||
'mastodonid1'
|
||||
)
|
||||
.run()
|
||||
|
||||
const activity: any = {
|
||||
type: 'Delete',
|
||||
actor: actorA.id,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: originalObjectId,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>()
|
||||
assert.equal(count, 0)
|
||||
})
|
||||
|
||||
test('delete Tombstone', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const originalObjectId = 'https://example.com/note456'
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)'
|
||||
)
|
||||
.bind(
|
||||
'https://example.com/object1',
|
||||
'Note',
|
||||
JSON.stringify({ content: 'my first status' }),
|
||||
actorA.id.toString(),
|
||||
originalObjectId,
|
||||
'mastodonid1'
|
||||
)
|
||||
.run()
|
||||
|
||||
const activity: any = {
|
||||
type: 'Delete',
|
||||
actor: actorA.id,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: {
|
||||
type: 'Tombstone',
|
||||
id: originalObjectId,
|
||||
},
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>()
|
||||
assert.equal(count, 0)
|
||||
})
|
||||
|
||||
test('reject Note deletion from another Actor', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const originalObjectId = 'https://example.com/note123'
|
||||
|
||||
// ActorB creates a Note
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)'
|
||||
)
|
||||
.bind(
|
||||
'https://example.com/object1',
|
||||
'Note',
|
||||
JSON.stringify({ content: 'my first status' }),
|
||||
actorB.id.toString(),
|
||||
originalObjectId,
|
||||
'mastodonid1'
|
||||
)
|
||||
.run()
|
||||
|
||||
const activity: any = {
|
||||
type: 'Delete',
|
||||
actor: actorA.id, // ActorA attempts to delete
|
||||
to: [],
|
||||
cc: [],
|
||||
object: actorA.id,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
// Ensure that we didn't actually delete the object
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>()
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
|
||||
test('ignore deletion of an Actor', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Delete',
|
||||
actor: actorA.id,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: actorA.id,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
// Ensure that we didn't actually delete the actor
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM actors').first<{ count: number }>()
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
|
||||
test('ignore deletion of a local Note', async () => {
|
||||
// Deletion of local Note should only be done using Mastodon API
|
||||
// (ie ActivityPub client-to-server).
|
||||
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Delete',
|
||||
actor: actorA.id,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: note.id,
|
||||
}
|
||||
|
||||
await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys)
|
||||
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>()
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,426 +0,0 @@
|
|||
import { makeDB } from '../utils'
|
||||
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
|
||||
import * as objects from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import * as ap_inbox from 'wildebeest/functions/ap/users/[id]/inbox'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
|
||||
const userKEK = 'test_kek9'
|
||||
const domain = 'cloudflare.com'
|
||||
const adminEmail = 'admin@example.com'
|
||||
const vapidKeys = {} as JWK
|
||||
|
||||
const kv_cache: any = {
|
||||
async put() {},
|
||||
}
|
||||
|
||||
const waitUntil = async (p: Promise<void>) => await p
|
||||
|
||||
describe('ActivityPub', () => {
|
||||
test('send Note to non existant user', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const activity: any = {}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'sven',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('send Note to inbox stores in DB', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: [actor.id.toString()],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'sven',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db
|
||||
.prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id')
|
||||
.first()
|
||||
const properties = JSON.parse(entry.properties)
|
||||
assert.equal(properties.content, 'test note')
|
||||
})
|
||||
|
||||
test("send Note adds in remote actor's outbox", async () => {
|
||||
const remoteActorId = 'https://example.com/actor'
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === remoteActorId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: remoteActorId,
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: remoteActorId,
|
||||
to: [],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'sven',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM outbox_objects WHERE actor_id=?').bind(remoteActorId).first()
|
||||
assert.equal(entry.actor_id, remoteActorId)
|
||||
})
|
||||
|
||||
test('local actor sends Note with mention create notification', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actorB.id.toString(),
|
||||
to: [actorA.id.toString()],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
|
||||
assert.equal(entry.type, 'mention')
|
||||
assert.equal(entry.actor_id.toString(), actorA.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
|
||||
})
|
||||
|
||||
test('remote actor sends Note with mention create notification and download actor', async () => {
|
||||
const actorB = 'https://remote.com/actorb'
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === actorB) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: actorB,
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actorB,
|
||||
to: [actorA.id.toString()],
|
||||
cc: [],
|
||||
object: {
|
||||
id: 'https://example.com/note3',
|
||||
type: 'Note',
|
||||
content: 'test note',
|
||||
},
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actors WHERE id=?').bind(actorB).first()
|
||||
assert.equal(entry.id, actorB)
|
||||
})
|
||||
|
||||
test('send Note records reply', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
{
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: [actor.id.toString()],
|
||||
object: {
|
||||
id: 'https://example.com/note1',
|
||||
type: 'Note',
|
||||
content: 'post',
|
||||
},
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'sven',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
}
|
||||
|
||||
{
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: [actor.id.toString()],
|
||||
object: {
|
||||
inReplyTo: 'https://example.com/note1',
|
||||
id: 'https://example.com/note2',
|
||||
type: 'Note',
|
||||
content: 'reply',
|
||||
},
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'sven',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
}
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_replies').first()
|
||||
assert.equal(entry.actor_id, actor.id.toString().toString())
|
||||
|
||||
const obj: any = await objects.getObjectById(db, entry.object_id)
|
||||
assert(obj)
|
||||
assert.equal(obj.originalObjectId, 'https://example.com/note2')
|
||||
|
||||
const inReplyTo: any = await objects.getObjectById(db, entry.in_reply_to_object_id)
|
||||
assert(inReplyTo)
|
||||
assert.equal(inReplyTo.originalObjectId, 'https://example.com/note1')
|
||||
})
|
||||
|
||||
describe('Announce', () => {
|
||||
test('records reblog in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_reblogs').first()
|
||||
assert.equal(entry.actor_id.toString(), actorB.id.toString())
|
||||
assert.equal(entry.object_id.toString(), note.id.toString())
|
||||
})
|
||||
|
||||
test('creates notification', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Announce',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
|
||||
assert(entry)
|
||||
assert.equal(entry.type, 'reblog')
|
||||
assert.equal(entry.actor_id.toString(), actorA.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Like', () => {
|
||||
test('records like in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Like',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_favourites').first()
|
||||
assert.equal(entry.actor_id.toString(), actorB.id.toString())
|
||||
assert.equal(entry.object_id.toString(), note.id.toString())
|
||||
})
|
||||
|
||||
test('creates notification', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Like',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
|
||||
assert.equal(entry.type, 'favourite')
|
||||
assert.equal(entry.actor_id.toString(), actorA.id.toString())
|
||||
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
|
||||
})
|
||||
|
||||
test('records like in db', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'my first status', actorA)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Like',
|
||||
actor: actorB.id,
|
||||
object: note.id,
|
||||
}
|
||||
const res = await ap_inbox.handleRequest(
|
||||
domain,
|
||||
db,
|
||||
kv_cache,
|
||||
'a',
|
||||
activity,
|
||||
userKEK,
|
||||
waitUntil,
|
||||
adminEmail,
|
||||
vapidKeys
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const entry = await db.prepare('SELECT * FROM actor_favourites').first()
|
||||
assert.equal(entry.actor_id.toString(), actorB.id.toString())
|
||||
assert.equal(entry.object_id.toString(), note.id.toString())
|
||||
})
|
||||
})
|
||||
})
|
|
@ -11,6 +11,7 @@ import { makeDB, assertCORS, assertJSON, assertCache, createTestClient } from '.
|
|||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { createSubscription } from '../src/mastodon/subscription'
|
||||
import * as subscription from 'wildebeest/functions/api/v1/push/subscription'
|
||||
import { enrichStatus } from 'wildebeest/backend/src/mastodon/microformats'
|
||||
|
||||
const userKEK = 'test_kek'
|
||||
const domain = 'cloudflare.com'
|
||||
|
@ -26,6 +27,17 @@ async function generateVAPIDKeys(): Promise<JWK> {
|
|||
|
||||
describe('Mastodon APIs', () => {
|
||||
describe('instance', () => {
|
||||
type Data = {
|
||||
rules: unknown[]
|
||||
uri: string
|
||||
title: string
|
||||
email: string
|
||||
description: string
|
||||
version: string
|
||||
domain: string
|
||||
contact: { email: string }
|
||||
}
|
||||
|
||||
test('return the instance infos v1', async () => {
|
||||
const env = {
|
||||
INSTANCE_TITLE: 'a',
|
||||
|
@ -39,12 +51,13 @@ describe('Mastodon APIs', () => {
|
|||
assertJSON(res)
|
||||
|
||||
{
|
||||
const data = await res.json<any>()
|
||||
const data = await res.json<Data>()
|
||||
assert.equal(data.rules.length, 0)
|
||||
assert.equal(data.uri, domain)
|
||||
assert.equal(data.title, 'a')
|
||||
assert.equal(data.email, 'b')
|
||||
assert.equal(data.description, 'c')
|
||||
assert(data.version.includes('Wildebeest'))
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -76,12 +89,13 @@ describe('Mastodon APIs', () => {
|
|||
assertJSON(res)
|
||||
|
||||
{
|
||||
const data = await res.json<any>()
|
||||
const data = await res.json<Data>()
|
||||
assert.equal(data.rules.length, 0)
|
||||
assert.equal(data.domain, domain)
|
||||
assert.equal(data.title, 'a')
|
||||
assert.equal(data.contact.email, 'b')
|
||||
assert.equal(data.description, 'c')
|
||||
assert(data.version.includes('Wildebeest'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
@ -93,6 +107,9 @@ describe('Mastodon APIs', () => {
|
|||
const request = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
body: '{"redirect_uris":"mastodon://joinmastodon.org/oauth","website":"https://app.joinmastodon.org/ios","client_name":"Mastodon for iOS","scopes":"read write follow push"}',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await apps.handleRequest(db, request, vapidKeys)
|
||||
|
@ -243,7 +260,7 @@ describe('Mastodon APIs', () => {
|
|||
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first()
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>()
|
||||
assert.equal(count, 1)
|
||||
})
|
||||
})
|
||||
|
@ -265,4 +282,69 @@ describe('Mastodon APIs', () => {
|
|||
const data = await res.json<any>()
|
||||
assert.equal(data.length, 0)
|
||||
})
|
||||
|
||||
describe('Microformats', () => {
|
||||
test('convert mentions to HTML', () => {
|
||||
const mentionsToTest = [
|
||||
{
|
||||
mention: '@sven2@example.com',
|
||||
expectedMentionSpan:
|
||||
'<span class="h-card"><a href="https://example.com/@sven2" class="u-url mention">@<span>sven2</span></a></span>',
|
||||
},
|
||||
{
|
||||
mention: '@test@example.eng.com',
|
||||
expectedMentionSpan:
|
||||
'<span class="h-card"><a href="https://example.eng.com/@test" class="u-url mention">@<span>test</span></a></span>',
|
||||
},
|
||||
{
|
||||
mention: '@test.a.b.c-d@example.eng.co.uk',
|
||||
expectedMentionSpan:
|
||||
'<span class="h-card"><a href="https://example.eng.co.uk/@test.a.b.c-d" class="u-url mention">@<span>test.a.b.c-d</span></a></span>',
|
||||
},
|
||||
{
|
||||
mention: '@testey@123456.abcdef',
|
||||
expectedMentionSpan:
|
||||
'<span class="h-card"><a href="https://123456.abcdef/@testey" class="u-url mention">@<span>testey</span></a></span>',
|
||||
},
|
||||
{
|
||||
mention: '@testey@123456.test.testey.abcdef',
|
||||
expectedMentionSpan:
|
||||
'<span class="h-card"><a href="https://123456.test.testey.abcdef/@testey" class="u-url mention">@<span>testey</span></a></span>',
|
||||
},
|
||||
]
|
||||
mentionsToTest.forEach(({ mention, expectedMentionSpan }) => {
|
||||
assert.equal(enrichStatus(`hey ${mention} hi`), `<p>hey ${expectedMentionSpan} hi</p>`)
|
||||
assert.equal(enrichStatus(`${mention} hi`), `<p>${expectedMentionSpan} hi</p>`)
|
||||
assert.equal(enrichStatus(`${mention}\n\thein`), `<p>${expectedMentionSpan}\n\thein</p>`)
|
||||
assert.equal(enrichStatus(`hey ${mention}`), `<p>hey ${expectedMentionSpan}</p>`)
|
||||
assert.equal(enrichStatus(`${mention}`), `<p>${expectedMentionSpan}</p>`)
|
||||
assert.equal(enrichStatus(`@!@£${mention}!!!`), `<p>@!@£${expectedMentionSpan}!!!</p>`)
|
||||
})
|
||||
})
|
||||
|
||||
test('handle invalid mention', () => {
|
||||
assert.equal(enrichStatus('hey @#-...@example.com'), '<p>hey @#-...@example.com</p>')
|
||||
})
|
||||
|
||||
test('convert links to HTML', () => {
|
||||
const linksToTest = [
|
||||
'https://cloudflare.com/abc',
|
||||
'https://cloudflare.com/abc/def',
|
||||
'https://www.cloudflare.com/123',
|
||||
'http://www.cloudflare.co.uk',
|
||||
'http://www.cloudflare.co.uk?test=test@123',
|
||||
'http://www.cloudflare.com/.com/?test=test@~123&a=b',
|
||||
'https://developers.cloudflare.com/workers/runtime-apis/request/#background',
|
||||
]
|
||||
linksToTest.forEach((link) => {
|
||||
const url = new URL(link)
|
||||
const urlDisplayText = `${url.hostname}${url.pathname}`
|
||||
assert.equal(enrichStatus(`hey ${link} hi`), `<p>hey <a href="${link}">${urlDisplayText}</a> hi</p>`)
|
||||
assert.equal(enrichStatus(`${link} hi`), `<p><a href="${link}">${urlDisplayText}</a> hi</p>`)
|
||||
assert.equal(enrichStatus(`hey ${link}`), `<p>hey <a href="${link}">${urlDisplayText}</a></p>`)
|
||||
assert.equal(enrichStatus(`${link}`), `<p><a href="${link}">${urlDisplayText}</a></p>`)
|
||||
assert.equal(enrichStatus(`@!@£${link}!!!`), `<p>@!@£<a href="${link}">${urlDisplayText}</a>!!!</p>`)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import { strict as assert } from 'node:assert/strict'
|
||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||
import { createReply } from 'wildebeest/backend/test/shared.utils'
|
||||
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
|
||||
import { MessageType } from 'wildebeest/backend/src/types/queue'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import * as accounts_following from 'wildebeest/functions/api/v1/accounts/[id]/following'
|
||||
import * as accounts_featured_tags from 'wildebeest/functions/api/v1/accounts/[id]/featured_tags'
|
||||
|
@ -10,14 +12,15 @@ import * as accounts_follow from 'wildebeest/functions/api/v1/accounts/[id]/foll
|
|||
import * as accounts_unfollow from 'wildebeest/functions/api/v1/accounts/[id]/unfollow'
|
||||
import * as accounts_statuses from 'wildebeest/functions/api/v1/accounts/[id]/statuses'
|
||||
import * as accounts_get from 'wildebeest/functions/api/v1/accounts/[id]'
|
||||
import { isUrlValid, makeDB, assertCORS, assertJSON } from '../utils'
|
||||
import { isUUID, isUrlValid, makeDB, assertCORS, assertJSON, makeQueue } from '../utils'
|
||||
import * as accounts_verify_creds from 'wildebeest/functions/api/v1/accounts/verify_credentials'
|
||||
import * as accounts_update_creds from 'wildebeest/functions/api/v1/accounts/update_credentials'
|
||||
import { createPerson, getPersonById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { createPerson, getActorById } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
|
||||
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import * as filters from 'wildebeest/functions/api/v1/filters'
|
||||
import { createStatus } from 'wildebeest/backend/src/mastodon/status'
|
||||
|
||||
const userKEK = 'test_kek2'
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
@ -96,6 +99,7 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
test('update credentials', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const updates = new FormData()
|
||||
|
@ -112,7 +116,8 @@ describe('Mastodon APIs', () => {
|
|||
connectedActor,
|
||||
'CF_ACCOUNT_ID',
|
||||
'CF_API_TOKEN',
|
||||
userKEK
|
||||
userKEK,
|
||||
queue
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
|
@ -120,31 +125,20 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(data.display_name, 'newsven')
|
||||
assert.equal(data.note, 'hein')
|
||||
|
||||
const updatedActor: any = await getPersonById(db, connectedActor.id)
|
||||
const updatedActor: any = await getActorById(db, connectedActor.id)
|
||||
assert(updatedActor)
|
||||
assert.equal(updatedActor.name, 'newsven')
|
||||
assert.equal(updatedActor.summary, 'hein')
|
||||
})
|
||||
|
||||
test('update credentials sends update', async () => {
|
||||
test('update credentials sends update to follower', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
await addFollowing(db, actor2, connectedActor, 'sven2@' + domain)
|
||||
await acceptFollowing(db, actor2, connectedActor)
|
||||
|
||||
let receivedActivity: any = null
|
||||
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (input.url.toString() === `https://${domain}/ap/users/sven2/inbox`) {
|
||||
assert.equal(input.method, 'POST')
|
||||
receivedActivity = await input.json()
|
||||
return new Response('')
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input.url)
|
||||
}
|
||||
|
||||
const updates = new FormData()
|
||||
updates.set('display_name', 'newsven')
|
||||
|
||||
|
@ -158,26 +152,32 @@ describe('Mastodon APIs', () => {
|
|||
connectedActor,
|
||||
'CF_ACCOUNT_ID',
|
||||
'CF_API_TOKEN',
|
||||
userKEK
|
||||
userKEK,
|
||||
queue
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert(receivedActivity)
|
||||
assert.equal(receivedActivity.type, 'Update')
|
||||
assert.equal(receivedActivity.object.id.toString(), connectedActor.id.toString())
|
||||
assert.equal(receivedActivity.object.name, 'newsven')
|
||||
assert.equal(queue.messages.length, 1)
|
||||
|
||||
assert.equal(queue.messages[0].type, MessageType.Deliver)
|
||||
assert.equal(queue.messages[0].activity.type, 'Update')
|
||||
assert.equal(queue.messages[0].actorId, connectedActor.id.toString())
|
||||
assert.equal(queue.messages[0].toActorId, actor2.id.toString())
|
||||
})
|
||||
|
||||
test('update credentials avatar and header', async () => {
|
||||
globalThis.fetch = async (input: RequestInfo, data: any) => {
|
||||
if (input === 'https://api.cloudflare.com/client/v4/accounts/CF_ACCOUNT_ID/images/v1') {
|
||||
assert.equal(data.method, 'POST')
|
||||
const file: any = data.body.get('file')
|
||||
const file: any = (data.body as { get: (str: string) => any }).get('file')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
result: {
|
||||
variants: ['https://example.com/' + file.name],
|
||||
variants: [
|
||||
'https://example.com/' + file.name + '/avatar',
|
||||
'https://example.com/' + file.name + '/header',
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
|
@ -187,6 +187,7 @@ describe('Mastodon APIs', () => {
|
|||
}
|
||||
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const updates = new FormData()
|
||||
|
@ -203,13 +204,14 @@ describe('Mastodon APIs', () => {
|
|||
connectedActor,
|
||||
'CF_ACCOUNT_ID',
|
||||
'CF_API_TOKEN',
|
||||
userKEK
|
||||
userKEK,
|
||||
queue
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.avatar, 'https://example.com/selfie.jpg')
|
||||
assert.equal(data.header, 'https://example.com/mountain.jpg')
|
||||
assert.equal(data.avatar, 'https://example.com/selfie.jpg/avatar')
|
||||
assert.equal(data.header, 'https://example.com/mountain.jpg/header')
|
||||
})
|
||||
|
||||
test('get remote actor by id', async () => {
|
||||
|
@ -234,7 +236,8 @@ describe('Mastodon APIs', () => {
|
|||
id: 'https://social.com/someone',
|
||||
url: 'https://social.com/@someone',
|
||||
type: 'Person',
|
||||
preferredUsername: 'sven',
|
||||
preferredUsername: '<script>bad</script>sven',
|
||||
name: 'Sven <i>Cool<i>',
|
||||
outbox: 'https://social.com/someone/outbox',
|
||||
following: 'https://social.com/someone/following',
|
||||
followers: 'https://social.com/someone/followers',
|
||||
|
@ -286,7 +289,9 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.username, 'sven')
|
||||
// Note the sanitization
|
||||
assert.equal(data.username, 'badsven')
|
||||
assert.equal(data.display_name, 'Sven Cool')
|
||||
assert.equal(data.acct, 'sven@social.com')
|
||||
|
||||
assert(isUrlValid(data.url))
|
||||
|
@ -315,8 +320,7 @@ describe('Mastodon APIs', () => {
|
|||
await addFollowing(db, actor3, actor, 'sven@' + domain)
|
||||
await acceptFollowing(db, actor3, actor)
|
||||
|
||||
const firstNote = await createPublicNote(domain, db, 'my first status', actor)
|
||||
await addObjectInOutbox(db, actor, firstNote)
|
||||
await createStatus(domain, db, actor, 'my first status')
|
||||
|
||||
const res = await accounts_get.handleRequest(domain, 'sven', db)
|
||||
assert.equal(res.status, 200)
|
||||
|
@ -328,44 +332,86 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(data.following_count, 2)
|
||||
assert.equal(data.statuses_count, 1)
|
||||
assert(isUrlValid(data.url))
|
||||
assert(data.url.includes(domain))
|
||||
assert((data.url as string).includes(domain))
|
||||
})
|
||||
|
||||
test('get local actor statuses', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const firstNote = await createPublicNote(domain, db, 'my first status', actor)
|
||||
await addObjectInOutbox(db, actor, firstNote)
|
||||
const firstNote = await createStatus(domain, db, actor, 'my first status')
|
||||
await insertLike(db, actor, firstNote)
|
||||
await sleep(10)
|
||||
const secondNode = await createPublicNote(domain, db, 'my second status', actor)
|
||||
await addObjectInOutbox(db, actor, secondNode)
|
||||
await insertReblog(db, actor, secondNode)
|
||||
const secondNote = await createStatus(domain, db, actor, 'my second status')
|
||||
await insertReblog(db, actor, secondNote)
|
||||
|
||||
const req = new Request('https://' + domain)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
assert.equal(data.length, 2)
|
||||
|
||||
assert(isUUID(data[0].id))
|
||||
assert.equal(data[0].content, 'my second status')
|
||||
assert.equal(data[0].account.acct, 'sven@' + domain)
|
||||
assert.equal(data[0].favourites_count, 0)
|
||||
assert.equal(data[0].reblogs_count, 1)
|
||||
assert.equal(new URL(data[0].uri).pathname, '/ap/o/' + data[0].id)
|
||||
assert.equal(new URL(data[0].url).pathname, '/statuses/' + data[0].id)
|
||||
|
||||
assert(isUUID(data[1].id))
|
||||
assert.equal(data[1].content, 'my first status')
|
||||
assert.equal(data[1].favourites_count, 1)
|
||||
assert.equal(data[1].reblogs_count, 0)
|
||||
})
|
||||
|
||||
test("get local actor statuses doesn't include replies", async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const note = await createStatus(domain, db, actor, 'a post')
|
||||
|
||||
await sleep(10)
|
||||
|
||||
await createReply(domain, db, actor, note, 'a reply')
|
||||
|
||||
const req = new Request('https://' + domain)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
||||
// Only 1 post because the reply is hidden
|
||||
assert.equal(data.length, 1)
|
||||
})
|
||||
|
||||
test('get local actor statuses includes media attachements', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const properties = { url: 'https://example.com/image.jpg' }
|
||||
const mediaAttachments = [await createImage(domain, db, actor, properties)]
|
||||
await createStatus(domain, db, actor, 'status from actor', mediaAttachments)
|
||||
|
||||
const req = new Request('https://' + domain)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
||||
assert.equal(data.length, 1)
|
||||
assert.equal(data[0].media_attachments.length, 1)
|
||||
assert.equal(data[0].media_attachments[0].type, 'image')
|
||||
assert.equal(data[0].media_attachments[0].url, properties.url)
|
||||
})
|
||||
|
||||
test('get pinned statuses', async () => {
|
||||
const db = await makeDB()
|
||||
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const req = new Request('https://' + domain + '?pinned=true')
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -395,7 +441,7 @@ describe('Mastodon APIs', () => {
|
|||
{
|
||||
// Query statuses after object1, should only see object2.
|
||||
const req = new Request('https://' + domain + '?max_id=object1')
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -407,7 +453,7 @@ describe('Mastodon APIs', () => {
|
|||
{
|
||||
// Query statuses after object2, nothing is after.
|
||||
const req = new Request('https://' + domain + '?max_id=object2')
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -415,6 +461,13 @@ describe('Mastodon APIs', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('get local actor statuses with max_id poiting to unknown id', async () => {
|
||||
const db = await makeDB()
|
||||
const req = new Request('https://' + domain + '?max_id=object1')
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain)
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('get remote actor statuses', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
|
@ -471,6 +524,26 @@ describe('Mastodon APIs', () => {
|
|||
id: 'https://example.com/object1',
|
||||
type: 'Note',
|
||||
content: '<p>p</p>',
|
||||
attachment: [
|
||||
{
|
||||
type: 'Document',
|
||||
mediaType: 'image/jpeg',
|
||||
url: 'https://example.com/image',
|
||||
name: null,
|
||||
blurhash: 'U48;V;_24mx[_1~p.7%MW9?a-;xtxvWBt6ad',
|
||||
width: 1080,
|
||||
height: 894,
|
||||
},
|
||||
{
|
||||
type: 'Document',
|
||||
mediaType: 'video/mp4',
|
||||
url: 'https://example.com/video',
|
||||
name: null,
|
||||
blurhash: 'UB9jfvtT0gO^N5tSX4XV9uR%^Ni]D%Rj$*nf',
|
||||
width: 1080,
|
||||
height: 616,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -489,7 +562,7 @@ describe('Mastodon APIs', () => {
|
|||
}
|
||||
|
||||
const req = new Request('https://example.com')
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com', userKEK)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -497,9 +570,13 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(data[0].content, '<p>p</p>')
|
||||
assert.equal(data[0].account.username, 'someone')
|
||||
|
||||
assert.equal(data[0].media_attachments.length, 2)
|
||||
assert.equal(data[0].media_attachments[0].type, 'image')
|
||||
assert.equal(data[0].media_attachments[1].type, 'video')
|
||||
|
||||
// Statuses were imported locally and once was a reblog of an already
|
||||
// existing local object.
|
||||
const row = await db.prepare(`SELECT count(*) as count FROM objects`).first()
|
||||
const row: { count: number } = await db.prepare(`SELECT count(*) as count FROM objects`).first()
|
||||
assert.equal(row.count, 2)
|
||||
})
|
||||
|
||||
|
@ -567,7 +644,7 @@ describe('Mastodon APIs', () => {
|
|||
}
|
||||
|
||||
const req = new Request('https://example.com')
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com', userKEK)
|
||||
const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -576,15 +653,87 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
test('get remote actor followers', async () => {
|
||||
const db = await makeDB()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://example.com/users/sven',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/sven') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/users/sven',
|
||||
type: 'Person',
|
||||
followers: 'https://example.com/users/sven/followers',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/sven/followers') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: 'https://example.com/users/sven/followers',
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 3,
|
||||
first: 'https://example.com/users/sven/followers/1',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/sven/followers/1') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: 'https://example.com/users/sven/followers/1',
|
||||
type: 'OrderedCollectionPage',
|
||||
totalItems: 3,
|
||||
partOf: 'https://example.com/users/sven/followers',
|
||||
orderedItems: [
|
||||
actorA.id.toString(), // local user
|
||||
'https://example.com/users/b', // remote user
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/b') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/users/b',
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const req = new Request(`https://${domain}`)
|
||||
const res = await accounts_followers.handleRequest(req, db, 'sven@example.com', connectedActor)
|
||||
assert.equal(res.status, 403)
|
||||
const res = await accounts_followers.handleRequest(req, db, 'sven@example.com')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
assert.equal(data.length, 2)
|
||||
|
||||
assert.equal(data[0].acct, 'a@cloudflare.com')
|
||||
assert.equal(data[1].acct, 'b@example.com')
|
||||
})
|
||||
|
||||
test('get local actor followers', async () => {
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (input.toString() === 'https://' + domain + '/ap/users/sven2') {
|
||||
if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/actor',
|
||||
|
@ -602,9 +751,8 @@ describe('Mastodon APIs', () => {
|
|||
await addFollowing(db, actor2, actor, 'sven@' + domain)
|
||||
await acceptFollowing(db, actor2, actor)
|
||||
|
||||
const connectedActor = actor
|
||||
const req = new Request(`https://${domain}`)
|
||||
const res = await accounts_followers.handleRequest(req, db, 'sven', connectedActor)
|
||||
const res = await accounts_followers.handleRequest(req, db, 'sven')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -613,7 +761,7 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
test('get local actor following', async () => {
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (input.toString() === 'https://' + domain + '/ap/users/sven2') {
|
||||
if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/foo',
|
||||
|
@ -631,9 +779,8 @@ describe('Mastodon APIs', () => {
|
|||
await addFollowing(db, actor, actor2, 'sven@' + domain)
|
||||
await acceptFollowing(db, actor, actor2)
|
||||
|
||||
const connectedActor = actor
|
||||
const req = new Request(`https://${domain}`)
|
||||
const res = await accounts_following.handleRequest(req, db, 'sven', connectedActor)
|
||||
const res = await accounts_following.handleRequest(req, db, 'sven')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
|
@ -642,11 +789,82 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
test('get remote actor following', async () => {
|
||||
const db = await makeDB()
|
||||
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://example.com/users/sven',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/sven') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/users/sven',
|
||||
type: 'Person',
|
||||
following: 'https://example.com/users/sven/following',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/sven/following') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: 'https://example.com/users/sven/following',
|
||||
type: 'OrderedCollection',
|
||||
totalItems: 3,
|
||||
first: 'https://example.com/users/sven/following/1',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/sven/following/1') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
id: 'https://example.com/users/sven/following/1',
|
||||
type: 'OrderedCollectionPage',
|
||||
totalItems: 3,
|
||||
partOf: 'https://example.com/users/sven/following',
|
||||
orderedItems: [
|
||||
actorA.id.toString(), // local user
|
||||
'https://example.com/users/b', // remote user
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://example.com/users/b') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/users/b',
|
||||
type: 'Person',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const req = new Request(`https://${domain}`)
|
||||
const res = await accounts_following.handleRequest(req, db, 'sven@example.com', connectedActor)
|
||||
assert.equal(res.status, 403)
|
||||
const res = await accounts_following.handleRequest(req, db, 'sven@example.com')
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<any>>()
|
||||
assert.equal(data.length, 2)
|
||||
|
||||
assert.equal(data[0].acct, 'a@cloudflare.com')
|
||||
assert.equal(data[1].acct, 'b@example.com')
|
||||
})
|
||||
|
||||
test('get remote actor featured_tags', async () => {
|
||||
|
@ -749,11 +967,9 @@ describe('Mastodon APIs', () => {
|
|||
beforeEach(() => {
|
||||
receivedActivity = null
|
||||
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (
|
||||
input.toString() ===
|
||||
'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + ''
|
||||
) {
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
const request = new Request(input)
|
||||
if (request.url === 'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + '') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
|
@ -767,7 +983,7 @@ describe('Mastodon APIs', () => {
|
|||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://social.com/sven') {
|
||||
if (request.url === 'https://social.com/sven') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: `https://${domain}/ap/users/actor`,
|
||||
|
@ -777,13 +993,13 @@ describe('Mastodon APIs', () => {
|
|||
)
|
||||
}
|
||||
|
||||
if (input.url === 'https://example.com/inbox') {
|
||||
assert.equal(input.method, 'POST')
|
||||
receivedActivity = await input.json()
|
||||
if (request.url === 'https://example.com/inbox') {
|
||||
assert.equal(request.method, 'POST')
|
||||
receivedActivity = await request.json()
|
||||
return new Response('')
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
throw new Error('unexpected request to ' + request.url)
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -802,7 +1018,11 @@ describe('Mastodon APIs', () => {
|
|||
assert(receivedActivity)
|
||||
assert.equal(receivedActivity.type, 'Follow')
|
||||
|
||||
const row = await db
|
||||
const row: {
|
||||
target_actor_acct: string
|
||||
target_actor_id: string
|
||||
state: string
|
||||
} = await db
|
||||
.prepare(`SELECT target_actor_acct, target_actor_id, state FROM actor_following WHERE actor_id=?`)
|
||||
.bind(actor.id.toString())
|
||||
.first()
|
||||
|
@ -833,7 +1053,7 @@ describe('Mastodon APIs', () => {
|
|||
const row = await db
|
||||
.prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`)
|
||||
.bind(actor.id.toString())
|
||||
.first()
|
||||
.first<{ count: number }>()
|
||||
assert(row)
|
||||
assert.equal(row.count, 0)
|
||||
})
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import { addPeer } from 'wildebeest/backend/src/activitypub/peers'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import * as peers from 'wildebeest/functions/api/v1/instance/peers'
|
||||
import { makeDB } from '../utils'
|
||||
|
||||
describe('Mastodon APIs', () => {
|
||||
describe('instance', () => {
|
||||
test('returns peers', async () => {
|
||||
const db = await makeDB()
|
||||
await addPeer(db, 'a')
|
||||
await addPeer(db, 'b')
|
||||
|
||||
const res = await peers.handleRequest(db)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<Array<string>>()
|
||||
assert.equal(data.length, 2)
|
||||
assert.equal(data[0], 'a')
|
||||
assert.equal(data[1], 'b')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,8 +1,11 @@
|
|||
import * as media from 'wildebeest/functions/api/v2/media'
|
||||
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
|
||||
import * as media_id from 'wildebeest/functions/api/v2/media/[id]'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import { makeDB, assertJSON, isUrlValid } from '../utils'
|
||||
import * as objects from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { mastodonIdSymbol, originalActorIdSymbol } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
const userKEK = 'test_kek10'
|
||||
const CF_ACCOUNT_ID = 'testaccountid'
|
||||
|
@ -13,18 +16,19 @@ describe('Mastodon APIs', () => {
|
|||
describe('media', () => {
|
||||
test('upload image creates object', async () => {
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input === 'https://api.cloudflare.com/client/v4/accounts/testaccountid/images/v1') {
|
||||
const request = new Request(input)
|
||||
if (request.url.toString() === 'https://api.cloudflare.com/client/v4/accounts/testaccountid/images/v1') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
result: {
|
||||
id: 'abcd',
|
||||
variants: ['https://example.com/' + file.name],
|
||||
variants: ['https://example.com/' + file.name + '/usercontent'],
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
throw new Error('unexpected request to ' + input)
|
||||
throw new Error('unexpected request to ' + request.url)
|
||||
}
|
||||
|
||||
const db = await makeDB()
|
||||
|
@ -39,7 +43,7 @@ describe('Mastodon APIs', () => {
|
|||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
const res = await media.handleRequest(req, db, connectedActor, CF_ACCOUNT_ID, CF_API_TOKEN)
|
||||
const res = await media.handleRequestPost(req, db, connectedActor, CF_ACCOUNT_ID, CF_API_TOKEN)
|
||||
assert.equal(res.status, 200)
|
||||
assertJSON(res)
|
||||
|
||||
|
@ -50,9 +54,36 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
const obj = await objects.getObjectByMastodonId(db, data.id)
|
||||
assert(obj)
|
||||
assert(obj.mastodonId)
|
||||
assert(obj[mastodonIdSymbol])
|
||||
assert.equal(obj.type, 'Image')
|
||||
assert.equal(obj.originalActorId, connectedActor.id.toString())
|
||||
assert.equal(obj[originalActorIdSymbol], connectedActor.id.toString())
|
||||
})
|
||||
|
||||
test('update image description', async () => {
|
||||
const db = await makeDB()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const properties = {
|
||||
url: 'https://cloudflare.com/image.jpg',
|
||||
description: 'foo bar',
|
||||
}
|
||||
const image = await createImage(domain, db, connectedActor, properties)
|
||||
|
||||
const request = new Request('https://' + domain, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ description: 'new foo bar' }),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const res = await media_id.handleRequestPut(db, image[mastodonIdSymbol]!, request)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.description, 'new foo bar')
|
||||
|
||||
const newImage = (await objects.getObjectByMastodonId(db, image[mastodonIdSymbol]!)) as any
|
||||
assert.equal(newImage.description, 'new foo bar')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -4,12 +4,13 @@ import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/not
|
|||
import { createNotification, insertFollowNotification } from 'wildebeest/backend/src/mastodon/notification'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import * as notifications from 'wildebeest/functions/api/v1/notifications'
|
||||
import { makeDB, assertJSON, createTestClient } from '../utils'
|
||||
import { makeCache, makeDB, assertJSON, createTestClient } from '../utils'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import { sendLikeNotification } from 'wildebeest/backend/src/mastodon/notification'
|
||||
import { createSubscription } from 'wildebeest/backend/src/mastodon/subscription'
|
||||
import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops'
|
||||
import { getNotifications } from 'wildebeest/backend/src/mastodon/notification'
|
||||
import { mastodonIdSymbol } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
const userKEK = 'test_kek15'
|
||||
const domain = 'cloudflare.com'
|
||||
|
@ -32,15 +33,13 @@ describe('Mastodon APIs', () => {
|
|||
test('returns notifications stored in KV cache', async () => {
|
||||
const db = await makeDB()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const kv_cache: any = {
|
||||
async get(key: string) {
|
||||
assert.equal(key, connectedActor.id + '/notifications')
|
||||
return 'cached data'
|
||||
},
|
||||
}
|
||||
const cache = makeCache()
|
||||
|
||||
await cache.put(connectedActor.id + '/notifications', 12345)
|
||||
|
||||
const req = new Request('https://' + domain)
|
||||
const data = await notifications.handleRequest(req, kv_cache, connectedActor)
|
||||
assert.equal(await data.text(), 'cached data')
|
||||
const data = await notifications.handleRequest(req, cache, connectedActor)
|
||||
assert.equal(await data.json(), 12345)
|
||||
})
|
||||
|
||||
test('returns notifications stored in db', async () => {
|
||||
|
@ -56,15 +55,15 @@ describe('Mastodon APIs', () => {
|
|||
await sleep(10)
|
||||
await createNotification(db, 'mention', connectedActor, fromActor, note)
|
||||
|
||||
const notifications: any = await getNotifications(db, connectedActor)
|
||||
const notifications: any = await getNotifications(db, connectedActor, domain)
|
||||
|
||||
assert.equal(notifications[0].type, 'mention')
|
||||
assert.equal(notifications[0].account.username, 'from')
|
||||
assert.equal(notifications[0].status.id, note.mastodonId)
|
||||
assert.equal(notifications[0].status.id, note[mastodonIdSymbol])
|
||||
|
||||
assert.equal(notifications[1].type, 'favourite')
|
||||
assert.equal(notifications[1].account.username, 'from')
|
||||
assert.equal(notifications[1].status.id, note.mastodonId)
|
||||
assert.equal(notifications[1].status.id, note[mastodonIdSymbol])
|
||||
assert.equal(notifications[1].status.account.username, 'sven')
|
||||
|
||||
assert.equal(notifications[2].type, 'follow')
|
||||
|
@ -119,7 +118,7 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
globalThis.fetch = async (input: RequestInfo, data: any) => {
|
||||
if (input === 'https://push.com') {
|
||||
assert(data.headers['Authorization'].includes('WebPush'))
|
||||
assert((data.headers['Authorization'] as string).includes('WebPush'))
|
||||
|
||||
const cryptoKeyHeader = parseCryptoKey(data.headers['Crypto-Key'])
|
||||
assert(cryptoKeyHeader.dh)
|
||||
|
|
|
@ -5,6 +5,7 @@ import * as oauth_token from 'wildebeest/functions/oauth/token'
|
|||
import { isUrlValid, makeDB, assertCORS, assertJSON, createTestClient } from '../utils'
|
||||
import { TEST_JWT, ACCESS_CERTS } from '../test-data'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
|
||||
const userKEK = 'test_kek3'
|
||||
const accessDomain = 'access.com'
|
||||
|
@ -34,25 +35,37 @@ describe('Mastodon APIs', () => {
|
|||
const db = await makeDB()
|
||||
|
||||
let req = new Request('https://example.com/oauth/authorize')
|
||||
let res = await oauth_authorize.handleRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
let res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 401)
|
||||
|
||||
const headers = {
|
||||
'Cf-Access-Jwt-Assertion': TEST_JWT,
|
||||
}
|
||||
|
||||
req = new Request('https://example.com/oauth/authorize', { headers })
|
||||
res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 400)
|
||||
|
||||
req = new Request('https://example.com/oauth/authorize?scope=foobar')
|
||||
res = await oauth_authorize.handleRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
req = new Request('https://example.com/oauth/authorize?scope=foobar', { headers })
|
||||
res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 400)
|
||||
})
|
||||
|
||||
test('authorize unsupported response_type', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const headers = {
|
||||
'Cf-Access-Jwt-Assertion': TEST_JWT,
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: 'https://example.com',
|
||||
response_type: 'hein',
|
||||
client_id: 'client_id',
|
||||
})
|
||||
|
||||
const req = new Request('https://example.com/oauth/authorize?' + params)
|
||||
const res = await oauth_authorize.handleRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
const req = new Request('https://example.com/oauth/authorize?' + params, { headers })
|
||||
const res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 400)
|
||||
})
|
||||
|
||||
|
@ -71,7 +84,7 @@ describe('Mastodon APIs', () => {
|
|||
const req = new Request('https://example.com/oauth/authorize?' + params, {
|
||||
headers,
|
||||
})
|
||||
const res = await oauth_authorize.handleRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
const res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 403)
|
||||
})
|
||||
|
||||
|
@ -83,6 +96,7 @@ describe('Mastodon APIs', () => {
|
|||
redirect_uri: client.redirect_uris,
|
||||
response_type: 'code',
|
||||
client_id: client.id,
|
||||
state: 'mock-state',
|
||||
})
|
||||
|
||||
const headers = { 'Cf-Access-Jwt-Assertion': TEST_JWT }
|
||||
|
@ -90,26 +104,25 @@ describe('Mastodon APIs', () => {
|
|||
const req = new Request('https://example.com/oauth/authorize?' + params, {
|
||||
headers,
|
||||
})
|
||||
const res = await oauth_authorize.handleRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
const res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 302)
|
||||
|
||||
const location = new URL(res.headers.get('location') || '')
|
||||
assert.equal(
|
||||
location.searchParams.get('redirect_uri'),
|
||||
encodeURIComponent(`${client.redirect_uris}?code=${client.id}.${TEST_JWT}`)
|
||||
encodeURIComponent(`${client.redirect_uris}?code=${client.id}.${TEST_JWT}&state=mock-state`)
|
||||
)
|
||||
|
||||
// actor isn't created yet
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM actors').first()
|
||||
const { count } = await db.prepare('SELECT count(*) as count FROM actors').first<{ count: number }>()
|
||||
assert.equal(count, 0)
|
||||
})
|
||||
|
||||
test('first login creates the user and redirects', async () => {
|
||||
test('first login is protected by Access', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: 'https://redirect.com/a',
|
||||
email: 'a@cloudflare.com',
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
|
@ -120,21 +133,45 @@ describe('Mastodon APIs', () => {
|
|||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
const res = await first_login.handlePostRequest(req, db, userKEK)
|
||||
const res = await first_login.handlePostRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 401)
|
||||
})
|
||||
|
||||
test('first login creates the user and redirects', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: 'https://redirect.com/a',
|
||||
})
|
||||
|
||||
const formData = new FormData()
|
||||
formData.set('username', 'username')
|
||||
formData.set('name', 'name')
|
||||
|
||||
const req = new Request('https://example.com/first-login?' + params, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
cookie: `CF_Authorization=${TEST_JWT}`,
|
||||
},
|
||||
})
|
||||
const res = await first_login.handlePostRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 302)
|
||||
|
||||
const location = res.headers.get('location')
|
||||
assert.equal(location, 'https://redirect.com/a')
|
||||
|
||||
const actor = await db.prepare('SELECT * FROM actors').first()
|
||||
const actor = await db
|
||||
.prepare('SELECT * FROM actors')
|
||||
.first<{ properties: string; email: string; id: string } & Actor>()
|
||||
const properties = JSON.parse(actor.properties)
|
||||
|
||||
assert.equal(actor.email, 'a@cloudflare.com')
|
||||
assert.equal(actor.email, 'sven@cloudflare.com')
|
||||
assert.equal(properties.preferredUsername, 'username')
|
||||
assert.equal(properties.name, 'name')
|
||||
assert(isUrlValid(actor.id))
|
||||
// ensure that we generate a correct key pairs for the user
|
||||
assert((await getSigningKey(userKEK, db, actor)) instanceof CryptoKey)
|
||||
assert((await getSigningKey(userKEK, db, actor as Actor)) instanceof CryptoKey)
|
||||
})
|
||||
|
||||
test('token error on unknown client', async () => {
|
||||
|
@ -199,7 +236,7 @@ describe('Mastodon APIs', () => {
|
|||
const req = new Request('https://example.com/oauth/authorize', {
|
||||
method: 'OPTIONS',
|
||||
})
|
||||
const res = await oauth_authorize.handleRequest(req, db, userKEK, accessDomain, accessAud)
|
||||
const res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud)
|
||||
assert.equal(res.status, 200)
|
||||
assertCORS(res)
|
||||
})
|
||||
|
|
|
@ -1,28 +1,34 @@
|
|||
import { strict as assert } from 'node:assert/strict'
|
||||
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
|
||||
import { getMentions } from 'wildebeest/backend/src/mastodon/status'
|
||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { createReply } from 'wildebeest/backend/test/shared.utils'
|
||||
import { createStatus, getMentions } from 'wildebeest/backend/src/mastodon/status'
|
||||
import { createPublicNote, type Note } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
|
||||
import * as statuses from 'wildebeest/functions/api/v1/statuses'
|
||||
import * as statuses_get from 'wildebeest/functions/api/v1/statuses/[id]'
|
||||
import * as statuses_id from 'wildebeest/functions/api/v1/statuses/[id]'
|
||||
import * as statuses_favourite from 'wildebeest/functions/api/v1/statuses/[id]/favourite'
|
||||
import * as statuses_reblog from 'wildebeest/functions/api/v1/statuses/[id]/reblog'
|
||||
import * as statuses_context from 'wildebeest/functions/api/v1/statuses/[id]/context'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
|
||||
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import { isUrlValid, makeDB, assertJSON, streamToArrayBuffer } from '../utils'
|
||||
import * as note from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { isUrlValid, makeDB, assertJSON, streamToArrayBuffer, makeQueue, makeCache } from '../utils'
|
||||
import * as activities from 'wildebeest/backend/src/activitypub/activities'
|
||||
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import { MessageType } from 'wildebeest/backend/src/types/queue'
|
||||
import { MastodonStatus } from 'wildebeest/backend/src/types'
|
||||
import { mastodonIdSymbol, getObjectByMastodonId } from 'wildebeest/backend/src/activitypub/objects'
|
||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||
|
||||
const userKEK = 'test_kek4'
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
const domain = 'cloudflare.com'
|
||||
const cache = makeCache()
|
||||
|
||||
describe('Mastodon APIs', () => {
|
||||
describe('statuses', () => {
|
||||
test('create new status missing params', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
|
||||
const body = { status: 'my status' }
|
||||
const req = new Request('https://example.com', {
|
||||
|
@ -32,16 +38,17 @@ describe('Mastodon APIs', () => {
|
|||
})
|
||||
|
||||
const connectedActor: any = {}
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 400)
|
||||
})
|
||||
|
||||
test('create new status creates Note', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const body = {
|
||||
status: 'my status',
|
||||
status: 'my status <script>evil</script>',
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
|
@ -51,13 +58,13 @@ describe('Mastodon APIs', () => {
|
|||
})
|
||||
|
||||
const connectedActor = actor
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
assertJSON(res)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert(data.uri.includes('example.com'))
|
||||
assert(data.uri.includes(data.id))
|
||||
const data = await res.json<MastodonStatus>()
|
||||
assert((data.uri as unknown as string).includes('example.com'))
|
||||
assert((data.uri as unknown as string).includes(data.id))
|
||||
// Required fields from https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java
|
||||
assert(data.created_at !== undefined)
|
||||
assert(data.account !== undefined)
|
||||
|
@ -79,14 +86,43 @@ describe('Mastodon APIs', () => {
|
|||
FROM objects
|
||||
`
|
||||
)
|
||||
.first()
|
||||
assert.equal(row.content, 'my status')
|
||||
.first<{ content: string; original_actor_id: URL; original_object_id: unknown }>()
|
||||
assert.equal(row.content, '<p>my status <p>evil</p></p>') // note the sanitization
|
||||
assert.equal(row.original_actor_id.toString(), actor.id.toString())
|
||||
assert.equal(row.original_object_id, null)
|
||||
})
|
||||
|
||||
test('create new status regenerates the timeline and contains post', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const cache = makeCache()
|
||||
|
||||
const body = {
|
||||
status: 'my status',
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
assertJSON(res)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
||||
const cachedData = await cache.get<any>(actor.id + '/timeline/home')
|
||||
assert(cachedData)
|
||||
assert.equal(cachedData.length, 1)
|
||||
assert.equal(cachedData[0].id, data.id)
|
||||
})
|
||||
|
||||
test("create new status adds to Actor's outbox", async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const body = {
|
||||
|
@ -100,15 +136,52 @@ describe('Mastodon APIs', () => {
|
|||
})
|
||||
|
||||
const connectedActor = actor
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first()
|
||||
const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first<{ count: number }>()
|
||||
assert.equal(row.count, 1)
|
||||
})
|
||||
|
||||
test('create new status delivers to followers via Queue', async () => {
|
||||
const queue = makeQueue()
|
||||
const db = await makeDB()
|
||||
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const followerA = await createPerson(domain, db, userKEK, 'followerA@cloudflare.com')
|
||||
const followerB = await createPerson(domain, db, userKEK, 'followerB@cloudflare.com')
|
||||
|
||||
await addFollowing(db, followerA, actor, 'not needed')
|
||||
await sleep(10)
|
||||
await addFollowing(db, followerB, actor, 'not needed')
|
||||
await acceptFollowing(db, followerA, actor)
|
||||
await acceptFollowing(db, followerB, actor)
|
||||
|
||||
const body = { status: 'my status', visibility: 'public' }
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert.equal(queue.messages.length, 2)
|
||||
|
||||
assert.equal(queue.messages[0].type, MessageType.Deliver)
|
||||
assert.equal(queue.messages[0].userKEK, userKEK)
|
||||
assert.equal(queue.messages[0].actorId, actor.id.toString())
|
||||
assert.equal(queue.messages[0].toActorId, followerA.id.toString())
|
||||
|
||||
assert.equal(queue.messages[1].type, MessageType.Deliver)
|
||||
assert.equal(queue.messages[1].userKEK, userKEK)
|
||||
assert.equal(queue.messages[1].actorId, actor.id.toString())
|
||||
assert.equal(queue.messages[1].toActorId, followerB.id.toString())
|
||||
})
|
||||
|
||||
test('create new status with mention delivers ActivityPub Note', async () => {
|
||||
let deliveredNote: any = null
|
||||
let deliveredNote: Note | null = null
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo, data: any) => {
|
||||
if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') {
|
||||
|
@ -118,17 +191,17 @@ describe('Mastodon APIs', () => {
|
|||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://social.com/sven',
|
||||
href: 'https://social.com/users/sven',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://social.com/sven') {
|
||||
if (input.toString() === 'https://social.com/users/sven') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://social.com/sven',
|
||||
id: 'https://social.com/users/sven',
|
||||
inbox: 'https://social.com/sven/inbox',
|
||||
})
|
||||
)
|
||||
|
@ -156,6 +229,7 @@ describe('Mastodon APIs', () => {
|
|||
}
|
||||
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const body = {
|
||||
|
@ -169,28 +243,58 @@ describe('Mastodon APIs', () => {
|
|||
})
|
||||
|
||||
const connectedActor = actor
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert(deliveredNote)
|
||||
assert.equal(deliveredNote.type, 'Create')
|
||||
assert.equal(deliveredNote.actor, `https://${domain}/ap/users/sven`)
|
||||
assert.equal(deliveredNote.object.attributedTo, `https://${domain}/ap/users/sven`)
|
||||
assert.equal(deliveredNote.object.type, 'Note')
|
||||
assert(deliveredNote.object.to.includes(note.PUBLIC))
|
||||
assert.equal(deliveredNote.object.cc.length, 1)
|
||||
assert.equal((deliveredNote as { type: string }).type, 'Create')
|
||||
assert.equal((deliveredNote as { actor: string }).actor, `https://${domain}/ap/users/sven`)
|
||||
assert.equal(
|
||||
(deliveredNote as { object: { attributedTo: string } }).object.attributedTo,
|
||||
`https://${domain}/ap/users/sven`
|
||||
)
|
||||
assert.equal((deliveredNote as { object: { type: string } }).object.type, 'Note')
|
||||
assert((deliveredNote as { object: { to: string[] } }).object.to.includes(activities.PUBLIC_GROUP))
|
||||
assert.equal((deliveredNote as { object: { cc: string[] } }).object.cc.length, 2)
|
||||
})
|
||||
|
||||
test('create new status with image', async () => {
|
||||
test('create new status with mention add tags on Note', async () => {
|
||||
const db = await makeDB()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const properties = { url: 'foo' }
|
||||
const image = await createImage(domain, db, connectedActor, properties)
|
||||
// @ts-ignore
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (
|
||||
(input as RequestInfo).toString() ===
|
||||
'https://cloudflare.com/.well-known/webfinger?resource=acct%3Asven%40cloudflare.com'
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: actor.id,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input === actor.id.toString()) {
|
||||
return new Response(JSON.stringify(actor))
|
||||
}
|
||||
|
||||
if (input.url === actor.inbox.toString()) {
|
||||
return new Response()
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + JSON.stringify(input))
|
||||
}
|
||||
|
||||
const body = {
|
||||
status: 'my status',
|
||||
media_ids: [image.mastodonId],
|
||||
status: 'my status @sven@' + domain,
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
|
@ -199,7 +303,38 @@ describe('Mastodon APIs', () => {
|
|||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
|
||||
const connectedActor = actor
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
||||
const note = (await getObjectByMastodonId(db, data.id)) as unknown as Note
|
||||
assert.equal(note.tag.length, 1)
|
||||
assert.equal(note.tag[0].href, actor.id.toString())
|
||||
assert.equal(note.tag[0].name, 'sven@' + domain)
|
||||
})
|
||||
|
||||
test('create new status with image', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const properties = { url: 'https://example.com/image.jpg' }
|
||||
const image = await createImage(domain, db, connectedActor, properties)
|
||||
|
||||
const body = {
|
||||
status: 'my status',
|
||||
media_ids: [image[mastodonIdSymbol]],
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
@ -228,29 +363,21 @@ describe('Mastodon APIs', () => {
|
|||
)
|
||||
.run()
|
||||
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (input === actor.id.toString()) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: actor.id,
|
||||
inbox: 'https://social.com/sven/inbox',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.url === 'https://social.com/sven/inbox') {
|
||||
assert.equal(input.method, 'POST')
|
||||
const body = await input.json()
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
const request = new Request(input)
|
||||
if (request.url === actor.id.toString() + '/inbox') {
|
||||
assert.equal(request.method, 'POST')
|
||||
const body = await request.json()
|
||||
deliveredActivity = body
|
||||
return new Response()
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + JSON.stringify(input))
|
||||
throw new Error('unexpected request to ' + request.url)
|
||||
}
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
||||
const res = await statuses_favourite.handleRequest(db, 'mastodonid1', connectedActor, userKEK)
|
||||
const res = await statuses_favourite.handleRequest(db, 'mastodonid1', connectedActor, userKEK, domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert(deliveredActivity)
|
||||
|
@ -265,49 +392,137 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
const connectedActor: any = actor
|
||||
|
||||
const res = await statuses_favourite.handleRequest(db, note.mastodonId!, connectedActor, userKEK)
|
||||
const res = await statuses_favourite.handleRequest(db, note[mastodonIdSymbol]!, connectedActor, userKEK, domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.favourited, true)
|
||||
|
||||
const row = await db.prepare(`SELECT * FROM actor_favourites`).first()
|
||||
const row = await db.prepare(`SELECT * FROM actor_favourites`).first<{ actor_id: string; object_id: string }>()
|
||||
assert.equal(row.actor_id, actor.id.toString())
|
||||
assert.equal(row.object_id, note.id.toString())
|
||||
})
|
||||
|
||||
test('get mentions from status', () => {
|
||||
test('get mentions from status', async () => {
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
if (input.toString() === 'https://instance.horse/.well-known/webfinger?resource=acct%3Asven%40instance.horse') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://instance.horse/users/sven',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
if (input.toString() === 'https://cloudflare.com/.well-known/webfinger?resource=acct%3Asven%40cloudflare.com') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://cloudflare.com/users/sven',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
if (input.toString() === 'https://cloudflare.com/.well-known/webfinger?resource=acct%3Aa%40cloudflare.com') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://cloudflare.com/users/a',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
if (input.toString() === 'https://cloudflare.com/.well-known/webfinger?resource=acct%3Ab%40cloudflare.com') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
links: [
|
||||
{
|
||||
rel: 'self',
|
||||
type: 'application/activity+json',
|
||||
href: 'https://cloudflare.com/users/b',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.toString() === 'https://instance.horse/users/sven') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://instance.horse/users/sven',
|
||||
})
|
||||
)
|
||||
}
|
||||
if (input.toString() === 'https://cloudflare.com/users/sven') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://cloudflare.com/users/sven',
|
||||
})
|
||||
)
|
||||
}
|
||||
if (input.toString() === 'https://cloudflare.com/users/a') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://cloudflare.com/users/a',
|
||||
})
|
||||
)
|
||||
}
|
||||
if (input.toString() === 'https://cloudflare.com/users/b') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://cloudflare.com/users/b',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input)
|
||||
}
|
||||
|
||||
{
|
||||
const mentions = getMentions('test status')
|
||||
const mentions = await getMentions('test status', domain)
|
||||
assert.equal(mentions.length, 0)
|
||||
}
|
||||
|
||||
{
|
||||
const mentions = getMentions('@sven@instance.horse test status')
|
||||
const mentions = await getMentions('unknown@actor.com', domain)
|
||||
assert.equal(mentions.length, 0)
|
||||
}
|
||||
|
||||
{
|
||||
const mentions = await getMentions('@sven@instance.horse test status', domain)
|
||||
assert.equal(mentions.length, 1)
|
||||
assert.equal(mentions[0].localPart, 'sven')
|
||||
assert.equal(mentions[0].domain, 'instance.horse')
|
||||
assert.equal(mentions[0].id.toString(), 'https://instance.horse/users/sven')
|
||||
}
|
||||
|
||||
{
|
||||
const mentions = getMentions('@sven test status')
|
||||
const mentions = await getMentions('@sven test status', domain)
|
||||
assert.equal(mentions.length, 1)
|
||||
assert.equal(mentions[0].localPart, 'sven')
|
||||
assert.equal(mentions[0].domain, null)
|
||||
assert.equal(mentions[0].id.toString(), 'https://' + domain + '/users/sven')
|
||||
}
|
||||
|
||||
{
|
||||
const mentions = getMentions('@sven @james @pete')
|
||||
assert.deepEqual(mentions, [
|
||||
{ localPart: 'sven', domain: null },
|
||||
{ localPart: 'james', domain: null },
|
||||
{ localPart: 'pete', domain: null },
|
||||
])
|
||||
const mentions = await getMentions('@a @b', domain)
|
||||
assert.equal(mentions.length, 2)
|
||||
assert.equal(mentions[0].id.toString(), 'https://' + domain + '/users/a')
|
||||
assert.equal(mentions[1].id.toString(), 'https://' + domain + '/users/b')
|
||||
}
|
||||
|
||||
{
|
||||
const mentions = getMentions('<p>@sven</p>')
|
||||
assert.deepEqual(mentions, [{ localPart: 'sven', domain: null }])
|
||||
const mentions = await getMentions('<p>@sven</p>', domain)
|
||||
assert.equal(mentions.length, 1)
|
||||
assert.equal(mentions[0].id.toString(), 'https://' + domain + '/users/sven')
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -321,7 +536,7 @@ describe('Mastodon APIs', () => {
|
|||
await insertLike(db, actor2, note)
|
||||
await insertLike(db, actor3, note)
|
||||
|
||||
const res = await statuses_get.handleRequest(db, note.mastodonId!)
|
||||
const res = await statuses_id.handleRequestGet(db, note[mastodonIdSymbol]!, domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
@ -337,7 +552,7 @@ describe('Mastodon APIs', () => {
|
|||
const mediaAttachments = [await createImage(domain, db, actor, properties)]
|
||||
const note = await createPublicNote(domain, db, 'my first status', actor, mediaAttachments)
|
||||
|
||||
const res = await statuses_get.handleRequest(db, note.mastodonId!)
|
||||
const res = await statuses_id.handleRequestGet(db, note[mastodonIdSymbol]!, domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
@ -351,18 +566,12 @@ describe('Mastodon APIs', () => {
|
|||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'a post', actor)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
const note = await createStatus(domain, db, actor, 'a post')
|
||||
await sleep(10)
|
||||
|
||||
const inReplyTo = note.id
|
||||
const reply = await createPublicNote(domain, db, 'a reply', actor, [], { inReplyTo })
|
||||
await addObjectInOutbox(db, actor, reply)
|
||||
await sleep(10)
|
||||
await createReply(domain, db, actor, note, 'a reply')
|
||||
|
||||
await insertReply(db, actor, reply, note)
|
||||
|
||||
const res = await statuses_context.handleRequest(domain, db, note.mastodonId!)
|
||||
const res = await statuses_context.handleRequest(domain, db, note[mastodonIdSymbol]!)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
@ -382,7 +591,7 @@ describe('Mastodon APIs', () => {
|
|||
await insertReblog(db, actor2, note)
|
||||
await insertReblog(db, actor3, note)
|
||||
|
||||
const res = await statuses_get.handleRequest(db, note.mastodonId!)
|
||||
const res = await statuses_id.handleRequestGet(db, note[mastodonIdSymbol]!, domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
@ -392,18 +601,26 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
test('reblog records in db', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'my first status', actor)
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
||||
const res = await statuses_reblog.handleRequest(db, note.mastodonId!, connectedActor, userKEK)
|
||||
const res = await statuses_reblog.handleRequest(
|
||||
db,
|
||||
note[mastodonIdSymbol]!,
|
||||
connectedActor,
|
||||
userKEK,
|
||||
queue,
|
||||
domain
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.reblogged, true)
|
||||
|
||||
const row = await db.prepare(`SELECT * FROM actor_reblogs`).first()
|
||||
const row = await db.prepare(`SELECT * FROM actor_reblogs`).first<{ actor_id: string; object_id: string }>()
|
||||
assert.equal(row.actor_id, actor.id.toString())
|
||||
assert.equal(row.object_id, note.id.toString())
|
||||
})
|
||||
|
@ -411,36 +628,32 @@ describe('Mastodon APIs', () => {
|
|||
test('reblog status adds in actor outbox', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const originalObjectId = 'https://example.com/note123'
|
||||
const queue = makeQueue()
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, mastodon_id, local) VALUES (?, ?, ?, ?, ?, ?, 0)'
|
||||
)
|
||||
.bind(
|
||||
'https://example.com/object1',
|
||||
'Note',
|
||||
JSON.stringify({ content: 'my first status' }),
|
||||
actor.id.toString(),
|
||||
originalObjectId,
|
||||
'mastodonid1'
|
||||
)
|
||||
.run()
|
||||
const note = await createPublicNote(domain, db, 'my first status', actor)
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
||||
const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK)
|
||||
const res = await statuses_reblog.handleRequest(
|
||||
db,
|
||||
note[mastodonIdSymbol]!,
|
||||
connectedActor,
|
||||
userKEK,
|
||||
queue,
|
||||
domain
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const row = await db.prepare(`SELECT * FROM outbox_objects`).first()
|
||||
const row = await db.prepare(`SELECT * FROM outbox_objects`).first<{ actor_id: string; object_id: string }>()
|
||||
assert.equal(row.actor_id, actor.id.toString())
|
||||
assert.equal(row.object_id, 'https://example.com/object1')
|
||||
assert.equal(row.object_id, note.id.toString())
|
||||
})
|
||||
|
||||
test('reblog remote status status sends Announce activity to author', async () => {
|
||||
let deliveredActivity: any = null
|
||||
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const originalObjectId = 'https://example.com/note123'
|
||||
|
||||
|
@ -458,29 +671,21 @@ describe('Mastodon APIs', () => {
|
|||
)
|
||||
.run()
|
||||
|
||||
globalThis.fetch = async (input: any) => {
|
||||
if (input === actor.id.toString()) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: actor.id,
|
||||
inbox: 'https://social.com/sven/inbox',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (input.url === 'https://social.com/sven/inbox') {
|
||||
assert.equal(input.method, 'POST')
|
||||
const body = await input.json()
|
||||
globalThis.fetch = async (input: RequestInfo) => {
|
||||
const request = new Request(input)
|
||||
if (request.url === 'https://cloudflare.com/ap/users/sven/inbox') {
|
||||
assert.equal(request.method, 'POST')
|
||||
const body = await request.json()
|
||||
deliveredActivity = body
|
||||
return new Response()
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + JSON.stringify(input))
|
||||
throw new Error('unexpected request to ' + request.url)
|
||||
}
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
||||
const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK)
|
||||
const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK, queue, domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert(deliveredActivity)
|
||||
|
@ -489,5 +694,277 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(deliveredActivity.object, originalObjectId)
|
||||
})
|
||||
})
|
||||
|
||||
test('create new status in reply to non existing status', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const body = {
|
||||
status: 'my reply',
|
||||
in_reply_to_id: 'hein',
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('create new status in reply to', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'my first status', actor)
|
||||
|
||||
const body = {
|
||||
status: 'my reply',
|
||||
in_reply_to_id: note[mastodonIdSymbol],
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
|
||||
{
|
||||
const row = await db
|
||||
.prepare(
|
||||
`
|
||||
SELECT json_extract(properties, '$.inReplyTo') as inReplyTo
|
||||
FROM objects
|
||||
WHERE mastodon_id=?
|
||||
`
|
||||
)
|
||||
.bind(data.id)
|
||||
.first<{ inReplyTo: string }>()
|
||||
assert(row !== undefined)
|
||||
assert.equal(row.inReplyTo, note.id.toString())
|
||||
}
|
||||
|
||||
{
|
||||
const row = await db.prepare('select * from actor_replies').first<{
|
||||
actor_id: string
|
||||
in_reply_to_object_id: string
|
||||
}>()
|
||||
assert(row !== undefined)
|
||||
assert.equal(row.actor_id, actor.id.toString())
|
||||
assert.equal(row.in_reply_to_object_id, note.id.toString())
|
||||
}
|
||||
})
|
||||
|
||||
test('create new status with too many image', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const body = {
|
||||
status: 'my status',
|
||||
media_ids: ['id', 'id', 'id', 'id', 'id'],
|
||||
visibility: 'public',
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 400)
|
||||
const data = await res.json<{ error: string }>()
|
||||
assert(data.error.includes('Limit exceeded'))
|
||||
})
|
||||
|
||||
test('create new status sending multipart and too many image', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const body = new FormData()
|
||||
body.append('status', 'my status')
|
||||
body.append('visibility', 'public')
|
||||
body.append('media_ids[]', 'id')
|
||||
body.append('media_ids[]', 'id')
|
||||
body.append('media_ids[]', 'id')
|
||||
body.append('media_ids[]', 'id')
|
||||
body.append('media_ids[]', 'id')
|
||||
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
|
||||
const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache)
|
||||
assert.equal(res.status, 400)
|
||||
const data = await res.json<{ error: string }>()
|
||||
assert(data.error.includes('Limit exceeded'))
|
||||
})
|
||||
|
||||
test('delete non-existing status', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const mastodonId = 'abcd'
|
||||
const res = await statuses_id.handleRequestDelete(db, mastodonId, actor, domain, userKEK, queue, cache)
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('delete status from a different actor', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'note from actor2', actor2)
|
||||
|
||||
const res = await statuses_id.handleRequestDelete(
|
||||
db,
|
||||
note[mastodonIdSymbol]!,
|
||||
actor,
|
||||
domain,
|
||||
userKEK,
|
||||
queue,
|
||||
cache
|
||||
)
|
||||
assert.equal(res.status, 404)
|
||||
})
|
||||
|
||||
test('delete status remove DB rows', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'note from actor', actor)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
|
||||
const res = await statuses_id.handleRequestDelete(
|
||||
db,
|
||||
note[mastodonIdSymbol]!,
|
||||
actor,
|
||||
domain,
|
||||
userKEK,
|
||||
queue,
|
||||
cache
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
{
|
||||
const { count } = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first<any>()
|
||||
assert.equal(count, 0)
|
||||
}
|
||||
{
|
||||
const { count } = await db.prepare(`SELECT count(*) as count FROM objects`).first<any>()
|
||||
assert.equal(count, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('delete status regenerates the timeline', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const cache = makeCache()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'note from actor', actor)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
|
||||
// Poison the timeline
|
||||
await cache.put(actor.id.toString() + '/timeline/home', 'funny value')
|
||||
|
||||
const res = await statuses_id.handleRequestDelete(
|
||||
db,
|
||||
note[mastodonIdSymbol]!,
|
||||
actor,
|
||||
domain,
|
||||
userKEK,
|
||||
queue,
|
||||
cache
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
// ensure that timeline has been regenerated after the deletion
|
||||
// and that timeline is empty
|
||||
const timeline = await cache.get<Array<any>>(actor.id.toString() + '/timeline/home')
|
||||
assert(timeline)
|
||||
assert.equal(timeline!.length, 0)
|
||||
})
|
||||
|
||||
test('delete status sends to followers', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
const actor3 = await createPerson(domain, db, userKEK, 'sven3@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'note from actor', actor)
|
||||
|
||||
await addFollowing(db, actor2, actor, 'not needed')
|
||||
await acceptFollowing(db, actor2, actor)
|
||||
await addFollowing(db, actor3, actor, 'not needed')
|
||||
await acceptFollowing(db, actor3, actor)
|
||||
|
||||
const res = await statuses_id.handleRequestDelete(
|
||||
db,
|
||||
note[mastodonIdSymbol]!,
|
||||
actor,
|
||||
domain,
|
||||
userKEK,
|
||||
queue,
|
||||
cache
|
||||
)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
assert.equal(queue.messages.length, 2)
|
||||
assert.equal(queue.messages[0].activity.type, 'Delete')
|
||||
assert.equal(queue.messages[0].actorId, actor.id.toString())
|
||||
assert.equal(queue.messages[0].toActorId, actor2.id.toString())
|
||||
assert.equal(queue.messages[1].activity.type, 'Delete')
|
||||
assert.equal(queue.messages[1].actorId, actor.id.toString())
|
||||
assert.equal(queue.messages[1].toActorId, actor3.id.toString())
|
||||
})
|
||||
|
||||
test('create duplicate statuses idempotency', async () => {
|
||||
const db = await makeDB()
|
||||
const queue = makeQueue()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const idempotencyKey = 'abcd'
|
||||
|
||||
const body = { status: 'my status', visibility: 'public' }
|
||||
const req = () =>
|
||||
new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'idempotency-key': idempotencyKey,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const res1 = await statuses.handleRequest(req(), db, actor, userKEK, queue, cache)
|
||||
assert.equal(res1.status, 200)
|
||||
const data1 = await res1.json()
|
||||
|
||||
const res2 = await statuses.handleRequest(req(), db, actor, userKEK, queue, cache)
|
||||
assert.equal(res2.status, 200)
|
||||
const data2 = await res2.json()
|
||||
|
||||
assert.deepEqual(data1, data2)
|
||||
|
||||
{
|
||||
const row = await db.prepare(`SELECT count(*) as count FROM objects`).first<{ count: number }>()
|
||||
assert.equal(row.count, 1)
|
||||
}
|
||||
|
||||
{
|
||||
const row = await db.prepare(`SELECT count(*) as count FROM idempotency_keys`).first<{ count: number }>()
|
||||
assert.equal(row.count, 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,16 +1,17 @@
|
|||
import { strict as assert } from 'node:assert/strict'
|
||||
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
|
||||
import { createReply } from 'wildebeest/backend/test/shared.utils'
|
||||
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
|
||||
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { createPublicNote, createPrivateNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { makeDB, assertCORS, assertJSON } from '../utils'
|
||||
import { makeDB, assertCORS, assertJSON, makeCache } from '../utils'
|
||||
import * as timelines_home from 'wildebeest/functions/api/v1/timelines/home'
|
||||
import * as timelines_public from 'wildebeest/functions/api/v1/timelines/public'
|
||||
import * as timelines from 'wildebeest/backend/src/mastodon/timeline'
|
||||
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
|
||||
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import { insertReblog, createReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import { createStatus } from 'wildebeest/backend/src/mastodon/status'
|
||||
|
||||
const userKEK = 'test_kek6'
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
@ -29,12 +30,11 @@ describe('Mastodon APIs', () => {
|
|||
await acceptFollowing(db, actor, actor2)
|
||||
|
||||
// Actor 2 is posting
|
||||
const firstNoteFromActor2 = await createPublicNote(domain, db, 'first status from actor2', actor2)
|
||||
await addObjectInOutbox(db, actor2, firstNoteFromActor2)
|
||||
const firstNoteFromActor2 = await createStatus(domain, db, actor2, 'first status from actor2')
|
||||
await sleep(10)
|
||||
await addObjectInOutbox(db, actor2, await createPublicNote(domain, db, 'second status from actor2', actor2))
|
||||
await createStatus(domain, db, actor2, 'second status from actor2')
|
||||
await sleep(10)
|
||||
await addObjectInOutbox(db, actor3, await createPublicNote(domain, db, 'first status from actor3', actor3))
|
||||
await createStatus(domain, db, actor3, 'first status from actor3')
|
||||
await sleep(10)
|
||||
|
||||
await insertLike(db, actor, firstNoteFromActor2)
|
||||
|
@ -53,12 +53,65 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(data[1].reblogs_count, 1)
|
||||
})
|
||||
|
||||
test("home doesn't show private Notes from followed actors", async () => {
|
||||
const db = await makeDB()
|
||||
const actor1 = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
const actor3 = await createPerson(domain, db, userKEK, 'sven3@cloudflare.com')
|
||||
|
||||
// actor3 follows actor1 and actor2
|
||||
await addFollowing(db, actor3, actor1, 'not needed')
|
||||
await acceptFollowing(db, actor3, actor1)
|
||||
await addFollowing(db, actor3, actor2, 'not needed')
|
||||
await acceptFollowing(db, actor3, actor2)
|
||||
|
||||
// actor2 sends a DM to actor1
|
||||
const note = await createPrivateNote(domain, db, 'DM', actor2, actor1)
|
||||
await addObjectInOutbox(db, actor2, note, undefined, actor1.id.toString())
|
||||
|
||||
// actor3 shouldn't see the private note
|
||||
const data = await timelines.getHomeTimeline(domain, db, actor3)
|
||||
assert.equal(data.length, 0)
|
||||
})
|
||||
|
||||
test("home returns Notes sent to Actor's followers", async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
|
||||
// Actor is following actor2
|
||||
await addFollowing(db, actor, actor2, 'not needed')
|
||||
await acceptFollowing(db, actor, actor2)
|
||||
|
||||
// Actor 2 is posting
|
||||
const note = await createPublicNote(domain, db, 'test post', actor2)
|
||||
await addObjectInOutbox(db, actor2, note, undefined, actor2.followers.toString())
|
||||
|
||||
// Actor should only see posts from actor2 in the timeline
|
||||
const data = await timelines.getHomeTimeline(domain, db, actor)
|
||||
assert.equal(data.length, 1)
|
||||
assert.equal(data[0].content, 'test post')
|
||||
})
|
||||
|
||||
test("public doesn't show private Notes", async () => {
|
||||
const db = await makeDB()
|
||||
const actor1 = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
|
||||
// actor2 sends a DM to actor1
|
||||
const note = await createPrivateNote(domain, db, 'DM', actor2, actor1)
|
||||
await addObjectInOutbox(db, actor2, note, undefined, actor1.id.toString())
|
||||
|
||||
const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet)
|
||||
assert.equal(data.length, 0)
|
||||
})
|
||||
|
||||
test('home returns Notes from ourself', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
// Actor is posting
|
||||
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'status from myself', actor))
|
||||
await createStatus(domain, db, actor, 'status from myself')
|
||||
|
||||
// Actor should only see posts from actor2 in the timeline
|
||||
const connectedActor = actor
|
||||
|
@ -72,27 +125,20 @@ describe('Mastodon APIs', () => {
|
|||
test('home returns cache', async () => {
|
||||
const db = await makeDB()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const kv_cache: any = {
|
||||
async get(key: string) {
|
||||
assert.equal(key, connectedActor.id + '/timeline/home')
|
||||
return 'cached data'
|
||||
},
|
||||
}
|
||||
const cache = makeCache()
|
||||
await cache.put(connectedActor.id + '/timeline/home', 12345)
|
||||
|
||||
const req = new Request('https://' + domain)
|
||||
const data = await timelines_home.handleRequest(req, kv_cache, connectedActor)
|
||||
assert.equal(await data.text(), 'cached data')
|
||||
const data = await timelines_home.handleRequest(req, cache, connectedActor)
|
||||
assert.equal(await data.json(), 12345)
|
||||
})
|
||||
|
||||
test('home returns empty if not in cache', async () => {
|
||||
const db = await makeDB()
|
||||
const connectedActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const kv_cache: any = {
|
||||
async get() {
|
||||
return null
|
||||
},
|
||||
}
|
||||
const cache = makeCache()
|
||||
const req = new Request('https://' + domain)
|
||||
const data = await timelines_home.handleRequest(req, kv_cache, connectedActor)
|
||||
const data = await timelines_home.handleRequest(req, cache, connectedActor)
|
||||
const posts = await data.json<Array<any>>()
|
||||
|
||||
assert.equal(posts.length, 0)
|
||||
|
@ -103,10 +149,9 @@ describe('Mastodon APIs', () => {
|
|||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com')
|
||||
|
||||
const statusFromActor = await createPublicNote(domain, db, 'status from actor', actor)
|
||||
await addObjectInOutbox(db, actor, statusFromActor)
|
||||
const statusFromActor = await createStatus(domain, db, actor, 'status from actor')
|
||||
await sleep(10)
|
||||
await addObjectInOutbox(db, actor2, await createPublicNote(domain, db, 'status from actor2', actor2))
|
||||
await createStatus(domain, db, actor2, 'status from actor2')
|
||||
|
||||
await insertLike(db, actor, statusFromActor)
|
||||
await insertReblog(db, actor, statusFromActor)
|
||||
|
@ -145,8 +190,7 @@ describe('Mastodon APIs', () => {
|
|||
|
||||
const properties = { url: 'https://example.com/image.jpg' }
|
||||
const mediaAttachments = [await createImage(domain, db, actor, properties)]
|
||||
const note = await createPublicNote(domain, db, 'status from actor', actor, mediaAttachments)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
await createStatus(domain, db, actor, 'status from actor', mediaAttachments)
|
||||
|
||||
const res = await timelines_public.handleRequest(domain, db)
|
||||
assert.equal(res.status, 200)
|
||||
|
@ -182,16 +226,11 @@ describe('Mastodon APIs', () => {
|
|||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'a post', actor)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
const note = await createStatus(domain, db, actor, 'a post')
|
||||
|
||||
await sleep(10)
|
||||
|
||||
const inReplyTo = note.id
|
||||
const reply = await createPublicNote(domain, db, 'a reply', actor, [], { inReplyTo })
|
||||
await addObjectInOutbox(db, actor, reply)
|
||||
await sleep(10)
|
||||
|
||||
await insertReply(db, actor, reply, note)
|
||||
await createReply(domain, db, actor, note, 'a reply')
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
||||
|
@ -214,8 +253,7 @@ describe('Mastodon APIs', () => {
|
|||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'a post', actor)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
const note = await createStatus(domain, db, actor, 'a post')
|
||||
await insertReblog(db, actor, note)
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
@ -229,8 +267,7 @@ describe('Mastodon APIs', () => {
|
|||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
|
||||
const note = await createPublicNote(domain, db, 'a post', actor)
|
||||
await addObjectInOutbox(db, actor, note)
|
||||
const note = await createStatus(domain, db, actor, 'a post')
|
||||
await insertLike(db, actor, note)
|
||||
|
||||
const connectedActor: any = actor
|
||||
|
@ -239,5 +276,23 @@ describe('Mastodon APIs', () => {
|
|||
assert.equal(data.length, 1)
|
||||
assert.equal(data[0].favourited, true)
|
||||
})
|
||||
|
||||
test('show unique Notes', async () => {
|
||||
const db = await makeDB()
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const actorA = await createPerson(domain, db, userKEK, 'svenA@cloudflare.com')
|
||||
const actorB = await createPerson(domain, db, userKEK, 'svenB@cloudflare.com')
|
||||
|
||||
// Actor posts
|
||||
const note = await createStatus(domain, db, actor, 'a post')
|
||||
|
||||
// ActorA and B reblog the post
|
||||
await createReblog(db, actorA, note)
|
||||
await createReblog(db, actorB, note)
|
||||
|
||||
const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet)
|
||||
assert.equal(data.length, 1)
|
||||
assert.equal(data[0].content, 'a post')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { strict as assert } from 'node:assert/strict'
|
||||
import * as nodeinfo_21 from 'wildebeest/functions/nodeinfo/2.1'
|
||||
import * as nodeinfo_20 from 'wildebeest/functions/nodeinfo/2.0'
|
||||
import * as nodeinfo from 'wildebeest/functions/.well-known/nodeinfo'
|
||||
|
||||
const domain = 'example.com'
|
||||
|
||||
describe('NodeInfo', () => {
|
||||
test('well-known returns links', async () => {
|
||||
const res = await nodeinfo.handleRequest(domain)
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.links.length, 2)
|
||||
})
|
||||
|
||||
test('expose NodeInfo version 2.0', async () => {
|
||||
const res = await nodeinfo_20.handleRequest()
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.version, '2.0')
|
||||
})
|
||||
|
||||
test('expose NodeInfo version 2.1', async () => {
|
||||
const res = await nodeinfo_21.handleRequest()
|
||||
assert.equal(res.status, 200)
|
||||
|
||||
const data = await res.json<any>()
|
||||
assert.equal(data.version, '2.1')
|
||||
})
|
||||
})
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* This file contains test utils that are also shared with the frontend code, these could not
|
||||
* be in the utils.ts file since it containing nodejs imports would cause the frontend to failing
|
||||
* building.
|
||||
*/
|
||||
|
||||
import type { Actor } from '../src/activitypub/actors'
|
||||
import { addObjectInOutbox } from '../src/activitypub/actors/outbox'
|
||||
import { type Note, createPublicNote } from '../src/activitypub/objects/note'
|
||||
import { insertReply } from '../src/mastodon/reply'
|
||||
|
||||
/**
|
||||
* Creates a reply and inserts it in the reply author's outbox
|
||||
*
|
||||
* @param domain the domain to use
|
||||
* @param db D1Database
|
||||
* @param actor Author of the reply
|
||||
* @param originalNote The original note
|
||||
* @param replyContent content of the reply
|
||||
*/
|
||||
export async function createReply(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
actor: Actor,
|
||||
originalNote: Note,
|
||||
replyContent: string
|
||||
) {
|
||||
const inReplyTo = originalNote.id
|
||||
const replyNote = await createPublicNote(domain, db, replyContent, actor, [], { inReplyTo })
|
||||
await addObjectInOutbox(db, actor, replyNote)
|
||||
await insertReply(db, actor, replyNote, originalNote)
|
||||
}
|
|
@ -40,9 +40,7 @@ describe('utils', () => {
|
|||
test('handle parsing', async () => {
|
||||
let res
|
||||
|
||||
res = parseHandle('')
|
||||
assert.equal(res.localPart, '')
|
||||
assert.equal(res.domain, null)
|
||||
assert.throws(() => parseHandle(''), { message: /invalid handle/ })
|
||||
|
||||
res = parseHandle('@a')
|
||||
assert.equal(res.localPart, 'a')
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
import { strict as assert } from 'node:assert/strict'
|
||||
import type { Cache } from 'wildebeest/backend/src/cache'
|
||||
import type { Queue } from 'wildebeest/backend/src/types/queue'
|
||||
import { createClient } from 'wildebeest/backend/src/mastodon/client'
|
||||
import type { Client } from 'wildebeest/backend/src/mastodon/client'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
import { BetaDatabase } from '@miniflare/d1'
|
||||
import * as Database from 'better-sqlite3'
|
||||
|
||||
|
@ -15,15 +18,19 @@ export function isUrlValid(s: string) {
|
|||
return url.protocol === 'https:'
|
||||
}
|
||||
|
||||
export async function makeDB(): Promise<any> {
|
||||
export async function makeDB(): Promise<D1Database> {
|
||||
const db = new Database(':memory:')
|
||||
const db2 = new BetaDatabase(db)!
|
||||
|
||||
// Manually run our migrations since @miniflare/d1 doesn't support it (yet).
|
||||
const initial = await fs.readFile('./migrations/0000_initial.sql', 'utf-8')
|
||||
await db.exec(initial)
|
||||
const migrations = await fs.readdir('./migrations/')
|
||||
|
||||
return db2
|
||||
for (let i = 0, len = migrations.length; i < len; i++) {
|
||||
const content = await fs.readFile(path.join('migrations', migrations[i]), 'utf-8')
|
||||
db.exec(content)
|
||||
}
|
||||
|
||||
return db2 as unknown as D1Database
|
||||
}
|
||||
|
||||
export function assertCORS(response: Response) {
|
||||
|
@ -64,3 +71,49 @@ export async function createTestClient(
|
|||
): Promise<Client> {
|
||||
return createClient(db, 'test client', redirectUri, 'https://cloudflare.com', scopes)
|
||||
}
|
||||
|
||||
type TestQueue = Queue<any> & { messages: Array<any> }
|
||||
|
||||
export function makeQueue(): TestQueue {
|
||||
const messages: Array<any> = []
|
||||
|
||||
return {
|
||||
messages,
|
||||
|
||||
async send(msg: any) {
|
||||
messages.push(msg)
|
||||
},
|
||||
|
||||
async sendBatch(batch: Array<{ body: any }>) {
|
||||
for (let i = 0, len = batch.length; i < len; i++) {
|
||||
messages.push(batch[i].body)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function makeCache(): Cache {
|
||||
const cache: any = {}
|
||||
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
if (cache[key]) {
|
||||
return cache[key] as T
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async put<T>(key: string, value: T): Promise<void> {
|
||||
cache[key] = value
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function isUUID(v: string): boolean {
|
||||
assert.equal(typeof v, 'string')
|
||||
if (v.match('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') === null) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
19
cfsetup.yaml
19
cfsetup.yaml
|
@ -1,19 +0,0 @@
|
|||
default-flavor: bullseye
|
||||
|
||||
template:
|
||||
build:
|
||||
builddeps: &builddeps
|
||||
nodejs:
|
||||
procps:
|
||||
post-cache:
|
||||
|
||||
bullseye:
|
||||
test:
|
||||
builddeps:
|
||||
<<: *builddeps
|
||||
post-cache:
|
||||
- yarn install
|
||||
- yarn pretty
|
||||
- yarn test
|
||||
# Until https://github.com/cloudflare/wrangler2/issues/2463 is resolved.
|
||||
# - yarn database:create-mock && yarn test:ui
|
|
@ -2,5 +2,5 @@ import type { DefaultImages } from '../backend/src/types/configs'
|
|||
|
||||
export const defaultImages: DefaultImages = {
|
||||
avatar: 'https://masto.ai/avatars/original/missing.png',
|
||||
header: 'https://arrifana.org/system/cache/accounts/headers/109/541/309/468/846/872/original/89ed71066eac95f7.png',
|
||||
header: 'https://imagedelivery.net/NkfPDviynOyTAOI79ar_GQ/b24caf12-5230-48c4-0bf7-2f40063bd400/header',
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { WILDEBEEST_VERSION, MASTODON_API_VERSION } from 'wildebeest/config/versions'
|
||||
|
||||
export function getFederationUA(domain: string): string {
|
||||
return `Wildebeest/${WILDEBEEST_VERSION} (Mastodon/${MASTODON_API_VERSION}; +${domain})`
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import * as packagejson from '../package.json'
|
||||
|
||||
// https://github.com/mastodon/mastodon/blob/main/CHANGELOG.md
|
||||
export const MASTODON_API_VERSION = '4.0.2'
|
||||
|
||||
export const WILDEBEEST_VERSION = packagejson.version
|
||||
|
||||
export function getVersion(): string {
|
||||
return `${MASTODON_API_VERSION} (compatible; Wildebeest ${WILDEBEEST_VERSION})`
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "consumer",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20221111.1",
|
||||
"toucan-js": "^3.1.0",
|
||||
"typescript": "^4.9.4",
|
||||
"wrangler": "2.7.1"
|
||||
},
|
||||
"private": true
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import type { DeliverMessageBody } from 'wildebeest/backend/src/types/queue'
|
||||
import { getSigningKey } from 'wildebeest/backend/src/mastodon/account'
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { Env } from './'
|
||||
import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver'
|
||||
|
||||
export async function handleDeliverMessage(env: Env, actor: Actor, message: DeliverMessageBody) {
|
||||
const toActorId = new URL(message.toActorId)
|
||||
const targetActor = await actors.getAndCache(toActorId, env.DATABASE)
|
||||
if (targetActor === null) {
|
||||
console.warn(`actor ${toActorId} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const signingKey = await getSigningKey(message.userKEK, env.DATABASE, actor)
|
||||
await deliverToActor(signingKey, actor, targetActor, message.activity, env.DOMAIN)
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import type { InboxMessageBody } from 'wildebeest/backend/src/types/queue'
|
||||
import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle'
|
||||
import * as notification from 'wildebeest/backend/src/mastodon/notification'
|
||||
import * as timeline from 'wildebeest/backend/src/mastodon/timeline'
|
||||
import { cacheFromEnv } from 'wildebeest/backend/src/cache'
|
||||
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import type { Env } from './'
|
||||
|
||||
export async function handleInboxMessage(env: Env, actor: Actor, message: InboxMessageBody) {
|
||||
const domain = env.DOMAIN
|
||||
const db = env.DATABASE
|
||||
const adminEmail = env.ADMIN_EMAIL
|
||||
const cache = cacheFromEnv(env)
|
||||
const activity = message.activity
|
||||
console.log(JSON.stringify(activity))
|
||||
|
||||
await activityHandler.handle(domain, activity, db, message.userKEK, adminEmail, message.vapidKeys)
|
||||
|
||||
// Assuming we received new posts or a like, pregenerate the user's timelines
|
||||
// and notifications.
|
||||
await Promise.all([
|
||||
timeline.pregenerateTimelines(domain, db, cache, actor),
|
||||
notification.pregenerateNotifications(db, cache, actor, domain),
|
||||
])
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import type { MessageBody, InboxMessageBody, DeliverMessageBody } from 'wildebeest/backend/src/types/queue'
|
||||
import * as actors from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { MessageType } from 'wildebeest/backend/src/types/queue'
|
||||
import { initSentryQueue } from './sentry'
|
||||
import { handleInboxMessage } from './inbox'
|
||||
import { handleDeliverMessage } from './deliver'
|
||||
|
||||
export type Env = {
|
||||
DATABASE: D1Database
|
||||
DOMAIN: string
|
||||
ADMIN_EMAIL: string
|
||||
DO_CACHE: DurableObjectNamespace
|
||||
|
||||
SENTRY_DSN: string
|
||||
SENTRY_ACCESS_CLIENT_ID: string
|
||||
SENTRY_ACCESS_CLIENT_SECRET: string
|
||||
}
|
||||
|
||||
export default {
|
||||
async queue(batch: MessageBatch<MessageBody>, env: Env, ctx: ExecutionContext) {
|
||||
const sentry = initSentryQueue(env, ctx)
|
||||
|
||||
try {
|
||||
for (const message of batch.messages) {
|
||||
const actor = await actors.getActorById(env.DATABASE, new URL(message.body.actorId))
|
||||
if (actor === null) {
|
||||
console.warn(`actor ${message.body.actorId} is missing`)
|
||||
return
|
||||
}
|
||||
|
||||
switch (message.body.type) {
|
||||
case MessageType.Inbox: {
|
||||
await handleInboxMessage(env, actor, message.body as InboxMessageBody)
|
||||
break
|
||||
}
|
||||
case MessageType.Deliver: {
|
||||
await handleDeliverMessage(env, actor, message.body as DeliverMessageBody)
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new Error('unsupported message type: ' + message.body.type)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (sentry !== null) {
|
||||
sentry.captureException(err)
|
||||
}
|
||||
console.error(err.stack, err.cause)
|
||||
}
|
||||
},
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
import { Toucan } from 'toucan-js'
|
||||
import type { Env } from './'
|
||||
|
||||
export function initSentryQueue(env: Env, context: any) {
|
||||
if (env.SENTRY_DSN === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const headers: any = {}
|
||||
|
||||
if (env.SENTRY_ACCESS_CLIENT_ID !== '' && env.SENTRY_ACCESS_CLIENT_SECRET !== '') {
|
||||
headers['CF-Access-Client-ID'] = env.SENTRY_ACCESS_CLIENT_ID
|
||||
headers['CF-Access-Client-Secret'] = env.SENTRY_ACCESS_CLIENT_SECRET
|
||||
}
|
||||
|
||||
const sentry = new Toucan({
|
||||
dsn: env.SENTRY_DSN,
|
||||
context,
|
||||
transportOptions: { headers },
|
||||
})
|
||||
|
||||
return sentry
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
import { MessageType } from 'wildebeest/backend/src/types/queue'
|
||||
import { strict as assert } from 'node:assert/strict'
|
||||
import type { DeliverMessageBody } from 'wildebeest/backend/src/types/queue'
|
||||
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import { makeDB } from 'wildebeest/backend/test/utils'
|
||||
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { handleDeliverMessage } from '../src/deliver'
|
||||
|
||||
const domain = 'cloudflare.com'
|
||||
const userKEK = 'test_kek25'
|
||||
|
||||
describe('Consumer', () => {
|
||||
describe('Deliver', () => {
|
||||
test('deliver to target Actor', async () => {
|
||||
const db = await makeDB()
|
||||
|
||||
let receivedActivity: any = null
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | Request) => {
|
||||
if (input.toString() === 'https://example.com/users/a') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'https://example.com/users/a',
|
||||
type: 'Person',
|
||||
preferredUsername: 'someone',
|
||||
inbox: 'https://example.com/inbox',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Make TypeScript happy
|
||||
input = input as Request
|
||||
|
||||
if (input.url.toString() === 'https://example.com/inbox') {
|
||||
assert(input.headers.get('accept')!.includes('json'))
|
||||
assert(input.headers.get('user-agent')!.includes('Wildebeest'))
|
||||
assert(input.headers.get('user-agent')!.includes(domain))
|
||||
assert.equal(input.method, 'POST')
|
||||
receivedActivity = await input.json()
|
||||
return new Response('')
|
||||
}
|
||||
|
||||
throw new Error('unexpected request to ' + input.url)
|
||||
}
|
||||
|
||||
const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
|
||||
const note = await createPublicNote(domain, db, 'my first status', actor)
|
||||
|
||||
const activity: any = {
|
||||
type: 'Create',
|
||||
actor: actor.id.toString(),
|
||||
to: ['https://example.com/users/a'],
|
||||
cc: [],
|
||||
object: note.id,
|
||||
}
|
||||
|
||||
const message: DeliverMessageBody = {
|
||||
activity,
|
||||
type: MessageType.Deliver,
|
||||
actorId: actor.id.toString(),
|
||||
toActorId: 'https://example.com/users/a',
|
||||
userKEK: userKEK,
|
||||
}
|
||||
|
||||
const env = {
|
||||
DATABASE: db,
|
||||
DOMAIN: domain,
|
||||
} as any
|
||||
await handleDeliverMessage(env, actor, message)
|
||||
|
||||
assert(receivedActivity)
|
||||
assert.equal(receivedActivity.type, activity.type)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,108 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
|
||||
/* Modules */
|
||||
"module": "es2022" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
"paths": {
|
||||
"wildebeest/*": ["../*"]
|
||||
},
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
"types": [
|
||||
"@cloudflare/workers-types",
|
||||
"jest"
|
||||
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
"resolveJsonModule": true /* Enable importing .json files */,
|
||||
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
|
||||
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
"noEmit": true /* Disable emitting files from a compilation. */,
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
|
||||
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
compatibility_date = "2023-01-09"
|
||||
main = "./src/index.ts"
|
||||
usage_model = "unbound"
|
|
@ -0,0 +1,881 @@
|
|||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@cloudflare/kv-asset-handler@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.2.0.tgz#c9959bbd7a1c40bd7c674adae98aa8c8d0e5ca68"
|
||||
integrity sha512-MVbXLbTcAotOPUj0pAMhVtJ+3/kFkwJqc5qNOleOZTv6QkZZABDMS21dSrSlVswEHwrpWC03e4fWytjqKvuE2A==
|
||||
dependencies:
|
||||
mime "^3.0.0"
|
||||
|
||||
"@cloudflare/workers-types@^4.20221111.1":
|
||||
version "4.20230115.0"
|
||||
resolved "https://registry.yarnpkg.com/@cloudflare/workers-types/-/workers-types-4.20230115.0.tgz#84e6c7b1a753098dbc7cd718fdcf191c215225af"
|
||||
integrity sha512-GPJEiO8AFN+jUpA+DHJ1qdVmk4s/hq8JYKjOV/+U7avGquQbVnj905+Kg6uAEfrq16muwmRKl+XJGqsvlBlDNg==
|
||||
|
||||
"@esbuild-plugins/node-globals-polyfill@^0.1.1":
|
||||
version "0.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.1.1.tgz#a313ab3efbb2c17c8ce376aa216c627c9b40f9d7"
|
||||
integrity sha512-MR0oAA+mlnJWrt1RQVQ+4VYuRJW/P2YmRTv1AsplObyvuBMnPHiizUF95HHYiSsMGLhyGtWufaq2XQg6+iurBg==
|
||||
|
||||
"@esbuild-plugins/node-modules-polyfill@^0.1.4":
|
||||
version "0.1.4"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild-plugins/node-modules-polyfill/-/node-modules-polyfill-0.1.4.tgz#eb2f55da11967b2986c913f1a7957d1c868849c0"
|
||||
integrity sha512-uZbcXi0zbmKC/050p3gJnne5Qdzw8vkXIv+c2BW0Lsc1ji1SkrxbKPUy5Efr0blbTu1SL8w4eyfpnSdPg3G0Qg==
|
||||
dependencies:
|
||||
escape-string-regexp "^4.0.0"
|
||||
rollup-plugin-node-polyfills "^0.2.1"
|
||||
|
||||
"@iarna/toml@^2.2.5":
|
||||
version "2.2.5"
|
||||
resolved "https://registry.yarnpkg.com/@iarna/toml/-/toml-2.2.5.tgz#b32366c89b43c6f8cefbdefac778b9c828e3ba8c"
|
||||
integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==
|
||||
|
||||
"@miniflare/cache@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/cache/-/cache-2.11.0.tgz#e13f4a860ebe01fc67f0169e76be93350c29d7ff"
|
||||
integrity sha512-L/kc9AzidPwFuk2fwHpAEePi0kNBk6FWUq3ln+9beRCDrPEpfVrDRFpNleF1NFZz5//oeVMuo8F0IVUQGzR7+Q==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
http-cache-semantics "^4.1.0"
|
||||
undici "5.9.1"
|
||||
|
||||
"@miniflare/cli-parser@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/cli-parser/-/cli-parser-2.11.0.tgz#47f517731791c9e652e9849d590fde3235737529"
|
||||
integrity sha512-JUmyRzEGAS6CouvXJwBh8p44onfw3KRpfq5JGXEuHModOGjTp6li7PQyCTNPV2Hv/7StAXWnTFGXeAqyDHuTig==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
kleur "^4.1.4"
|
||||
|
||||
"@miniflare/core@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/core/-/core-2.11.0.tgz#68efb7c9bab0d56bdf284b704089b035cd0b1a28"
|
||||
integrity sha512-UFMFiCG0co36VpZkgFrSBnrxo71uf1x+cjlzzJi3khmMyDlnLu4RuIQsAqvKbYom6fi3G9Q8lTgM7JuOXFyjhw==
|
||||
dependencies:
|
||||
"@iarna/toml" "^2.2.5"
|
||||
"@miniflare/queues" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
"@miniflare/watcher" "2.11.0"
|
||||
busboy "^1.6.0"
|
||||
dotenv "^10.0.0"
|
||||
kleur "^4.1.4"
|
||||
set-cookie-parser "^2.4.8"
|
||||
undici "5.9.1"
|
||||
urlpattern-polyfill "^4.0.3"
|
||||
|
||||
"@miniflare/d1@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/d1/-/d1-2.11.0.tgz#1c340abe1c50cce27432b100d78b345b45e60b10"
|
||||
integrity sha512-aDdBVQZ2C0Zs3+Y9ZbRctmuQxozPfpumwJ/6NG6fBadANvune/hW7ddEoxyteIEU9W3IgzVj8s4by4VvasX90A==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
|
||||
"@miniflare/durable-objects@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/durable-objects/-/durable-objects-2.11.0.tgz#4c53c27a939e022a1de47ee338cec395cbb9e24d"
|
||||
integrity sha512-0cKJaMgraTEU1b4kqK8cjD2oTeOjA6QU3Y+lWiZT/k1PMHZULovrSFnjii7qZ8npf4VHSIN6XYPxhyxRyEM65Q==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
"@miniflare/storage-memory" "2.11.0"
|
||||
undici "5.9.1"
|
||||
|
||||
"@miniflare/html-rewriter@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/html-rewriter/-/html-rewriter-2.11.0.tgz#5e5e1292876feca3002b3a729dd8f892f7ef0d0c"
|
||||
integrity sha512-olTqmuYTHnoTNtiA0vjQ/ixRfbwgPzDrAUbtXDCYW45VFbHfDVJrJGZX3Jg0HpSlxy86Zclle1SUxGbVDzxsBg==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
html-rewriter-wasm "^0.4.1"
|
||||
undici "5.9.1"
|
||||
|
||||
"@miniflare/http-server@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/http-server/-/http-server-2.11.0.tgz#76d2e2c6549528d965e5f48a8ddc3448c28d4569"
|
||||
integrity sha512-sMLcrDFzqqAvnQmAUH0hRTo8sBjW79VZYfnIH5FAGSGcKX6kdAGs9RStdYZ4CftQCBAEQScX0KBsMx5FwJRe9Q==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
"@miniflare/web-sockets" "2.11.0"
|
||||
kleur "^4.1.4"
|
||||
selfsigned "^2.0.0"
|
||||
undici "5.9.1"
|
||||
ws "^8.2.2"
|
||||
youch "^2.2.2"
|
||||
|
||||
"@miniflare/kv@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/kv/-/kv-2.11.0.tgz#af162567e2d49ae533be60bca29eaf9486408a68"
|
||||
integrity sha512-3m9dL2HBBN170V1JvwjjucR5zl4G3mlcsV6C1E7A2wLl2Z2TWvIx/tSY9hrhkD96dFnejwJ9qmPMbXMMuynhjg==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
|
||||
"@miniflare/queues@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/queues/-/queues-2.11.0.tgz#feb48d1934b4f98d9bc605d0967140f8e70cc5be"
|
||||
integrity sha512-fLHjdrNLKhn0LZM/aii/9GsAttFd+lWlGzK8HOg1R0vhfKBwEub4zntjMmOfFbDm1ntc21tdMK7n3ldUphwh5w==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
|
||||
"@miniflare/r2@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/r2/-/r2-2.11.0.tgz#52f19d22b63b4d5e72d2b96ee514333a810dce7a"
|
||||
integrity sha512-MKuyJ/gGNsK3eWbGdygvozqcyaZhM3C6NGHvoaZwH503dwN569j5DpatTWiHGFeDeSu64VqcIsGehz05GDUaag==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
undici "5.9.1"
|
||||
|
||||
"@miniflare/runner-vm@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/runner-vm/-/runner-vm-2.11.0.tgz#801c16ddbd360c3c8fcca84c43faaecfd2d4ef70"
|
||||
integrity sha512-bkVSuvCf5+VylqN8lTiLxIYqYcKFbl+BywZGwGQndPC/3wh42J00mM0jw4hRbvXgwuBhlUyCVpEXtYlftFFT/g==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
|
||||
"@miniflare/scheduler@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/scheduler/-/scheduler-2.11.0.tgz#2568d44f571e73355369be6a6da4481aa4af25c8"
|
||||
integrity sha512-DPdzINhdWeS99eIicGoluMsD4pLTTAWNQbgCv3CTwgdKA3dxdvMSCkNqZzQLiALzvk9+rSfj46FlH++HE7o7/w==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
cron-schedule "^3.0.4"
|
||||
|
||||
"@miniflare/shared@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/shared/-/shared-2.11.0.tgz#12905f4b4310bdcc28169667d024ca6fab35a035"
|
||||
integrity sha512-fWMqq3ZkWAg+k7CnyzMV/rZHugwn+/JxvVzCxrtvxzwotTN547THlOxgZe8JAP23U9BiTxOfpTfnLvFEjAmegw==
|
||||
dependencies:
|
||||
"@types/better-sqlite3" "^7.6.0"
|
||||
kleur "^4.1.4"
|
||||
npx-import "^1.1.3"
|
||||
picomatch "^2.3.1"
|
||||
|
||||
"@miniflare/sites@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/sites/-/sites-2.11.0.tgz#f7849ed6cc13fd3a96a329b815828ed5e4f22df3"
|
||||
integrity sha512-qbefKdWZUJgsdLf+kCw03sn3h/92LZgJAbkOpP6bCrfWkXlJ37EQXO4KWdhn4Ghc7A6GwU1s1I/mdB64B3AewQ==
|
||||
dependencies:
|
||||
"@miniflare/kv" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
"@miniflare/storage-file" "2.11.0"
|
||||
|
||||
"@miniflare/storage-file@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/storage-file/-/storage-file-2.11.0.tgz#eaa30899d6a369f9a0dca32859ff1b36db1f0bac"
|
||||
integrity sha512-beWF/lTX74x7AiaSB+xQxywPSNdhtEKvqDkRui8eOJ5kqN2o4UaleLKQGgqmCw3WyHRIsckV7If1qpbNiLtWMw==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
"@miniflare/storage-memory" "2.11.0"
|
||||
|
||||
"@miniflare/storage-memory@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/storage-memory/-/storage-memory-2.11.0.tgz#24b6ba299435a96dbe8929308c49cdd2346d2d25"
|
||||
integrity sha512-s0AhPww7fq/Jz80NbPb+ffhcVRKnfPi7H1dHTRTre2Ud23EVJjAWl2gat42x8NOT/Fu3/o/7A72DWQQJqfO98A==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
|
||||
"@miniflare/watcher@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/watcher/-/watcher-2.11.0.tgz#4cfe96ed8131118de31287d7b2690758925f4505"
|
||||
integrity sha512-RUfjz2iYcsQXLcGySemJl98CJ2iierbWsPGWZhIVZI+NNhROkEy77g/Q+lvP2ATwexG3/dUSfdJ3P8aH+sI4Ig==
|
||||
dependencies:
|
||||
"@miniflare/shared" "2.11.0"
|
||||
|
||||
"@miniflare/web-sockets@2.11.0":
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/@miniflare/web-sockets/-/web-sockets-2.11.0.tgz#1d4ef353c618a971c882efcc33ed37df9fa01af1"
|
||||
integrity sha512-NC8RKrmxrO0hZmwpzn5g4hPGA2VblnFTIBobmWoxuK95eW49zfs7dtE/PyFs+blsGv3CjTIjHVSQ782K+C6HFA==
|
||||
dependencies:
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
undici "5.9.1"
|
||||
ws "^8.2.2"
|
||||
|
||||
"@sentry/core@7.28.1":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.28.1.tgz#c712ce17469b18b01606108817be24a99ed2116e"
|
||||
integrity sha512-7wvnuvn/mrAfcugWoCG/3pqDIrUgH5t+HisMJMGw0h9Tc33KqrmqMDCQVvjlrr2pWrw/vuUCFdm8CbUHJ832oQ==
|
||||
dependencies:
|
||||
"@sentry/types" "7.28.1"
|
||||
"@sentry/utils" "7.28.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/types@7.28.1":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.28.1.tgz#9018b4c152b475de9bedd267237393d3c9b1253d"
|
||||
integrity sha512-DvSplMVrVEmOzR2M161V5+B8Up3vR71xMqJOpWTzE9TqtFJRGPtqT/5OBsNJJw1+/j2ssMcnKwbEo9Q2EGeS6g==
|
||||
|
||||
"@sentry/utils@7.28.1":
|
||||
version "7.28.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.28.1.tgz#0a7b6aa4b09e91e4d1aded2a8c8dbaf818cee96e"
|
||||
integrity sha512-75/jzLUO9HH09iC9TslNimGbxOP3jgn89P+q7uR+rp2fJfRExHVeKJZQdK0Ij4/SmE7TJ3Uh2r154N0INZEx1g==
|
||||
dependencies:
|
||||
"@sentry/types" "7.28.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@types/better-sqlite3@^7.6.0":
|
||||
version "7.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/better-sqlite3/-/better-sqlite3-7.6.3.tgz#117c3c182e300799b84d1b7e1781c27d8d536505"
|
||||
integrity sha512-YS64N9SNDT/NAvou3QNdzAu3E2om/W/0dhORimtPGLef+zSK5l1vDzfsWb4xgXOgfhtOI5ZDTRxnvRPb22AIVQ==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/node@*":
|
||||
version "18.13.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-18.13.0.tgz#0400d1e6ce87e9d3032c19eb6c58205b0d3f7850"
|
||||
integrity sha512-gC3TazRzGoOnoKAhUx+Q0t8S9Tzs74z7m0ipwGpSqQrleP14hKxP4/JUeEQcD3W1/aIpnWl8pHowI7WokuZpXg==
|
||||
|
||||
"@types/stack-trace@0.0.29":
|
||||
version "0.0.29"
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-trace/-/stack-trace-0.0.29.tgz#eb7a7c60098edb35630ed900742a5ecb20cfcb4d"
|
||||
integrity sha512-TgfOX+mGY/NyNxJLIbDWrO9DjGoVSW9+aB8H2yy1fy32jsvxijhmyJI9fDFgvz3YP4lvJaq9DzdR/M1bOgVc9g==
|
||||
|
||||
anymatch@~3.1.2:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
|
||||
integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
|
||||
dependencies:
|
||||
normalize-path "^3.0.0"
|
||||
picomatch "^2.0.4"
|
||||
|
||||
binary-extensions@^2.0.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
|
||||
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
|
||||
|
||||
blake3-wasm@^2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.yarnpkg.com/blake3-wasm/-/blake3-wasm-2.1.5.tgz#b22dbb84bc9419ed0159caa76af4b1b132e6ba52"
|
||||
integrity sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==
|
||||
|
||||
braces@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
|
||||
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
|
||||
dependencies:
|
||||
fill-range "^7.0.1"
|
||||
|
||||
buffer-from@^1.0.0:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
|
||||
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
|
||||
|
||||
builtins@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/builtins/-/builtins-5.0.1.tgz#87f6db9ab0458be728564fa81d876d8d74552fa9"
|
||||
integrity sha512-qwVpFEHNfhYJIzNRBvd2C1kyo6jz3ZSMPyyuR47OPdiKWlbYnZNyDWuyR175qDnAJLiCo5fBBqPb3RiXgWlkOQ==
|
||||
dependencies:
|
||||
semver "^7.0.0"
|
||||
|
||||
busboy@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893"
|
||||
integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==
|
||||
dependencies:
|
||||
streamsearch "^1.1.0"
|
||||
|
||||
chokidar@^3.5.3:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
|
||||
integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
|
||||
dependencies:
|
||||
anymatch "~3.1.2"
|
||||
braces "~3.0.2"
|
||||
glob-parent "~5.1.2"
|
||||
is-binary-path "~2.1.0"
|
||||
is-glob "~4.0.1"
|
||||
normalize-path "~3.0.0"
|
||||
readdirp "~3.6.0"
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
cookie@^0.4.1:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
|
||||
integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
|
||||
|
||||
cron-schedule@^3.0.4:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/cron-schedule/-/cron-schedule-3.0.6.tgz#7d0a3ad9154112fc3720fe43238a43d50e8465e7"
|
||||
integrity sha512-izfGgKyzzIyLaeb1EtZ3KbglkS6AKp9cv7LxmiyoOu+fXfol1tQDC0Cof0enVZGNtudTHW+3lfuW9ZkLQss4Wg==
|
||||
|
||||
cross-spawn@^7.0.3:
|
||||
version "7.0.3"
|
||||
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
|
||||
integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
|
||||
dependencies:
|
||||
path-key "^3.1.0"
|
||||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
dotenv@^10.0.0:
|
||||
version "10.0.0"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-10.0.0.tgz#3d4227b8fb95f81096cdd2b66653fb2c7085ba81"
|
||||
integrity sha512-rlBi9d8jpv9Sf1klPjNfFAuWDjKLwTIJJ/VxtoTwIR6hnZxcEOQCZg2oIL3MWBYw5GpUDKOEnND7LXTbIpQ03Q==
|
||||
|
||||
esbuild-android-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.51.tgz#414a087cb0de8db1e347ecca6c8320513de433db"
|
||||
integrity sha512-6FOuKTHnC86dtrKDmdSj2CkcKF8PnqkaIXqvgydqfJmqBazCPdw+relrMlhGjkvVdiiGV70rpdnyFmA65ekBCQ==
|
||||
|
||||
esbuild-android-arm64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.51.tgz#55de3bce2aab72bcd2b606da4318ad00fb9c8151"
|
||||
integrity sha512-vBtp//5VVkZWmYYvHsqBRCMMi1MzKuMIn5XDScmnykMTu9+TD9v0NMEDqQxvtFToeYmojdo5UCV2vzMQWJcJ4A==
|
||||
|
||||
esbuild-darwin-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.51.tgz#4259f23ed6b4cea2ec8a28d87b7fb9801f093754"
|
||||
integrity sha512-YFmXPIOvuagDcwCejMRtCDjgPfnDu+bNeh5FU2Ryi68ADDVlWEpbtpAbrtf/lvFTWPexbgyKgzppNgsmLPr8PA==
|
||||
|
||||
esbuild-darwin-arm64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.51.tgz#d77b4366a71d84e530ba019d540b538b295d494a"
|
||||
integrity sha512-juYD0QnSKwAMfzwKdIF6YbueXzS6N7y4GXPDeDkApz/1RzlT42mvX9jgNmyOlWKN7YzQAYbcUEJmZJYQGdf2ow==
|
||||
|
||||
esbuild-freebsd-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.51.tgz#27b6587b3639f10519c65e07219d249b01f2ad38"
|
||||
integrity sha512-cLEI/aXjb6vo5O2Y8rvVSQ7smgLldwYY5xMxqh/dQGfWO+R1NJOFsiax3IS4Ng300SVp7Gz3czxT6d6qf2cw0g==
|
||||
|
||||
esbuild-freebsd-arm64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.51.tgz#63c435917e566808c71fafddc600aca4d78be1ec"
|
||||
integrity sha512-TcWVw/rCL2F+jUgRkgLa3qltd5gzKjIMGhkVybkjk6PJadYInPtgtUBp1/hG+mxyigaT7ib+od1Xb84b+L+1Mg==
|
||||
|
||||
esbuild-linux-32@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.51.tgz#c3da774143a37e7f11559b9369d98f11f997a5d9"
|
||||
integrity sha512-RFqpyC5ChyWrjx8Xj2K0EC1aN0A37H6OJfmUXIASEqJoHcntuV3j2Efr9RNmUhMfNE6yEj2VpYuDteZLGDMr0w==
|
||||
|
||||
esbuild-linux-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.51.tgz#5d92b67f674e02ae0b4a9de9a757ba482115c4ae"
|
||||
integrity sha512-dxjhrqo5i7Rq6DXwz5v+MEHVs9VNFItJmHBe1CxROWNf4miOGoQhqSG8StStbDkQ1Mtobg6ng+4fwByOhoQoeA==
|
||||
|
||||
esbuild-linux-arm64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.51.tgz#dac84740516e859d8b14e1ecc478dd5241b10c93"
|
||||
integrity sha512-D9rFxGutoqQX3xJPxqd6o+kvYKeIbM0ifW2y0bgKk5HPgQQOo2k9/2Vpto3ybGYaFPCE5qTGtqQta9PoP6ZEzw==
|
||||
|
||||
esbuild-linux-arm@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.51.tgz#b3ae7000696cd53ed95b2b458554ff543a60e106"
|
||||
integrity sha512-LsJynDxYF6Neg7ZC7748yweCDD+N8ByCv22/7IAZglIEniEkqdF4HCaa49JNDLw1UQGlYuhOB8ZT/MmcSWzcWg==
|
||||
|
||||
esbuild-linux-mips64le@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.51.tgz#dad10770fac94efa092b5a0643821c955a9dd385"
|
||||
integrity sha512-vS54wQjy4IinLSlb5EIlLoln8buh1yDgliP4CuEHumrPk4PvvP4kTRIG4SzMXm6t19N0rIfT4bNdAxzJLg2k6A==
|
||||
|
||||
esbuild-linux-ppc64le@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.51.tgz#b68c2f8294d012a16a88073d67e976edd4850ae0"
|
||||
integrity sha512-xcdd62Y3VfGoyphNP/aIV9LP+RzFw5M5Z7ja+zdpQHHvokJM7d0rlDRMN+iSSwvUymQkqZO+G/xjb4/75du8BQ==
|
||||
|
||||
esbuild-linux-riscv64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.51.tgz#608a318b8697123e44c1e185cdf6708e3df50b93"
|
||||
integrity sha512-syXHGak9wkAnFz0gMmRBoy44JV0rp4kVCEA36P5MCeZcxFq8+fllBC2t6sKI23w3qd8Vwo9pTADCgjTSf3L3rA==
|
||||
|
||||
esbuild-linux-s390x@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.51.tgz#c9e7791170a3295dba79b93aa452beb9838a8625"
|
||||
integrity sha512-kFAJY3dv+Wq8o28K/C7xkZk/X34rgTwhknSsElIqoEo8armCOjMJ6NsMxm48KaWY2h2RUYGtQmr+RGuUPKBhyw==
|
||||
|
||||
esbuild-netbsd-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.51.tgz#0abd40b8c2e37fda6f5cc41a04cb2b690823d891"
|
||||
integrity sha512-ZZBI7qrR1FevdPBVHz/1GSk1x5GDL/iy42Zy8+neEm/HA7ma+hH/bwPEjeHXKWUDvM36CZpSL/fn1/y9/Hb+1A==
|
||||
|
||||
esbuild-openbsd-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.51.tgz#4adba0b7ea7eb1428bb00d8e94c199a949b130e8"
|
||||
integrity sha512-7R1/p39M+LSVQVgDVlcY1KKm6kFKjERSX1lipMG51NPcspJD1tmiZSmmBXoY5jhHIu6JL1QkFDTx94gMYK6vfA==
|
||||
|
||||
esbuild-sunos-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.51.tgz#4b8a6d97dfedda30a6e39607393c5c90ebf63891"
|
||||
integrity sha512-HoHaCswHxLEYN8eBTtyO0bFEWvA3Kdb++hSQ/lLG7TyKF69TeSG0RNoBRAs45x/oCeWaTDntEZlYwAfQlhEtJA==
|
||||
|
||||
esbuild-windows-32@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.51.tgz#d31d8ca0c1d314fb1edea163685a423b62e9ac17"
|
||||
integrity sha512-4rtwSAM35A07CBt1/X8RWieDj3ZUHQqUOaEo5ZBs69rt5WAFjP4aqCIobdqOy4FdhYw1yF8Z0xFBTyc9lgPtEg==
|
||||
|
||||
esbuild-windows-64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.51.tgz#7d3c09c8652d222925625637bdc7e6c223e0085d"
|
||||
integrity sha512-HoN/5HGRXJpWODprGCgKbdMvrC3A2gqvzewu2eECRw2sYxOUoh2TV1tS+G7bHNapPGI79woQJGV6pFH7GH7qnA==
|
||||
|
||||
esbuild-windows-arm64@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.51.tgz#0220d2304bfdc11bc27e19b2aaf56edf183e4ae9"
|
||||
integrity sha512-JQDqPjuOH7o+BsKMSddMfmVJXrnYZxXDHsoLHc0xgmAZkOOCflRmC43q31pk79F9xuyWY45jDBPolb5ZgGOf9g==
|
||||
|
||||
esbuild@0.14.51:
|
||||
version "0.14.51"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.51.tgz#1c8ecbc8db3710da03776211dc3ee3448f7aa51e"
|
||||
integrity sha512-+CvnDitD7Q5sT7F+FM65sWkF8wJRf+j9fPcprxYV4j+ohmzVj2W7caUqH2s5kCaCJAfcAICjSlKhDCcvDpU7nw==
|
||||
optionalDependencies:
|
||||
esbuild-android-64 "0.14.51"
|
||||
esbuild-android-arm64 "0.14.51"
|
||||
esbuild-darwin-64 "0.14.51"
|
||||
esbuild-darwin-arm64 "0.14.51"
|
||||
esbuild-freebsd-64 "0.14.51"
|
||||
esbuild-freebsd-arm64 "0.14.51"
|
||||
esbuild-linux-32 "0.14.51"
|
||||
esbuild-linux-64 "0.14.51"
|
||||
esbuild-linux-arm "0.14.51"
|
||||
esbuild-linux-arm64 "0.14.51"
|
||||
esbuild-linux-mips64le "0.14.51"
|
||||
esbuild-linux-ppc64le "0.14.51"
|
||||
esbuild-linux-riscv64 "0.14.51"
|
||||
esbuild-linux-s390x "0.14.51"
|
||||
esbuild-netbsd-64 "0.14.51"
|
||||
esbuild-openbsd-64 "0.14.51"
|
||||
esbuild-sunos-64 "0.14.51"
|
||||
esbuild-windows-32 "0.14.51"
|
||||
esbuild-windows-64 "0.14.51"
|
||||
esbuild-windows-arm64 "0.14.51"
|
||||
|
||||
escape-string-regexp@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
|
||||
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
|
||||
|
||||
estree-walker@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-0.6.1.tgz#53049143f40c6eb918b23671d1fe3219f3a1b362"
|
||||
integrity sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==
|
||||
|
||||
execa@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/execa/-/execa-6.1.0.tgz#cea16dee211ff011246556388effa0818394fb20"
|
||||
integrity sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA==
|
||||
dependencies:
|
||||
cross-spawn "^7.0.3"
|
||||
get-stream "^6.0.1"
|
||||
human-signals "^3.0.1"
|
||||
is-stream "^3.0.0"
|
||||
merge-stream "^2.0.0"
|
||||
npm-run-path "^5.1.0"
|
||||
onetime "^6.0.0"
|
||||
signal-exit "^3.0.7"
|
||||
strip-final-newline "^3.0.0"
|
||||
|
||||
fill-range@^7.0.1:
|
||||
version "7.0.1"
|
||||
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
|
||||
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
|
||||
dependencies:
|
||||
to-regex-range "^5.0.1"
|
||||
|
||||
fsevents@~2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
|
||||
get-stream@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
|
||||
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
|
||||
|
||||
glob-parent@~5.1.2:
|
||||
version "5.1.2"
|
||||
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
|
||||
integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
|
||||
dependencies:
|
||||
is-glob "^4.0.1"
|
||||
|
||||
html-rewriter-wasm@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/html-rewriter-wasm/-/html-rewriter-wasm-0.4.1.tgz#235e3d96c1aa4bfd2182661ee13881e290ff5ff2"
|
||||
integrity sha512-lNovG8CMCCmcVB1Q7xggMSf7tqPCijZXaH4gL6iE8BFghdQCbaY5Met9i1x2Ex8m/cZHDUtXK9H6/znKamRP8Q==
|
||||
|
||||
http-cache-semantics@^4.1.0:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a"
|
||||
integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==
|
||||
|
||||
human-signals@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5"
|
||||
integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==
|
||||
|
||||
is-binary-path@~2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
|
||||
integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
|
||||
dependencies:
|
||||
binary-extensions "^2.0.0"
|
||||
|
||||
is-extglob@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
|
||||
integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
|
||||
|
||||
is-glob@^4.0.1, is-glob@~4.0.1:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
|
||||
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
|
||||
dependencies:
|
||||
is-extglob "^2.1.1"
|
||||
|
||||
is-number@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
|
||||
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
|
||||
|
||||
is-stream@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-3.0.0.tgz#e6bfd7aa6bef69f4f472ce9bb681e3e57b4319ac"
|
||||
integrity sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
|
||||
|
||||
kleur@^4.1.4:
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
|
||||
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
|
||||
|
||||
lru-cache@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
|
||||
integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
|
||||
dependencies:
|
||||
yallist "^4.0.0"
|
||||
|
||||
magic-string@^0.25.3:
|
||||
version "0.25.9"
|
||||
resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.9.tgz#de7f9faf91ef8a1c91d02c2e5314c8277dbcdd1c"
|
||||
integrity sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==
|
||||
dependencies:
|
||||
sourcemap-codec "^1.4.8"
|
||||
|
||||
merge-stream@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
|
||||
integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
|
||||
|
||||
mime@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mime/-/mime-3.0.0.tgz#b374550dca3a0c18443b0c950a6a58f1931cf7a7"
|
||||
integrity sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==
|
||||
|
||||
mimic-fn@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-4.0.0.tgz#60a90550d5cb0b239cca65d893b1a53b29871ecc"
|
||||
integrity sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==
|
||||
|
||||
miniflare@2.11.0:
|
||||
version "2.11.0"
|
||||
resolved "https://registry.yarnpkg.com/miniflare/-/miniflare-2.11.0.tgz#36c575e1e75451c416f136d188b744896becc352"
|
||||
integrity sha512-QA18I1VQXdCo4nBtPJUcUDxW8c9xbc5ex5F61jwhkGVOISSnYdEheolESmjr8MYk28xwi0XD1ozS4rLaTONd+w==
|
||||
dependencies:
|
||||
"@miniflare/cache" "2.11.0"
|
||||
"@miniflare/cli-parser" "2.11.0"
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/d1" "2.11.0"
|
||||
"@miniflare/durable-objects" "2.11.0"
|
||||
"@miniflare/html-rewriter" "2.11.0"
|
||||
"@miniflare/http-server" "2.11.0"
|
||||
"@miniflare/kv" "2.11.0"
|
||||
"@miniflare/queues" "2.11.0"
|
||||
"@miniflare/r2" "2.11.0"
|
||||
"@miniflare/runner-vm" "2.11.0"
|
||||
"@miniflare/scheduler" "2.11.0"
|
||||
"@miniflare/shared" "2.11.0"
|
||||
"@miniflare/sites" "2.11.0"
|
||||
"@miniflare/storage-file" "2.11.0"
|
||||
"@miniflare/storage-memory" "2.11.0"
|
||||
"@miniflare/web-sockets" "2.11.0"
|
||||
kleur "^4.1.4"
|
||||
semiver "^1.1.0"
|
||||
source-map-support "^0.5.20"
|
||||
undici "5.9.1"
|
||||
|
||||
mustache@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64"
|
||||
integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==
|
||||
|
||||
nanoid@^3.3.3:
|
||||
version "3.3.4"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
|
||||
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
|
||||
|
||||
node-forge@^1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
|
||||
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
|
||||
|
||||
normalize-path@^3.0.0, normalize-path@~3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
|
||||
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
|
||||
|
||||
npm-run-path@^5.1.0:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-5.1.0.tgz#bc62f7f3f6952d9894bd08944ba011a6ee7b7e00"
|
||||
integrity sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==
|
||||
dependencies:
|
||||
path-key "^4.0.0"
|
||||
|
||||
npx-import@^1.1.3:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/npx-import/-/npx-import-1.1.4.tgz#0ee9a27484c633255528f7ec2e4c2adeaa1fcda3"
|
||||
integrity sha512-3ShymTWOgqGyNlh5lMJAejLuIv3W1K3fbI5Ewc6YErZU3Sp0PqsNs8UIU1O8z5+KVl/Du5ag56Gza9vdorGEoA==
|
||||
dependencies:
|
||||
execa "^6.1.0"
|
||||
parse-package-name "^1.0.0"
|
||||
semver "^7.3.7"
|
||||
validate-npm-package-name "^4.0.0"
|
||||
|
||||
onetime@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/onetime/-/onetime-6.0.0.tgz#7c24c18ed1fd2e9bca4bd26806a33613c77d34b4"
|
||||
integrity sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==
|
||||
dependencies:
|
||||
mimic-fn "^4.0.0"
|
||||
|
||||
parse-package-name@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/parse-package-name/-/parse-package-name-1.0.0.tgz#1a108757e4ffc6889d5e78bcc4932a97c097a5a7"
|
||||
integrity sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==
|
||||
|
||||
path-key@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||
|
||||
path-key@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-4.0.0.tgz#295588dc3aee64154f877adb9d780b81c554bf18"
|
||||
integrity sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==
|
||||
|
||||
path-to-regexp@^6.2.0:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5"
|
||||
integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==
|
||||
|
||||
picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
|
||||
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
|
||||
|
||||
readdirp@~3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
|
||||
integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
|
||||
dependencies:
|
||||
picomatch "^2.2.1"
|
||||
|
||||
rollup-plugin-inject@^3.0.0:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz#e4233855bfba6c0c12a312fd6649dff9a13ee9f4"
|
||||
integrity sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==
|
||||
dependencies:
|
||||
estree-walker "^0.6.1"
|
||||
magic-string "^0.25.3"
|
||||
rollup-pluginutils "^2.8.1"
|
||||
|
||||
rollup-plugin-node-polyfills@^0.2.1:
|
||||
version "0.2.1"
|
||||
resolved "https://registry.yarnpkg.com/rollup-plugin-node-polyfills/-/rollup-plugin-node-polyfills-0.2.1.tgz#53092a2744837164d5b8a28812ba5f3ff61109fd"
|
||||
integrity sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==
|
||||
dependencies:
|
||||
rollup-plugin-inject "^3.0.0"
|
||||
|
||||
rollup-pluginutils@^2.8.1:
|
||||
version "2.8.2"
|
||||
resolved "https://registry.yarnpkg.com/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz#72f2af0748b592364dbd3389e600e5a9444a351e"
|
||||
integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==
|
||||
dependencies:
|
||||
estree-walker "^0.6.1"
|
||||
|
||||
selfsigned@^2.0.0, selfsigned@^2.0.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.1.1.tgz#18a7613d714c0cd3385c48af0075abf3f266af61"
|
||||
integrity sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ==
|
||||
dependencies:
|
||||
node-forge "^1"
|
||||
|
||||
semiver@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/semiver/-/semiver-1.1.0.tgz#9c97fb02c21c7ce4fcf1b73e2c7a24324bdddd5f"
|
||||
integrity sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==
|
||||
|
||||
semver@^7.0.0, semver@^7.3.7:
|
||||
version "7.3.8"
|
||||
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
|
||||
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
|
||||
dependencies:
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
set-cookie-parser@^2.4.8:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.5.1.tgz#ddd3e9a566b0e8e0862aca974a6ac0e01349430b"
|
||||
integrity sha512-1jeBGaKNGdEq4FgIrORu/N570dwoPYio8lSoYLWmX7sQ//0JY08Xh9o5pBcgmHQ/MbsYp/aZnOe1s1lIsbLprQ==
|
||||
|
||||
shebang-command@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
|
||||
integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
|
||||
dependencies:
|
||||
shebang-regex "^3.0.0"
|
||||
|
||||
shebang-regex@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
signal-exit@^3.0.7:
|
||||
version "3.0.7"
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9"
|
||||
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
|
||||
|
||||
source-map-support@^0.5.20:
|
||||
version "0.5.21"
|
||||
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
|
||||
integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
|
||||
dependencies:
|
||||
buffer-from "^1.0.0"
|
||||
source-map "^0.6.0"
|
||||
|
||||
source-map@^0.6.0:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
|
||||
integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
|
||||
source-map@^0.7.4:
|
||||
version "0.7.4"
|
||||
resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656"
|
||||
integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==
|
||||
|
||||
sourcemap-codec@^1.4.8:
|
||||
version "1.4.8"
|
||||
resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4"
|
||||
integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==
|
||||
|
||||
stack-trace@0.0.10:
|
||||
version "0.0.10"
|
||||
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
|
||||
integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==
|
||||
|
||||
streamsearch@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
strip-final-newline@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-3.0.0.tgz#52894c313fbff318835280aed60ff71ebf12b8fd"
|
||||
integrity sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==
|
||||
|
||||
to-regex-range@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
|
||||
integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
|
||||
dependencies:
|
||||
is-number "^7.0.0"
|
||||
|
||||
toucan-js@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/toucan-js/-/toucan-js-3.1.0.tgz#412cf43c259e702f46427e465adb2f588b7eea85"
|
||||
integrity sha512-bRbq/HB+aSfwbsSoCNI6qyPXx2bhsscxSYxnAY63xXv9lIeOLUYfvYdOIBWfAVj9QHNST+X83GQ0lj/llvHpVg==
|
||||
dependencies:
|
||||
"@sentry/core" "7.28.1"
|
||||
"@sentry/types" "7.28.1"
|
||||
"@sentry/utils" "7.28.1"
|
||||
|
||||
tslib@^1.9.3:
|
||||
version "1.14.1"
|
||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
|
||||
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
|
||||
|
||||
typescript@^4.9.4:
|
||||
version "4.9.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||
|
||||
undici@5.9.1:
|
||||
version "5.9.1"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-5.9.1.tgz#fc9fd85dd488f965f153314a63d9426a11f3360b"
|
||||
integrity sha512-6fB3a+SNnWEm4CJbgo0/CWR8RGcOCQP68SF4X0mxtYTq2VNN8T88NYrWVBAeSX+zb7bny2dx2iYhP3XHi00omg==
|
||||
|
||||
urlpattern-polyfill@^4.0.3:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-4.0.3.tgz#c1fa7a73eb4e6c6a1ffb41b24cf31974f7392d3b"
|
||||
integrity sha512-DOE84vZT2fEcl9gqCUTcnAw5ZY5Id55ikUcziSUntuEFL3pRvavg5kwDmTEUJkeCHInTlV/HexFomgYnzO5kdQ==
|
||||
|
||||
validate-npm-package-name@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-4.0.0.tgz#fe8f1c50ac20afdb86f177da85b3600f0ac0d747"
|
||||
integrity sha512-mzR0L8ZDktZjpX4OB46KT+56MAhl4EIazWP/+G/HPGuvfdaqg4YsCdtOm6U9+LOFyYDoh4dpnpxZRB9MQQns5Q==
|
||||
dependencies:
|
||||
builtins "^5.0.0"
|
||||
|
||||
which@^2.0.1:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
|
||||
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
|
||||
dependencies:
|
||||
isexe "^2.0.0"
|
||||
|
||||
wrangler@2.7.1:
|
||||
version "2.7.1"
|
||||
resolved "https://registry.yarnpkg.com/wrangler/-/wrangler-2.7.1.tgz#adb9d06c0dde3fc434e4a85a286c1da3f485cade"
|
||||
integrity sha512-SKoe+UTOCX0J+RfEDE6MEdnNy1lDSnB1BfpAEblgvDHjmEunPFu0w6GxcyOYAl/fTl3/VjXZO3p+Ybm9zenzWg==
|
||||
dependencies:
|
||||
"@cloudflare/kv-asset-handler" "^0.2.0"
|
||||
"@esbuild-plugins/node-globals-polyfill" "^0.1.1"
|
||||
"@esbuild-plugins/node-modules-polyfill" "^0.1.4"
|
||||
"@miniflare/core" "2.11.0"
|
||||
"@miniflare/d1" "2.11.0"
|
||||
"@miniflare/durable-objects" "2.11.0"
|
||||
blake3-wasm "^2.1.5"
|
||||
chokidar "^3.5.3"
|
||||
esbuild "0.14.51"
|
||||
miniflare "2.11.0"
|
||||
nanoid "^3.3.3"
|
||||
path-to-regexp "^6.2.0"
|
||||
selfsigned "^2.0.1"
|
||||
source-map "^0.7.4"
|
||||
xxhash-wasm "^1.0.1"
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.2"
|
||||
|
||||
ws@^8.2.2:
|
||||
version "8.12.0"
|
||||
resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.0.tgz#485074cc392689da78e1828a9ff23585e06cddd8"
|
||||
integrity sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==
|
||||
|
||||
xxhash-wasm@^1.0.1:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/xxhash-wasm/-/xxhash-wasm-1.0.2.tgz#ecc0f813219b727af4d5f3958ca6becee2f2f1ff"
|
||||
integrity sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==
|
||||
|
||||
yallist@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
|
||||
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
|
||||
|
||||
youch@^2.2.2:
|
||||
version "2.2.2"
|
||||
resolved "https://registry.yarnpkg.com/youch/-/youch-2.2.2.tgz#cb87a359a5c524ebd35eb07ca3a1521dbc7e1a3e"
|
||||
integrity sha512-/FaCeG3GkuJwaMR34GHVg0l8jCbafZLHiFowSjqLlqhC6OMyf2tPJBu8UirF7/NI9X/R5ai4QfEKUCOxMAGxZQ==
|
||||
dependencies:
|
||||
"@types/stack-trace" "0.0.29"
|
||||
cookie "^0.4.1"
|
||||
mustache "^4.2.0"
|
||||
stack-trace "0.0.10"
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "wildebeest-do",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20221111.1",
|
||||
"typescript": "^4.9.4",
|
||||
"wrangler": "2.7.1"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "wrangler dev",
|
||||
"deploy": "wrangler publish"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
export interface Env {
|
||||
DO: DurableObjectNamespace
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env) {
|
||||
try {
|
||||
const id = env.DO.idFromName('default')
|
||||
const obj = env.DO.get(id)
|
||||
return obj.fetch(request)
|
||||
} catch (err: any) {
|
||||
return new Response(err.stack, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export class WildebeestCache {
|
||||
private storage: DurableObjectStorage
|
||||
|
||||
constructor(state: DurableObjectState) {
|
||||
this.storage = state.storage
|
||||
}
|
||||
|
||||
async fetch(request: Request) {
|
||||
if (request.method === 'GET') {
|
||||
const { pathname } = new URL(request.url)
|
||||
const key = pathname.slice(1) // remove the leading slash from path
|
||||
|
||||
const value = await this.storage.get(key)
|
||||
if (value === undefined) {
|
||||
console.log(`Get ${key} MISS`)
|
||||
return new Response('', { status: 404 })
|
||||
}
|
||||
|
||||
console.log(`Get ${key} HIT`)
|
||||
return new Response(JSON.stringify(value))
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
const { key, value } = await request.json<any>()
|
||||
console.log(`Set ${key}`)
|
||||
|
||||
await this.storage.put(key, value)
|
||||
return new Response('', { status: 201 })
|
||||
}
|
||||
|
||||
return new Response('', { status: 400 })
|
||||
}
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
|
||||
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
|
||||
/* Modules */
|
||||
"module": "es2022" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
|
||||
"types": [
|
||||
"@cloudflare/workers-types",
|
||||
"jest"
|
||||
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
"resolveJsonModule": true /* Enable importing .json files */,
|
||||
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
|
||||
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
"noEmit": true /* Disable emitting files from a compilation. */,
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
|
||||
// "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
|
||||
// "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
name = "wildebeest-do"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2023-01-18"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_classes = ["WildebeestCache"]
|
|
@ -30,7 +30,7 @@ module.exports = {
|
|||
'@typescript-eslint/ban-ts-comment': 'error',
|
||||
'prefer-spread': 'error',
|
||||
'no-case-declarations': 'error',
|
||||
'no-console': 'error',
|
||||
'no-console': ['error', { allow: ['warn', 'error']} ],
|
||||
'@typescript-eslint/no-unused-vars': ['error'],
|
||||
'prefer-const': 'error',
|
||||
},
|
||||
|
|
|
@ -1,46 +1,61 @@
|
|||
import { createPerson, getPersonByEmail, type Person } from 'wildebeest/backend/src/activitypub/actors'
|
||||
import * as statusesAPI from 'wildebeest/functions/api/v1/statuses'
|
||||
import * as reblogAPI from 'wildebeest/functions/api/v1/statuses/[id]/reblog'
|
||||
import { statuses } from 'wildebeest/frontend/src/dummyData'
|
||||
import { replies, statuses } from 'wildebeest/frontend/src/dummyData'
|
||||
import type { Account, MastodonStatus } from 'wildebeest/frontend/src/types'
|
||||
import { Note } from 'wildebeest/backend/src/activitypub/objects/note'
|
||||
import { createReblog } from 'wildebeest/backend/src/mastodon/reblog'
|
||||
import { createReply as createReplyInBackend } from 'wildebeest/backend/test/shared.utils'
|
||||
import { createStatus } from 'wildebeest/backend/src/mastodon/status'
|
||||
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
|
||||
|
||||
const kek = 'test-kek'
|
||||
/**
|
||||
* Run helper commands to initialize the database with actors, statuses, etc.
|
||||
*/
|
||||
export async function init(domain: string, db: D1Database) {
|
||||
const loadedStatuses: MastodonStatus[] = []
|
||||
const loadedStatuses: { status: MastodonStatus; note: Note }[] = []
|
||||
for (const status of statuses) {
|
||||
const actor = await getOrCreatePerson(domain, db, status.account)
|
||||
loadedStatuses.push(await createStatus(db, actor, status.content))
|
||||
const note = await createStatus(
|
||||
domain,
|
||||
db,
|
||||
actor,
|
||||
status.content,
|
||||
status.media_attachments as unknown as APObject[]
|
||||
)
|
||||
loadedStatuses.push({ status, note })
|
||||
}
|
||||
|
||||
// Grab the account from an arbitrary status to use as the reblogger
|
||||
const rebloggerAccount = loadedStatuses[1].account
|
||||
const reblogger = await getOrCreatePerson(domain, db, rebloggerAccount)
|
||||
// Reblog an arbitrary status with this reblogger
|
||||
const statusToReblog = loadedStatuses[2]
|
||||
await reblogStatus(db, reblogger, statusToReblog)
|
||||
const { reblogger, noteToReblog } = await pickReblogDetails(loadedStatuses, domain, db)
|
||||
await createReblog(db, reblogger, noteToReblog)
|
||||
|
||||
for (const reply of replies) {
|
||||
await createReply(domain, db, reply, loadedStatuses)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a status object in the given actors outbox.
|
||||
* Creates a reply for a note (representing a status)
|
||||
*/
|
||||
async function createStatus(db: D1Database, actor: Person, status: string, visibility = 'public') {
|
||||
const body = {
|
||||
status,
|
||||
visibility,
|
||||
async function createReply(
|
||||
domain: string,
|
||||
db: D1Database,
|
||||
reply: MastodonStatus,
|
||||
loadedStatuses: { status: MastodonStatus; note: Note }[]
|
||||
) {
|
||||
if (!reply.in_reply_to_id) {
|
||||
console.warn(`Ignoring reply with id ${reply.id} since it doesn't have a in_reply_to_id field`)
|
||||
return
|
||||
}
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
|
||||
const originalStatus = loadedStatuses.find(({ status: { id } }) => id === reply.in_reply_to_id)
|
||||
if (!originalStatus) {
|
||||
console.warn(
|
||||
`Ignoring reply with id ${reply.id} since no status matching the in_reply_to_id ${reply.in_reply_to_id} has been found`
|
||||
)
|
||||
return
|
||||
}
|
||||
const req = new Request('https://example.com', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const resp = await statusesAPI.handleRequest(req, db, actor, kek)
|
||||
return (await resp.json()) as MastodonStatus
|
||||
|
||||
const actor = await getOrCreatePerson(domain, db, reply.account)
|
||||
await createReplyInBackend(domain, db, actor, originalStatus.note, reply.content)
|
||||
}
|
||||
|
||||
async function getOrCreatePerson(
|
||||
|
@ -50,7 +65,7 @@ async function getOrCreatePerson(
|
|||
): Promise<Person> {
|
||||
const person = await getPersonByEmail(db, username)
|
||||
if (person) return person
|
||||
const newPerson = await createPerson(domain, db, kek, username, {
|
||||
const newPerson = await createPerson(domain, db, 'test-kek', username, {
|
||||
icon: { url: avatar },
|
||||
name: display_name,
|
||||
})
|
||||
|
@ -60,6 +75,20 @@ async function getOrCreatePerson(
|
|||
return newPerson
|
||||
}
|
||||
|
||||
async function reblogStatus(db: D1Database, actor: Person, status: MastodonStatus) {
|
||||
await reblogAPI.handleRequest(db, status.id, actor, kek)
|
||||
/**
|
||||
* Picks the details to use to reblog an arbitrary note/status.
|
||||
*
|
||||
* Both the note/status and the reblogger are picked arbitrarily
|
||||
* form a list of available notes/states (respectively from the first
|
||||
* and second entries).
|
||||
*/
|
||||
async function pickReblogDetails(
|
||||
loadedStatuses: { status: MastodonStatus; note: Note }[],
|
||||
domain: string,
|
||||
db: D1Database
|
||||
) {
|
||||
const rebloggerAccount = loadedStatuses[1].status.account
|
||||
const reblogger = await getOrCreatePerson(domain, db, rebloggerAccount)
|
||||
const noteToReblog = loadedStatuses[2].note
|
||||
return { reblogger, noteToReblog }
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import console from 'console';
|
||||
import { dirname, resolve } from 'path';
|
||||
import process from 'process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import console from 'console'
|
||||
import { dirname, resolve } from 'path'
|
||||
import process from 'process'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { unstable_dev } from 'wrangler'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
/**
|
||||
* A simple utility to run a Cloudflare Worker that will populate a local D1 database with mock data.
|
||||
*
|
||||
*
|
||||
* Uses Wrangler's `unstable_dev()` helper to execute the Worker and exit cleanly;
|
||||
* this is much harder to do with the command line Wrangler binary.
|
||||
*/
|
||||
|
@ -21,8 +21,8 @@ async function main() {
|
|||
tsconfig: resolve(__dirname, '../../tsconfig.json'),
|
||||
define: ['jest:{}'],
|
||||
}
|
||||
const workerPath = resolve(__dirname, "./worker.ts");
|
||||
const worker = await unstable_dev(workerPath, {...options, experimental: {disableExperimentalWarning: true }})
|
||||
const workerPath = resolve(__dirname, './worker.ts')
|
||||
const worker = await unstable_dev(workerPath, { ...options, experimental: { disableExperimentalWarning: true } })
|
||||
await worker.fetch()
|
||||
await worker.stop()
|
||||
}
|
||||
|
|
|
@ -12,9 +12,11 @@ const handler: ExportedHandler<Env> = {
|
|||
const domain = new URL(req.url).hostname
|
||||
try {
|
||||
await init(domain, DATABASE)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Database initialized.')
|
||||
} catch (e) {
|
||||
if (isD1ConstraintError(e)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Database already initialized.')
|
||||
} else {
|
||||
throw e
|
||||
|
@ -29,7 +31,10 @@ const handler: ExportedHandler<Env> = {
|
|||
* which will indicate that the database was already populated.
|
||||
*/
|
||||
function isD1ConstraintError(e: unknown) {
|
||||
return (e as any).message === 'D1_RUN_ERROR' && (e as any).cause?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
|
||||
return (
|
||||
(e as { message: string }).message === 'D1_RUN_ERROR' &&
|
||||
(e as { cause?: { code: string } }).cause?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
|
||||
)
|
||||
}
|
||||
|
||||
export default handler
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"lint": "eslint src mock-db adaptors",
|
||||
"build": "vite build && vite build -c adaptors/cloudflare-pages/vite.config.ts",
|
||||
"dev": "vite --mode ssr",
|
||||
"watch": "concurrently \"vite build -w\" \"vite build -w -c adaptors/cloudflare-pages/vite.config.ts\""
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { component$, useStore, $ } from '@builder.io/qwik'
|
||||
import { component$, useStore, PropFunction } from '@builder.io/qwik'
|
||||
import { MediaAttachment } from '~/types'
|
||||
|
||||
type Props = {
|
||||
mediaAttachment: MediaAttachment
|
||||
onOpenImagesModal$: PropFunction<(id: string) => void>
|
||||
}
|
||||
|
||||
export const focusToObjectFit = (focus: { x: number; y: number }) => {
|
||||
|
@ -15,7 +16,7 @@ export const focusToObjectFit = (focus: { x: number; y: number }) => {
|
|||
return { x: Math.floor(x2 * 100) / 100, y: Math.floor(y2 * 100) / 100 }
|
||||
}
|
||||
|
||||
export default component$<Props>(({ mediaAttachment }) => {
|
||||
export default component$<Props>(({ mediaAttachment, onOpenImagesModal$ }) => {
|
||||
const store = useStore({
|
||||
isModalOpen: false,
|
||||
})
|
||||
|
@ -25,35 +26,17 @@ export default component$<Props>(({ mediaAttachment }) => {
|
|||
objectFit = focusToObjectFit(mediaAttachment.meta.focus)
|
||||
}
|
||||
|
||||
const onPreviewClick = $(() => {
|
||||
document.body.style.overflowY = 'hidden'
|
||||
store.isModalOpen = true
|
||||
})
|
||||
|
||||
const onModalClose = $(() => {
|
||||
document.body.style.overflowY = 'scroll'
|
||||
store.isModalOpen = false
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="h-60">
|
||||
<div class={`${store.isModalOpen ? '' : 'cursor-zoom-in'} w-full h-full`}>
|
||||
<img
|
||||
class="object-cover w-full h-full rounded"
|
||||
class="object-cover w-full h-full rounded cursor-pointer"
|
||||
style={{
|
||||
...(objectFit && { 'object-position': `${objectFit.x}% ${objectFit.y}%` }),
|
||||
}}
|
||||
src={mediaAttachment.preview_url || mediaAttachment.url}
|
||||
onClick$={onPreviewClick}
|
||||
onClick$={() => onOpenImagesModal$(mediaAttachment.id)}
|
||||
/>
|
||||
{store.isModalOpen && (
|
||||
<div class="relative pointer-events-auto z-50">
|
||||
<div class="overlay inset-0 fixed z-60 bg-black opacity-70"></div>
|
||||
<div class="fixed z-70 inset-0 grid place-items-center" onClick$={onModalClose}>
|
||||
<img src={mediaAttachment.url} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
|
@ -0,0 +1,46 @@
|
|||
import { component$, useSignal, PropFunction } from '@builder.io/qwik'
|
||||
import { MediaAttachment } from '~/types'
|
||||
|
||||
type Props = {
|
||||
images: MediaAttachment[]
|
||||
idxOfCurrentImage: number
|
||||
onCloseImagesModal$: PropFunction<() => void>
|
||||
}
|
||||
|
||||
export const ImagesModal = component$<Props>(({ images, idxOfCurrentImage: initialIdx, onCloseImagesModal$ }) => {
|
||||
const idxOfCurrentImage = useSignal(initialIdx)
|
||||
|
||||
return (
|
||||
<div class="pointer-events-auto cursor-default z-50 fixed inset-0 isolate flex items-center justify-between backdrop-blur-sm">
|
||||
<div class="inset-0 absolute z-[-1] bg-wildebeest-900 opacity-70" onClick$={() => onCloseImagesModal$()}></div>
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
class="cursor-pointer text-4xl opacity-60 hover:opacity-90 focus-visible:opacity-90"
|
||||
onClick$={() => {
|
||||
const idx = idxOfCurrentImage.value - 1
|
||||
idxOfCurrentImage.value = idx < 0 ? images.length - 1 : idx
|
||||
}}
|
||||
>
|
||||
<i class="fa-solid fa-chevron-left ml-5"></i>
|
||||
</button>
|
||||
)}
|
||||
<img class="ma max-w-[80vw] max-h-[90vh] m-auto" src={images[idxOfCurrentImage.value].url} />
|
||||
{images.length > 1 && (
|
||||
<button
|
||||
class="cursor-pointer text-4xl opacity-60 hover:opacity-90 focus-visible:opacity-90"
|
||||
onClick$={() => {
|
||||
idxOfCurrentImage.value = (idxOfCurrentImage.value + 1) % images.length
|
||||
}}
|
||||
>
|
||||
<i class="fa-solid fa-chevron-right mr-5"></i>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
class="cursor-pointer absolute top-7 right-7 text-4xl opacity-60 hover:opacity-90 focus-visible:opacity-90"
|
||||
onClick$={() => onCloseImagesModal$()}
|
||||
>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,16 @@
|
|||
import { component$ } from '@builder.io/qwik'
|
||||
import { MediaAttachment } from '~/types'
|
||||
|
||||
type Props = {
|
||||
mediaAttachment: MediaAttachment
|
||||
}
|
||||
|
||||
export default component$<Props>(({ mediaAttachment }) => {
|
||||
return (
|
||||
<div class="h-full">
|
||||
<video controls class="object-cover w-full h-full rounded">
|
||||
<source src={mediaAttachment.preview_url || mediaAttachment.url} type="video/mp4" />
|
||||
</video>
|
||||
</div>
|
||||
)
|
||||
})
|
|
@ -0,0 +1,34 @@
|
|||
.media-gallery:has(:nth-child(1)) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.media-gallery:has(:nth-child(2)) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.media-gallery:has(:nth-child(3)) {
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
&:not(:has(:nth-child(4))) {
|
||||
:nth-child(1) {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / -1;
|
||||
}
|
||||
|
||||
:nth-child(2) {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
:nth-child(3) {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-gallery:has(:nth-child(4)) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
import { component$, useStylesScoped$, $, useStore } from '@builder.io/qwik'
|
||||
import { MediaAttachment } from '~/types'
|
||||
import Image from './Image'
|
||||
import Video from './Video'
|
||||
import styles from './index.scss?inline'
|
||||
import { ImagesModal } from './ImagesModal'
|
||||
|
||||
type Props = {
|
||||
medias: MediaAttachment[]
|
||||
}
|
||||
|
||||
export const MediaGallery = component$<Props>(({ medias }) => {
|
||||
useStylesScoped$(styles)
|
||||
|
||||
const images = medias.filter((media) => media.type === 'image')
|
||||
|
||||
const imagesModalState = useStore<{ isOpen: boolean; idxOfCurrentImage: number }>({
|
||||
isOpen: false,
|
||||
idxOfCurrentImage: 0,
|
||||
})
|
||||
|
||||
const onOpenImagesModal = $((imgId: string) => {
|
||||
document.body.style.overflowY = 'hidden'
|
||||
imagesModalState.isOpen = true
|
||||
const idx = images.findIndex(({ id }) => id === imgId)
|
||||
imagesModalState.idxOfCurrentImage = idx === -1 ? 0 : idx
|
||||
})
|
||||
|
||||
const onCloseImagesModal = $(() => {
|
||||
document.body.style.overflowY = 'scroll'
|
||||
imagesModalState.isOpen = false
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!medias.length && (
|
||||
<div class={`media-gallery overflow-hidden grid gap-1 h-52 md:h-60 lg:h-72 xl:h-80`}>
|
||||
{medias.map((media) => (
|
||||
<div class="w-full flex items-center justify-center overflow-hidden bg-black">
|
||||
{media.type === 'image' && <Image mediaAttachment={media} onOpenImagesModal$={onOpenImagesModal} />}
|
||||
{media.type === 'video' && <Video mediaAttachment={media} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{imagesModalState.isOpen && (
|
||||
<ImagesModal
|
||||
images={images}
|
||||
idxOfCurrentImage={imagesModalState.idxOfCurrentImage}
|
||||
onCloseImagesModal$={onCloseImagesModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
|
@ -2,9 +2,9 @@ import { component$, $, useStyles$ } from '@builder.io/qwik'
|
|||
import { Link, useNavigate } from '@builder.io/qwik-city'
|
||||
import { formatTimeAgo } from '~/utils/dateTime'
|
||||
import { Avatar } from '../avatar'
|
||||
import Image from './ImageGallery'
|
||||
import type { Account, MastodonStatus } from '~/types'
|
||||
import styles from './index.scss?inline'
|
||||
import styles from '../../utils/innerHtmlContent.scss?inline'
|
||||
import { MediaGallery } from '../MediaGallery.tsx'
|
||||
|
||||
type Props = {
|
||||
status: MastodonStatus
|
||||
|
@ -23,7 +23,7 @@ export default component$((props: Props) => {
|
|||
const handleContentClick = $(() => nav(statusUrl))
|
||||
|
||||
return (
|
||||
<div class="p-4 border-t border-wildebeest-700 pointer">
|
||||
<article class="p-4 border-t border-wildebeest-700 pointer">
|
||||
<RebloggerLink account={reblogger}></RebloggerLink>
|
||||
<div onClick$={handleContentClick}>
|
||||
<div class="flex justify-between mb-3">
|
||||
|
@ -31,9 +31,9 @@ export default component$((props: Props) => {
|
|||
<Avatar primary={status.account} secondary={reblogger} />
|
||||
<div class="flex-col ml-3">
|
||||
<div>
|
||||
<a class="no-underline" href={status.account.url}>
|
||||
<Link class="no-underline" href={accountUrl}>
|
||||
{status.account.display_name}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="text-wildebeest-500">@{status.account.username}</div>
|
||||
</div>
|
||||
|
@ -45,12 +45,12 @@ export default component$((props: Props) => {
|
|||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div class="leading-relaxed status-content" dangerouslySetInnerHTML={status.content} />
|
||||
<div class="leading-relaxed inner-html-content" dangerouslySetInnerHTML={status.content} />
|
||||
</div>
|
||||
|
||||
{status.media_attachments.length > 0 && <Image mediaAttachment={status.media_attachments[0]} />}
|
||||
<MediaGallery medias={status.media_attachments} />
|
||||
|
||||
{status.card && status.media_attachments.length == 0 && (
|
||||
{status.card && status.media_attachments.length === 0 && (
|
||||
<a class="no-underline" href={status.card.url}>
|
||||
<div class="rounded flex border border-wildebeest-600">
|
||||
<img class="w-16 h-16" src={status.card.image} />
|
||||
|
@ -61,7 +61,7 @@ export default component$((props: Props) => {
|
|||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -70,7 +70,7 @@ export const RebloggerLink = ({ account }: { account: Account | null }) => {
|
|||
account && (
|
||||
<div class="flex text-wildebeest-500 py-3">
|
||||
<p>
|
||||
<i class="fa fa-retweet mr-3" />
|
||||
<i class="fa fa-retweet mr-3 w-4 inline-block" />
|
||||
<a class="no-underline" href={account.url}>
|
||||
{account.display_name}
|
||||
</a>
|
||||
|
|
|
@ -1,9 +1,30 @@
|
|||
import { component$, Slot } from '@builder.io/qwik'
|
||||
import { $, component$, Slot } from '@builder.io/qwik'
|
||||
import { useNavigate } from '@builder.io/qwik-city'
|
||||
|
||||
export default component$<{ withBackButton?: boolean }>(({ withBackButton }) => {
|
||||
const nav = useNavigate()
|
||||
|
||||
const goBack = $(() => {
|
||||
if (window.history.length > 1) {
|
||||
window.history.back()
|
||||
} else {
|
||||
nav('/explore')
|
||||
}
|
||||
})
|
||||
|
||||
export default component$(() => {
|
||||
return (
|
||||
<header class="bg-wildebeest-900 sticky top-[3.9rem] xl:top-0 xl:pt-2.5 z-10">
|
||||
<Slot />
|
||||
<div class="flex bg-wildebeest-700 xl:rounded-t overflow-hidden">
|
||||
{!!withBackButton && (
|
||||
<div class="flex justify-between items-center bg-wildebeest-700">
|
||||
<button class="text-semi no-underline text-wildebeest-vibrant-400 bg-transparent p-4" onClick$={goBack}>
|
||||
<i class="fa fa-chevron-left mr-2 w-3 inline-block" />
|
||||
<span class="hover:underline">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Slot />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
})
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue