Merge branch 'main' into docs

pull/245/head
Celso Martinho 2023-02-10 17:10:57 +00:00 zatwierdzone przez GitHub
commit bd277c95d4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
184 zmienionych plików z 7700 dodań i 2654 usunięć

Wyświetl plik

@ -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',

Wyświetl plik

@ -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

Wyświetl plik

@ -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 }}

5
.gitignore vendored
Wyświetl plik

@ -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/

3
.vscode/extensions.json vendored 100644
Wyświetl plik

@ -0,0 +1,3 @@
{
"recommendations": ["ms-playwright.playwright"]
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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,
}
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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,

Wyświetl plik

@ -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),

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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(?, ?, ?)')

Wyświetl plik

@ -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

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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>
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)
}
}
}

Wyświetl plik

@ -0,0 +1,6 @@
import type { APObject } from 'wildebeest/backend/src/activitypub/objects'
export interface Link extends APObject {
href: URL
name: string
}

Wyświetl plik

@ -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),
}
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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)
}
}

42
backend/src/cache/index.ts vendored 100644
Wyświetl plik

@ -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()}`)
}
},
}
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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 (?, ?, ?, ?, ?)
`

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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=?
`

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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,

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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
`

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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}',
}

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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' ||

Wyświetl plik

@ -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>

Wyświetl plik

@ -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

Wyświetl plik

@ -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
}

Wyświetl plik

@ -0,0 +1,6 @@
export interface ObjectsRow {
properties: string
mastodon_id: string
id: URL
cdate: string
}

Wyświetl plik

@ -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>
}

Wyświetl plik

@ -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/

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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',
}
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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 }
}
}

Wyświetl plik

@ -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')
})
})
})

Wyświetl plik

@ -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)
})
})
})

Wyświetl plik

@ -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)
})
})
})
})

Wyświetl plik

@ -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())
})
})
})

Wyświetl plik

@ -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>`)
})
})
})
})

Wyświetl plik

@ -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)
})

Wyświetl plik

@ -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')
})
})
})

Wyświetl plik

@ -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')
})
})
})

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)
})

Wyświetl plik

@ -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)
}
})
})
})

Wyświetl plik

@ -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')
})
})
})

Wyświetl plik

@ -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')
})
})

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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')

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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',
}

5
config/ua.ts 100644
Wyświetl plik

@ -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})`
}

10
config/versions.ts 100644
Wyświetl plik

@ -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})`
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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),
])
}

Wyświetl plik

@ -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)
}
},
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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)
})
})
})

Wyświetl plik

@ -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. */
}
}

Wyświetl plik

@ -0,0 +1,3 @@
compatibility_date = "2023-01-09"
main = "./src/index.ts"
usage_model = "unbound"

881
consumer/yarn.lock 100644
Wyświetl plik

@ -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"

14
do/package.json 100644
Wyświetl plik

@ -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"
}
}

49
do/src/index.ts 100644
Wyświetl plik

@ -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 })
}
}

106
do/tsconfig.json 100644
Wyświetl plik

@ -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. */
}
}

7
do/wrangler.toml 100644
Wyświetl plik

@ -0,0 +1,7 @@
name = "wildebeest-do"
main = "src/index.ts"
compatibility_date = "2023-01-18"
[[migrations]]
tag = "v1"
new_classes = ["WildebeestCache"]

Wyświetl plik

@ -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',
},

Wyświetl plik

@ -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 }
}

Wyświetl plik

@ -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()
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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\""

Wyświetl plik

@ -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>
</>
)

Wyświetl plik

@ -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>
)
})

Wyświetl plik

@ -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>
)
})

Wyświetl plik

@ -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;
}

Wyświetl plik

@ -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}
/>
)}
</>
)
})

Wyświetl plik

@ -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>

Wyświetl plik

@ -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