Import wildebeest code

Co-authored-by: Sven Sauleau <sven@cloudflare.com>
Co-authored-by: Dario Piotrowicz <dario@cloudflare.com>
Co-authored-by: André Cruz <acruz@cloudflare.com>
Co-authored-by: James Culveyhouse <jculveyhouse@cloudflare.com>
Co-authored-by: Pete Bacon Darwin <pete@bacondarwin.com>
pull/3/head
Sven Sauleau 2022-12-05 20:14:56 +00:00
commit 25be15b2a0
221 zmienionych plików z 28155 dodań i 0 usunięć

31
.eslintignore 100644
Wyświetl plik

@ -0,0 +1,31 @@
**/*.log
**/.DS_Store
*.
.vscode/settings.json
.history
.yarn
bazel-*
bazel-bin
bazel-out
bazel-qwik
bazel-testlogs
dist
dist-dev
lib
lib-types
etc
external
node_modules
temp
tsc-out
tsdoc-metadata.json
target
output
rollup.config.js
build
.cache
.vscode
.rollup.cache
dist
tsconfig.tsbuildinfo
vite.config.ts

44
.eslintrc.cjs 100644
Wyświetl plik

@ -0,0 +1,44 @@
module.exports = {
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
},
plugins: ['@typescript-eslint'],
root: true,
rules: {
'no-var': 'error',
/*
Note: the following rules have been set to off so that linting
can pass with the current code, but we need to gradually
re-enable most of them
*/
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'no-constant-condition': 'off',
'@typescript-eslint/await-thenable': 'off',
'prefer-const': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-misused-promises': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
'no-console': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-empty-interface': 'off',
},
}

42
.github/workflows/PRs.yml vendored 100644
Wyświetl plik

@ -0,0 +1,42 @@
name: Pull request checks
on:
push:
pull_request:
# This allows a subsequently queued workflow run to interrupt previous runs
concurrency:
group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}'
cancel-in-progress: true
jobs:
test-api:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16.13.x
- name: Install
run: yarn
- name: Check formatting
run: yarn pretty
- name: Run API tests
run: yarn test
test-ui:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: 16.13.x
- name: Install
run: yarn && yarn --cwd frontend
- name: Initialize local database
run: yarn database:create-mock
- name: Run UI tests
run: yarn test:ui

4
.gitignore vendored 100644
Wyświetl plik

@ -0,0 +1,4 @@
node_modules/
yarn-error.log
package-lock.json
.wrangler/state/d1/*.sqlite3

1
.node-version 100644
Wyświetl plik

@ -0,0 +1 @@
16.13

6
.prettierignore 100644
Wyświetl plik

@ -0,0 +1,6 @@
# Files Prettier should not format
**/*.log
**/.DS_Store
*.
dist
node_modules

7
.prettierrc.json 100644
Wyświetl plik

@ -0,0 +1,7 @@
{
"trailingComma": "es5",
"useTabs": true,
"semi": false,
"singleQuote": true,
"printWidth": 120
}

Wyświetl plik

97
CONTRIBUTING.md 100644
Wyświetl plik

@ -0,0 +1,97 @@
# Contribute to Wildebeest
## Getting started
Install:
```sh
yarn
```
## Running tests
Run the API (backend) unit tests:
```sh
yarn test
```
Run the UI (frontend) integration tests:
```sh
yarn database:create-mock # this initializes a local test database
yarn test:ui
```
## Debugging locally
```sh
yarn database:create-mock # this initializes a local test database
yarn dev
```
If only working on the REST API endpoints this is sufficient.
Any changes to the `functions` directory and the files it imports will re-run the Pages build.
Changes to the UI code will not trigger a rebuild automatically.
To do so, run the following in second terminal:
```sh
yarn --cwd frontend watch
```
## Deploying
This is a Cloudflare Pages project and can be deployed directly from the command line using Wrangler.
First you must create and configure the Pages project and bindings (D1 database, KV namespace, etc).
### Initialization
Run the following command to create the Pages project and the D1 database in your account.
```
yarn deploy:init
```
You should see output like:
```
✅ Successfully created DB 'wildebeest'!
Add the following to your wrangler.toml to connect to it from a Worker:
[[ d1_databases ]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "wildebeest"
database_id = "ddce04a1-fd51-40cb-be21-e899d70fb9f3"
```
Grab the database_id from the command line output and add it to the wrangler.toml file. Don't change the binding name in the wrangler.toml. It should stay as `DATABASE`.
Next go to the Pages dashboard and add the D1 database to the newly created Pages project. This can be found at
```
wildebeest->Settings->Functions->D1 database bindings->Add binding
```
Enter `DATABASE` for the variable name and select the `wildebeest` database from the dropdown.
### Environment variables
wildebeest expectes the Pages project to inject the following environment variables.
Secret used to encrypt user private key in the database:
- `USER_KEY`
API token for integration with Cloudflare services (Cloudflare Images for example):
- `CF_ACCOUNT_ID`
- `CF_API_TOKEN`
### Deployment
Run the following command to deploy the current working directory to Cloudflare Pages:
```
yarn deploy
```

13
LICENSE 100644
Wyświetl plik

@ -0,0 +1,13 @@
Copyright (c) 2023 Cloudflare, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

9
README.md 100644
Wyświetl plik

@ -0,0 +1,9 @@
# Wildebeest
Nothing to see yet here, follow the Cloudflare blog.
## User registration
User registration is not supported by Wildebeest. Instead it relies on [Cloudflare Access] for user management, when you are allowed by [Cloudflare Access] you can use the Mastodon login flow and which will register you if needed.
[Cloudflare Access]: https://www.cloudflare.com/products/zero-trust/access/

Wyświetl plik

@ -0,0 +1,191 @@
// Copied from the @cloudflare/pages-plugin-cloudflare-access package but fixes two important issues:
// - uses the Authorization header to find the Access JWT (instead os Cf-Access-Jwt-Assertion).
// - prevents loosing the Response.status value across Pages middleware
const isTesting = typeof jest !== 'undefined'
const textDecoder = new TextDecoder('utf-8')
export type Identity = {
id: string
name: string
email: string
groups: string[]
amr: string[]
idp: { id: string; type: string }
geo: { country: string }
user_uuid: string
account_id: string
ip: string
auth_status: string
common_name: string
service_token_id: string
service_token_status: boolean
is_warp: boolean
is_gateway: boolean
version: number
device_sessions: Record<string, { last_authenticated: number }>
iat: number
}
export type JWTPayload = {
aud: string | string[]
common_name?: string // Service token client ID
country?: string
custom?: unknown
email?: string
exp: number
iat: number
nbf?: number
iss: string // https://<domain>.cloudflareaccess.com
type?: string // Always just 'app'?
identity_nonce?: string
sub: string // Empty string for service tokens or user ID otherwise
}
export type PluginArgs = {
aud: string
domain: string
}
type CloudflareAccessPagesPluginFunction<
Env = unknown,
Params extends string = any,
Data extends Record<string, unknown> = Record<string, unknown>
> = PagesPluginFunction<Env, Params, Data, PluginArgs>
// Adapted slightly from https://github.com/cloudflare/workers-access-external-auth-example
const base64URLDecode = (s: string) => {
s = s.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '')
return new Uint8Array(
// @ts-ignore
Array.prototype.map.call(atob(s), (c: string) => c.charCodeAt(0))
)
}
const asciiToUint8Array = (s: string) => {
let chars = []
for (let i = 0; i < s.length; ++i) {
chars.push(s.charCodeAt(i))
}
return new Uint8Array(chars)
}
export function getPayload(jwt: string): JWTPayload {
const parts = jwt.split('.')
if (parts.length !== 3) {
throw new Error('JWT does not have three parts.')
}
const [, payload] = parts
const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload)))
return payloadObj
}
export const generateValidator =
({ domain, aud, jwt }: { domain: string; aud: string; jwt: string }) =>
async (
request: Request
): Promise<{
payload: object
}> => {
const parts = jwt.split('.')
if (parts.length !== 3) {
throw new Error('JWT does not have three parts.')
}
const [header, payload, signature] = parts
const { kid, alg } = JSON.parse(textDecoder.decode(base64URLDecode(header)))
if (alg !== 'RS256') {
throw new Error('Unknown JWT type or algorithm.')
}
const certsURL = new URL('/cdn-cgi/access/certs', 'https://' + domain)
const certsResponse = await fetch(certsURL.toString())
const { keys } = (await certsResponse.json()) as {
keys: ({
kid: string
} & JsonWebKey)[]
public_cert: { kid: string; cert: string }
public_certs: { kid: string; cert: string }[]
}
if (!keys) {
throw new Error('Could not fetch signing keys.')
}
const jwk = keys.find((key) => key.kid === kid)
if (!jwk) {
throw new Error('Could not find matching signing key.')
}
if (jwk.kty !== 'RSA' || jwk.alg !== 'RS256') {
throw new Error('Unknown key type of algorithm.')
}
const key = await crypto.subtle.importKey('jwk', jwk, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, [
'verify',
])
const unroundedSecondsSinceEpoch = Date.now() / 1000
const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload)))
// For testing disable JWT checks.
// Ideally we match the production behavior in testing but that
// requires using local key pair to generate a valid JWT token.
// For now, let's keep it simple.
if (!isTesting) {
if (payloadObj.iss && payloadObj.iss !== certsURL.origin) {
throw new Error('JWT issuer is incorrect.')
}
if (payloadObj.aud && !payloadObj.aud.includes(aud)) {
throw new Error('JWT audience is incorrect.')
}
if (payloadObj.exp && Math.floor(unroundedSecondsSinceEpoch) >= payloadObj.exp) {
throw new Error('JWT has expired.')
}
if (payloadObj.nbf && Math.ceil(unroundedSecondsSinceEpoch) < payloadObj.nbf) {
throw new Error('JWT is not yet valid.')
}
}
const verified = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
key,
base64URLDecode(signature),
asciiToUint8Array(`${header}.${payload}`)
)
if (!verified) {
throw new Error('Could not verify JWT.')
}
return { payload: payloadObj }
}
export const getIdentity = async ({ jwt, domain }: { jwt: string; domain: string }): Promise<undefined | Identity> => {
const identityURL = new URL('/cdn-cgi/access/get-identity', 'https://' + domain)
const response = await fetch(identityURL.toString(), {
headers: { Cookie: `CF_Authorization=${jwt}` },
})
if (response.ok) return await response.json()
}
export const generateLoginURL = ({
redirectURL: redirectURLInit,
domain,
aud,
}: {
redirectURL: string | URL
domain: string
aud: string
}): string => {
const redirectURL = typeof redirectURLInit === 'string' ? new URL(redirectURLInit) : redirectURLInit
const { hostname } = redirectURL
const loginPathname = `/cdn-cgi/access/login/${hostname}?`
const searchParams = new URLSearchParams({
kid: aud,
redirect_url: redirectURL.pathname + redirectURL.search,
})
return new URL(loginPathname + searchParams.toString(), 'https://' + domain).toString()
}
export const generateLogoutURL = ({ domain }: { domain: string }) =>
new URL(`/cdn-cgi/access/logout`, 'https://' + domain).toString()

Wyświetl plik

@ -0,0 +1,14 @@
import type { Object } from '../objects'
import type { Actor } from '../actors'
import type { Activity } from '.'
const ACCEPT = 'Accept'
export function create(actor: Actor, object: Object): Activity {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: ACCEPT,
actor: actor.id,
object,
}
}

Wyświetl plik

@ -0,0 +1,16 @@
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce
import type { Object } from '../objects'
import type { Actor } from '../actors'
import type { Activity } from '.'
const ANNOUNCE = 'Announce'
export function create(actor: Actor, object: URL): Activity {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: ANNOUNCE,
actor: actor.id,
object,
}
}

Wyświetl plik

@ -0,0 +1,39 @@
import type { Note } from '../objects/note'
import type { Actor } from '../actors'
import type { Activity } from '.'
import * as activity from '.'
const CREATE = 'Create'
export function create(domain: string, actor: Actor, object: Note): Activity {
const a: Activity = {
'@context': [
'https://www.w3.org/ns/activitystreams',
{
ostatus: 'http://ostatus.org#',
atomUri: 'ostatus:atomUri',
inReplyToAtomUri: 'ostatus:inReplyToAtomUri',
conversation: 'ostatus:conversation',
sensitive: 'as:sensitive',
toot: 'http://joinmastodon.org/ns#',
votersCount: 'toot:votersCount',
},
],
id: activity.uri(domain),
type: CREATE,
actor: actor.id,
object,
}
if (object.published) {
a.published = object.published
}
if (object.to) {
a.to = object.to
}
if (object.cc) {
a.cc = object.cc
}
return a
}

Wyświetl plik

@ -0,0 +1,14 @@
import type { Object } from '../objects'
import type { Actor } from '../actors'
import type { Activity } from '.'
const FOLLOW = 'Follow'
export function create(actor: Actor, object: Object): Activity {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: FOLLOW,
actor: actor.id,
object: object.id,
}
}

Wyświetl plik

@ -0,0 +1,347 @@
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { actorURL } from 'wildebeest/backend/src/activitypub/actors'
import * as objects from 'wildebeest/backend/src/activitypub/objects'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import * as accept from 'wildebeest/backend/src/activitypub/activities/accept'
import { addObjectInInbox } from 'wildebeest/backend/src/activitypub/actors/inbox'
import {
sendMentionNotification,
sendLikeNotification,
sendFollowNotification,
sendReblogNotification,
createNotification,
insertFollowNotification,
} from 'wildebeest/backend/src/mastodon/notification'
import { type Object, updateObject } from 'wildebeest/backend/src/activitypub/objects'
import { parseHandle } from 'wildebeest/backend/src/utils/parse'
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver'
import { getSigningKey } from 'wildebeest/backend/src/mastodon/account'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
function extractID(domain: string, s: string | URL): string {
return s.toString().replace(`https://${domain}/ap/users/`, '')
}
export type HandleResponse = {
createdObjects: Array<Object>
}
export type HandleMode = 'caching' | 'inbox'
export async function handle(
domain: string,
activity: Activity,
db: D1Database,
userKEK: string,
mode: HandleMode
): Promise<HandleResponse> {
const createdObjects: Array<Object> = []
// The `object` field of the activity is required to be an object, with an
// `id` and a `type` field.
const requireComplexObject = () => {
if (typeof activity.object !== 'object') {
throw new Error('`activity.object` must be of type object')
}
}
const getObjectAsId = () => {
let url: any = null
if (activity.object.id !== undefined) {
url = activity.object.id
}
if (typeof activity.object === 'string') {
url = activity.object
}
if (activity.object instanceof URL) {
// This is used for testing only.
return activity.object
}
if (url === null) {
throw new Error('unknown value: ' + JSON.stringify(activity.object))
}
try {
return new URL(url)
} catch (err) {
console.warn('invalid URL: ' + url)
throw err
}
}
const getActorAsId = () => {
let url: any = null
if (activity.actor.id !== undefined) {
url = activity.actor.id
}
if (typeof activity.actor === 'string') {
url = activity.actor
}
if (activity.actor instanceof URL) {
// This is used for testing only.
return activity.actor
}
if (url === null) {
throw new Error('unknown value: ' + JSON.stringify(activity.actor))
}
try {
return new URL(url)
} catch (err) {
console.warn('invalid URL: ' + url)
throw err
}
}
console.log(activity)
switch (activity.type) {
case 'Update': {
requireComplexObject()
const actorId = getActorAsId()
const objectId = getObjectAsId()
// check current object
const object = await objects.getObjectBy(db, 'original_object_id', objectId.toString())
if (object === null) {
throw new Error(`object ${objectId} does not exist`)
}
if (actorId.toString() !== object.originalActorId) {
throw new Error('actorid mismatch when updating object')
}
const updated = await updateObject(db, activity.object, object.id)
if (!updated) {
throw new Error('could not update object in database')
}
break
}
// https://www.w3.org/TR/activitypub/#create-activity-inbox
case 'Create': {
requireComplexObject()
const actorId = getActorAsId()
// FIXME: download any attachment Objects
let recipients: Array<string> = []
if (Array.isArray(activity.to)) {
recipients = [...recipients, ...activity.to]
}
if (Array.isArray(activity.cc)) {
recipients = [...recipients, ...activity.cc]
}
const objectId = getObjectAsId()
const obj = await createObject(domain, activity.object, db, actorId, objectId)
if (obj === null) {
break
}
createdObjects.push(obj)
const actor = await actors.getAndCache(actorId, db)
// This note is actually a reply to another one, record it in the replies
// table.
if (obj.type === 'Note' && obj.inReplyTo) {
const inReplyToObjectId = new URL(obj.inReplyTo)
let inReplyToObject = await objects.getObjectByOriginalId(db, inReplyToObjectId)
if (inReplyToObject === null) {
const remoteObject = await objects.get(inReplyToObjectId)
inReplyToObject = await objects.cacheObject(domain, db, remoteObject, actorId, inReplyToObjectId, false)
createdObjects.push(inReplyToObject)
}
await insertReply(db, actor, obj, inReplyToObject)
}
const fromActor = await actors.getAndCache(getActorAsId(), db)
// Add the object in the originating actor's outbox, allowing other
// actors on this instance to see the note in their timelines.
await addObjectInOutbox(db, fromActor, obj, activity.published)
if (mode === 'inbox') {
for (let i = 0, len = recipients.length; i < len; i++) {
const handle = parseHandle(extractID(domain, recipients[i]))
if (handle.domain !== null && handle.domain !== domain) {
console.warn('activity not for current instance')
continue
}
const person = await actors.getPersonById(db, actorURL(domain, handle.localPart))
if (person === null) {
console.warn(`person ${recipients[i]} not found`)
continue
}
// FIXME: check if the actor mentions the person
const notifId = await createNotification(db, 'mention', person, fromActor, obj)
await Promise.all([
await addObjectInInbox(db, person, obj),
await sendMentionNotification(db, fromActor, person, notifId),
])
}
}
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept
case 'Accept': {
requireComplexObject()
const actorId = getActorAsId()
const actor = await actors.getPersonById(db, activity.object.actor)
if (actor !== null) {
const follower = await actors.getAndCache(new URL(actorId), db)
await acceptFollowing(db, actor, follower)
} else {
console.warn(`actor ${activity.object.actor} not found`)
}
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow
case 'Follow': {
const objectId = getObjectAsId()
const actorId = getActorAsId()
const receiver = await actors.getPersonById(db, objectId)
if (receiver !== null) {
const originalActor = await actors.getAndCache(new URL(actorId), db)
const receiverAcct = `${receiver.preferredUsername}@${domain}`
await Promise.all([
addFollowing(db, originalActor, receiver, receiverAcct),
acceptFollowing(db, originalActor, receiver),
])
// Automatically send the Accept reply
const reply = accept.create(receiver, activity)
const signingKey = await getSigningKey(userKEK, db, receiver)
await deliverToActor(signingKey, receiver, originalActor, reply)
// Notify the user
const notifId = await insertFollowNotification(db, receiver, originalActor)
await sendFollowNotification(db, originalActor, receiver, notifId)
} else {
console.warn(`actor ${objectId} not found`)
}
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce
case 'Announce': {
const actorId = getActorAsId()
const objectId = getObjectAsId()
let obj: any = null
const localObject = await objects.getObjectById(db, objectId)
if (localObject === null) {
try {
// Object doesn't exists locally, we'll need to download it.
const remoteObject = await objects.get<Note>(objectId)
obj = await createObject(domain, remoteObject, db, actorId, objectId)
if (obj === null) {
break
}
createdObjects.push(obj)
} catch (err: any) {
console.warn(`failed to retrieve object ${objectId}: ${err.message}`)
break
}
} else {
// Object already exists locally, we can just use it.
obj = localObject
}
const fromActor = await actors.getAndCache(actorId, db)
// notify the user
const targetActor = await actors.getPersonById(db, new URL(obj.originalActorId))
if (targetActor === null) {
console.warn('object actor not found')
break
}
const notifId = await createNotification(db, 'reblog', targetActor, fromActor, obj)
await Promise.all([
// Add the object in the originating actor's outbox, allowing other
// actors on this instance to see the note in their timelines.
addObjectInOutbox(db, fromActor, obj, activity.published),
// Store the reblog for counting
insertReblog(db, fromActor, obj),
sendReblogNotification(db, fromActor, targetActor, notifId),
])
break
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like
case 'Like': {
const actorId = getActorAsId()
const objectId = getObjectAsId()
const obj = await objects.getObjectById(db, objectId)
if (obj === null || !obj.originalActorId) {
console.warn('unknown object')
break
}
const fromActor = await actors.getAndCache(actorId, db)
const targetActor = await actors.getPersonById(db, new URL(obj.originalActorId))
if (targetActor === null) {
console.warn('object actor not found')
break
}
const [notifId] = await Promise.all([
// Notify the user
createNotification(db, 'favourite', targetActor, fromActor, obj),
// Store the like for counting
insertLike(db, fromActor, obj),
])
await sendLikeNotification(db, fromActor, targetActor, notifId)
break
}
default:
console.warn(`Unsupported activity: ${activity.type}`)
}
return { createdObjects }
}
async function createObject(
domain: string,
obj: Object,
db: D1Database,
originalActorId: URL,
originalObjectId: URL
): Promise<Object | null> {
switch (obj.type) {
case 'Note': {
return objects.cacheObject(domain, db, obj, originalActorId, originalObjectId, false)
}
default: {
console.warn(`Unsupported Create object: ${obj.type}`)
return null
}
}
}

Wyświetl plik

@ -0,0 +1,7 @@
export type Activity = any
// Generate a unique ID. Note that currently the generated URL aren't routable.
export function uri(domain: string): URL {
const id = crypto.randomUUID()
return new URL('/ap/a/' + id, 'https://' + domain)
}

Wyświetl plik

@ -0,0 +1,16 @@
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like
import type { Object } from '../objects'
import type { Actor } from '../actors'
import type { Activity } from '.'
const Like = 'Like'
export function create(actor: Actor, object: URL): Activity {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: Like,
actor: actor.id,
object,
}
}

Wyświetl plik

@ -0,0 +1,15 @@
import type { Object } from '../objects'
import type { Actor } from '../actors'
import type { Activity } from '.'
import * as follow from './follow'
const UNDO = 'Undo'
export function create(actor: Actor, object: Object): Activity {
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: UNDO,
actor: actor.id,
object: follow.create(actor, object),
}
}

Wyświetl plik

@ -0,0 +1,16 @@
import type { Object } from '../objects'
import type { Actor } from '../actors'
import type { Activity } from '.'
import * as activity from '.'
const UPDATE = 'Update'
export function create(domain: string, actor: Actor, object: Object): Activity {
return {
'@context': ['https://www.w3.org/ns/activitystreams'],
id: activity.uri(domain),
type: UPDATE,
actor: actor.id,
object,
}
}

Wyświetl plik

@ -0,0 +1,24 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core'
const headers = {
accept: 'application/activity+json',
}
export async function getFollowingMetadata(actor: Actor): Promise<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 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>>()
}

Wyświetl plik

@ -0,0 +1,13 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
export async function addObjectInInbox(db: D1Database, actor: Actor, obj: Object) {
const id = crypto.randomUUID()
const out = await db
.prepare('INSERT INTO inbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString())
.run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
}

Wyświetl plik

@ -0,0 +1,237 @@
import { MastodonAccount } from 'wildebeest/backend/src/types/account'
import { defaultImages } from 'wildebeest/config/accounts'
import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops'
import type { Object } from '../objects'
const PERSON = 'Person'
const isTesting = typeof jest !== 'undefined'
export const emailSymbol = Symbol()
export function actorURL(domain: string, id: string): URL {
return new URL(`/ap/users/${id}`, 'https://' + domain)
}
function inboxURL(id: URL): URL {
return new URL(id + '/inbox')
}
function outboxURL(id: URL): URL {
return new URL(id + '/outbox')
}
function followingURL(id: URL): URL {
return new URL(id + '/following')
}
export function followersURL(id: URL): URL {
return new URL(id + '/followers')
}
// https://www.w3.org/TR/activitystreams-vocabulary/#actor-types
export interface Actor extends Object {
inbox: URL
outbox: URL
following: URL
followers: URL
[emailSymbol]: string
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person
export interface Person extends Actor {
publicKey: string
}
export async function get(url: string | URL): Promise<Actor> {
const headers = {
accept: 'application/activity+json',
}
const res = await fetch(url.toString(), { headers })
if (!res.ok) {
throw new Error(`${url} returned: ${res.status}`)
}
const data = await res.json<any>()
const actor: Actor = { ...data }
actor.id = new URL(data.id)
// This is mostly for testing where for convenience not all values
// are provided.
// TODO: eventually clean that to better match production.
if (data.inbox !== undefined) {
actor.inbox = new URL(data.inbox)
}
if (data.following !== undefined) {
actor.following = new URL(data.following)
}
if (data.followers !== undefined) {
actor.followers = new URL(data.followers)
}
if (data.outbox !== undefined) {
actor.outbox = new URL(data.outbox)
}
return actor
}
export async function getAndCache(url: URL, db: D1Database): Promise<Actor> {
const person = await getPersonById(db, url)
if (person !== null) {
return person
}
const actor = await get(url)
if (!actor.type || !actor.id) {
throw new Error('missing fields on Actor')
}
const properties = actor
const sql = `
INSERT INTO actors (id, type, properties)
VALUES (?, ?, ?)
`
const { success, error } = await db
.prepare(sql)
.bind(actor.id.toString(), actor.type, JSON.stringify(properties))
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
return actor
}
export async function getPersonByEmail(db: D1Database, email: string): Promise<Person | null> {
const stmt = db.prepare('SELECT * FROM actors WHERE email=? AND type=?').bind(email, PERSON)
const { results } = await stmt.all()
if (!results || results.length === 0) {
return null
}
const row: any = results[0]
return personFromRow(row)
}
type Properties = { [key: string]: Properties | string }
export async function createPerson(
domain: string,
db: D1Database,
userKEK: string,
email: string,
properties: Properties = {}
): Promise<URL> {
const userKeyPair = await generateUserKey(userKEK)
let privkey, salt
// Since D1 and better-sqlite3 behaviors don't exactly match, presumable
// because Buffer support is different in Node/Worker. We have to transform
// the values depending on the platform.
if (isTesting) {
privkey = Buffer.from(userKeyPair.wrappedPrivKey)
salt = Buffer.from(userKeyPair.salt)
} else {
privkey = [...new Uint8Array(userKeyPair.wrappedPrivKey)]
salt = [...new Uint8Array(userKeyPair.salt)]
}
if (properties.preferredUsername === undefined) {
const parts = email.split('@')
properties.preferredUsername = parts[0]
}
if (properties.preferredUsername !== undefined && typeof properties.preferredUsername !== 'string') {
throw new Error(
`preferredUsername should be a string, received ${JSON.stringify(properties.preferredUsername)} instead`
)
}
const id = actorURL(domain, properties.preferredUsername).toString()
const { success, error } = await db
.prepare(
'INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties) VALUES(?, ?, ?, ?, ?, ?, ?)'
)
.bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties))
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
return new URL(id)
}
export async function updateActorProperty(db: D1Database, actorId: URL, key: string, value: string) {
const { success, error } = await db
.prepare(`UPDATE actors SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`)
.bind(value, actorId.toString())
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function getPersonById(db: D1Database, id: URL): Promise<Person | null> {
const stmt = db.prepare('SELECT * FROM actors WHERE id=? AND type=?').bind(id.toString(), PERSON)
const { results } = await stmt.all()
if (!results || results.length === 0) {
return null
}
const row: any = results[0]
return personFromRow(row)
}
export function personFromRow(row: any): Person {
const icon: Object = {
type: 'Image',
mediaType: 'image/jpeg',
url: new URL(defaultImages.avatar),
id: new URL(row.id + '#icon'),
}
const image: Object = {
type: 'Image',
mediaType: 'image/jpeg',
url: new URL(defaultImages.header),
id: new URL(row.id + '#image'),
}
let publicKey = null
if (row.pubkey !== null) {
publicKey = {
id: row.id + '#main-key',
owner: row.id,
publicKeyPem: row.pubkey,
}
}
const id = new URL(row.id)
let domain = id.hostname
if (row.original_actor_id) {
domain = new URL(row.original_actor_id).hostname
}
return {
// Hidden values
[emailSymbol]: row.email,
name: row.preferredUsername,
icon,
image,
discoverable: true,
publicKey,
type: PERSON,
id,
published: new Date(row.cdate).toISOString(),
inbox: inboxURL(row.id),
outbox: outboxURL(row.id),
following: followingURL(row.id),
followers: followersURL(row.id),
url: new URL('@' + row.preferredUsername, 'https://' + domain),
// It's very possible that properties override the values set above.
// Almost guaranteed for remote user.
...JSON.parse(row.properties),
}
}

Wyświetl plik

@ -0,0 +1,56 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Activity } from 'wildebeest/backend/src/activitypub/activities'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { OrderedCollection, OrderedCollectionPage } from 'wildebeest/backend/src/activitypub/core'
export async function addObjectInOutbox(db: D1Database, actor: Actor, obj: Object, published_date?: string) {
const id = crypto.randomUUID()
let out: any = null
if (published_date !== undefined) {
out = await db
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date) VALUES(?, ?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString(), published_date)
.run()
} else {
out = await db
.prepare('INSERT INTO outbox_objects(id, actor_id, object_id) VALUES(?, ?, ?)')
.bind(id, actor.id.toString(), obj.id.toString())
.run()
}
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
}
const headers = {
accept: 'application/activity+json',
}
export async function getMetadata(actor: Actor): Promise<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)
collection.items = await loadItems(collection, 20)
return collection
}
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

@ -0,0 +1,15 @@
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

@ -0,0 +1,75 @@
// https://www.w3.org/TR/activitypub/#delivery
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import type { Activity } from './activities'
import type { Actor } from './actors'
import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage'
import { signRequest } from 'wildebeest/backend/src/utils/http-signing'
import { getFollowers } from 'wildebeest/backend/src/mastodon/follow'
const headers = {
'content-type': 'application/activity+json',
}
export async function deliverToActor(signingKey: CryptoKey, from: Actor, to: Actor, activity: Activity) {
const body = JSON.stringify(activity)
console.log({ body })
let req = new Request(to.inbox, {
method: 'POST',
body,
headers,
})
const digest = await generateDigestHeader(body)
req.headers.set('Digest', digest)
await signRequest(req, signingKey, new URL(from.id))
const res = await fetch(req)
if (!res.ok) {
const body = await res.text()
throw new Error(`delivery to ${to.inbox} returned ${res.status}: ${body}`)
}
{
const body = await res.text()
console.log(`${to.inbox} returned 200: ${body}`)
}
}
export async function deliverFollowers(db: D1Database, signingKey: CryptoKey, from: Actor, activity: Activity) {
const body = JSON.stringify(activity)
const followers = await getFollowers(db, from)
const promises = followers.map(async (id) => {
const follower = new URL(id)
// FIXME: When an actor follows another Actor we should download its object
// locally, so we can retrieve the Actor's inbox without a request.
const targetActor = await actors.getAndCache(follower, db)
if (targetActor === null) {
console.warn(`actor ${follower} not found`)
return
}
const req = new Request(targetActor.inbox, {
method: 'POST',
body,
headers,
})
const digest = await generateDigestHeader(body)
req.headers.set('Digest', digest)
await signRequest(req, signingKey, new URL(from.id))
const res = await fetch(req)
if (!res.ok) {
const body = await res.text()
console.error(`delivery to ${targetActor.inbox} returned ${res.status}: ${body}`)
return
}
{
const body = await res.text()
console.log(`${targetActor.inbox} returned 200: ${body}`)
}
})
await Promise.allSettled(promises)
}

Wyświetl plik

@ -0,0 +1,12 @@
import * as objects from '.'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
export const IMAGE = 'Image'
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-image
export interface Image extends objects.Document {}
export async function createImage(domain: string, db: D1Database, actor: Actor, properties: any): Promise<Image> {
const actorId = new URL(actor.id)
return (await objects.createObject(domain, db, IMAGE, properties, actorId, true)) as Image
}

Wyświetl plik

@ -0,0 +1,172 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { UUID } from 'wildebeest/backend/src/types'
// https://www.w3.org/TR/activitystreams-vocabulary/#object-types
export interface Object {
type: string
id: URL
url: URL
published?: string
icon?: Object
image?: Object
summary?: string
name?: string
mediaType?: string
content?: string
inReplyTo?: string
// Extension
preferredUsername?: string
// Internal
originalActorId?: string
originalObjectId?: string
mastodonId?: UUID
}
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-document
export interface Document extends Object {}
export function uri(domain: string, id: string): URL {
return new URL('/ap/o/' + id, 'https://' + domain)
}
export async function createObject(
domain: string,
db: D1Database,
type: string,
properties: any,
originalActorId: URL,
local: boolean
): Promise<Object> {
const uuid = crypto.randomUUID()
const apId = uri(domain, uuid).toString()
const row: any = await db
.prepare(
'INSERT INTO objects(id, type, properties, original_actor_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?) RETURNING *'
)
.bind(apId, type, JSON.stringify(properties), originalActorId.toString(), local ? 1 : 0, uuid)
.first()
return {
...properties,
type,
id: new URL(row.id),
mastodonId: row.mastodon_id,
published: new Date(row.cdate).toISOString(),
originalActorId: row.original_actor_id,
}
}
export async function get<T>(url: URL): Promise<T> {
const headers = {
accept: 'application/activity+json',
}
const res = await fetch(url, { headers })
if (!res.ok) {
throw new Error(`${url} returned: ${res.status}`)
}
return res.json<T>()
}
export async function cacheObject(
domain: string,
db: D1Database,
properties: any,
originalActorId: URL,
originalObjectId: URL,
local: boolean
): Promise<Object> {
const cachedObject = await getObjectBy(db, 'original_object_id', originalObjectId.toString())
if (cachedObject !== null) {
return cachedObject
}
const uuid = crypto.randomUUID()
const apId = uri(domain, uuid).toString()
const row: any = await db
.prepare(
'INSERT INTO objects(id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING *'
)
.bind(
apId,
properties.type,
JSON.stringify(properties),
originalActorId.toString(),
originalObjectId.toString(),
local ? 1 : 0,
uuid
)
.first()
{
const properties = JSON.parse(row.properties)
return {
published: new Date(row.cdate).toISOString(),
...properties,
type: row.type,
id: new URL(row.id),
mastodonId: row.mastodon_id,
originalActorId: row.original_actor_id,
originalObjectId: row.original_object_id,
}
}
}
export async function updateObject(db: D1Database, properties: any, id: URL): Promise<boolean> {
const res: any = await db
.prepare('UPDATE objects SET properties = ? WHERE id = ?')
.bind(JSON.stringify(properties), id.toString())
.run()
// TODO: D1 doesn't return changes at the moment
// return res.changes === 1
return true
}
export async function getObjectById(db: D1Database, id: string | URL): Promise<Object | null> {
return getObjectBy(db, 'id', id.toString())
}
export async function getObjectByOriginalId(db: D1Database, id: string | URL): Promise<Object | null> {
return getObjectBy(db, 'original_object_id', id.toString())
}
export async function getObjectByMastodonId(db: D1Database, id: UUID): Promise<Object | null> {
return getObjectBy(db, 'mastodon_id', id)
}
export async function getObjectBy(db: D1Database, key: string, value: string): Promise<Object | null> {
const query = `
SELECT *
FROM objects
WHERE objects.${key}=?
`
const { results, success, error } = await db.prepare(query).bind(value).all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results || results.length === 0) {
return null
}
const result: any = results[0]
const properties = JSON.parse(result.properties)
return {
published: new Date(result.cdate).toISOString(),
...properties,
type: result.type,
id: new URL(result.id),
mastodonId: result.mastodon_id,
originalActorId: result.original_actor_id,
originalObjectId: result.original_object_id,
}
}

Wyświetl plik

@ -0,0 +1,52 @@
// https://www.w3.org/TR/activitystreams-vocabulary/#object-types
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { Document } from 'wildebeest/backend/src/activitypub/objects'
import { followersURL } from 'wildebeest/backend/src/activitypub/actors'
import * as objects from '.'
const NOTE = 'Note'
export const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'
// https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note
export interface Note extends objects.Object {
content: string
attributedTo?: string
summary?: string
inReplyTo?: string
replies?: string
to: Array<string>
attachment: Array<Document>
cc?: Array<string>
tag?: Array<string>
}
export async function createPublicNote(
domain: string,
db: D1Database,
content: string,
actor: Actor,
attachment: Array<Document> = [],
extraProperties: any = {}
): Promise<Note> {
const actorId = new URL(actor.id)
const properties = {
attributedTo: actorId,
content,
to: [PUBLIC],
cc: [followersURL(actorId)],
// FIXME: stub values
inReplyTo: null,
replies: null,
sensitive: false,
summary: null,
tag: [],
attachment,
...extraProperties,
}
return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note
}

Wyświetl plik

@ -0,0 +1,59 @@
export type InstanceConfig = {
title?: string
email?: string
description?: string
accessAud?: string
accessDomain?: string
}
export async function configure(db: D1Database, data: InstanceConfig) {
const sql = `
INSERT INTO instance_config
VALUES ('title', ?),
('email', ?),
('description', ?);
`
const { success, error } = await db.prepare(sql).bind(data.title, data.email, data.description).run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function configureAccess(db: D1Database, domain: string, aud: string) {
const sql = `
INSERT INTO instance_config
VALUES ('accessAud', ?), ('accessDomain', ?);
`
const { success, error } = await db.prepare(sql).bind(aud, domain).run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function generateVAPIDKeys(db: D1Database) {
const keyPair = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair
const jwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey)
const sql = `
INSERT INTO instance_config
VALUES ('vapid_jwk', ?);
`
const { success, error } = await db.prepare(sql).bind(JSON.stringify(jwk)).run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function get(db: D1Database, name: string): Promise<string> {
const row: any = await db.prepare('SELECT value FROM instance_config WHERE key = ?').bind(name).first()
if (!row) {
throw new Error(`configuration not found: ${name}`)
}
return row.value
}

Wyświetl plik

@ -0,0 +1,38 @@
type ErrorResponse = {
error: string
error_description?: string
}
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'content-type, authorization',
'content-type': 'application/json',
} as const
function generateErrorResponse(error: string, status: number, errorDescription?: string): Response {
const res: ErrorResponse = {
error: `${error}. ` + 'If the problem persists please contact your instance administrator.',
...(errorDescription ? { error_description: errorDescription } : {}),
}
return new Response(JSON.stringify(res), { headers, status })
}
export function notAuthorized(error: string, descr?: string): Response {
return generateErrorResponse(`An error occurred (${error})`, 401, descr)
}
export function domainNotAuthorized(): Response {
return generateErrorResponse(`Domain is not authorizated`, 403)
}
export function userConflict(): Response {
return generateErrorResponse(`User already exists or conflicts`, 403)
}
export function timelineMissing(): Response {
return generateErrorResponse(`The timeline is invalid or being regenerated`, 404)
}
export function clientUnknown(): Response {
return generateErrorResponse(`The client is unknown or invalid`, 403)
}

Wyświetl plik

@ -0,0 +1,95 @@
import { MastodonAccount } from 'wildebeest/backend/src/types/account'
import { unwrapPrivateKey } from 'wildebeest/backend/src/utils/key-ops'
import type { Actor } from '../activitypub/actors'
import { defaultImages } from 'wildebeest/config/accounts'
import * as apOutbox from 'wildebeest/backend/src/activitypub/actors/outbox'
import * as apFollow from 'wildebeest/backend/src/activitypub/actors/follow'
function toMastodonAccount(acct: string, res: Actor): MastodonAccount {
let avatar = defaultImages.avatar
let header = defaultImages.header
if (res.icon !== undefined && typeof res.icon.url === 'string') {
avatar = res.icon.url
}
if (res.image !== undefined && typeof res.image.url === 'string') {
header = res.image.url
}
return {
acct,
id: acct,
username: res.preferredUsername || res.name || 'unnamed',
url: res.url ? res.url.toString() : '',
display_name: res.name || res.preferredUsername || '',
note: res.summary || '',
created_at: res.published || new Date().toISOString(),
avatar,
avatar_static: avatar,
header,
header_static: header,
locked: false,
bot: false,
discoverable: true,
group: false,
emojis: [],
fields: [],
}
}
// Load an external user, using ActivityPub queries, and return it as a MastodonAccount
export async function loadExternalMastodonAccount(
acct: string,
res: Actor,
loadStats: boolean = false
): Promise<MastodonAccount> {
const account = toMastodonAccount(acct, res)
if (loadStats === true) {
account.statuses_count = (await apOutbox.getMetadata(res)).totalItems
account.followers_count = (await apFollow.getFollowersMetadata(res)).totalItems
account.following_count = (await apFollow.getFollowingMetadata(res)).totalItems
}
return account
}
// Load a local user and return it as a MastodonAccount
export async function loadLocalMastodonAccount(db: D1Database, res: Actor): Promise<MastodonAccount> {
const query = `
SELECT
(SELECT count(*)
FROM outbox_objects
INNER JOIN objects ON objects.id = outbox_objects.object_id
WHERE outbox_objects.actor_id=?
AND objects.type = 'Note') AS statuses_count,
(SELECT count(*)
FROM actor_following
WHERE actor_following.actor_id=?) AS following_count,
(SELECT count(*)
FROM actor_following
WHERE actor_following.target_actor_id=?) AS followers_count
`
// For local user the acct is only the local part of the email address.
const acct = res.preferredUsername || 'unknown'
const account = toMastodonAccount(acct, res)
const row: any = await db.prepare(query).bind(res.id.toString(), res.id.toString(), res.id.toString()).first()
account.statuses_count = row.statuses_count
account.followers_count = row.followers_count
account.following_count = row.following_count
return account
}
export async function getSigningKey(instanceKey: string, db: D1Database, actor: Actor): Promise<CryptoKey> {
const stmt = db.prepare('SELECT privkey, privkey_salt FROM actors WHERE id=?').bind(actor.id.toString())
const { privkey, privkey_salt } = (await stmt.first()) as any
return unwrapPrivateKey(instanceKey, new Uint8Array(privkey), new Uint8Array(privkey_salt))
}

Wyświetl plik

@ -0,0 +1,60 @@
import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops'
export interface Client {
id: string
secret: string
name: string
redirect_uris: string
website: string
scopes: string
}
export async function createClient(
db: D1Database,
name: string,
redirect_uris: string,
website: string,
scopes: string
): Promise<Client> {
const id = crypto.randomUUID()
const secretBytes = new Uint8Array(64)
crypto.getRandomValues(secretBytes)
const secret = arrayBufferToBase64(secretBytes.buffer)
const query = `
INSERT INTO clients (id, secret, name, redirect_uris, website, scopes)
VALUES (?, ?, ?, ?, ?, ?)
`
const { success, error } = await db.prepare(query).bind(id, secret, name, redirect_uris, website, scopes).run()
if (!success) {
throw new Error('SQL error: ' + error)
}
return {
id: id,
secret: secret,
name: name,
redirect_uris: redirect_uris,
website: website,
scopes: scopes,
}
}
export async function getClientById(db: D1Database, id: string): Promise<Client | null> {
const stmt = db.prepare('SELECT * FROM clients WHERE id=?').bind(id)
const { results } = await stmt.all()
if (!results || results.length === 0) {
return null
}
const row: any = results[0]
return {
id: id,
secret: row.secret,
name: row.name,
redirect_uris: row.redirect_uris,
website: row.website,
scopes: row.scopes,
}
}

Wyświetl plik

@ -0,0 +1,119 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
const STATE_PENDING = 'pending'
const STATE_ACCEPTED = 'accepted'
// Add a pending following
export async function addFollowing(db: D1Database, actor: Actor, target: Actor, targetAcct: string): Promise<string> {
const id = crypto.randomUUID()
const query = `
INSERT INTO actor_following (id, actor_id, target_actor_id, state, target_actor_acct)
VALUES (?, ?, ?, ?, ?)
`
const out = await db
.prepare(query)
.bind(id, actor.id.toString(), target.id.toString(), STATE_PENDING, targetAcct)
.run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
return id
}
// Accept the pending following request
export async function acceptFollowing(db: D1Database, actor: Actor, target: Actor) {
const id = crypto.randomUUID()
const query = `
UPDATE actor_following SET state=? WHERE actor_id=? AND target_actor_id=? AND state=?
`
const out = await db
.prepare(query)
.bind(STATE_ACCEPTED, actor.id.toString(), target.id.toString(), STATE_PENDING)
.run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
}
export async function removeFollowing(db: D1Database, actor: Actor, target: Actor) {
const query = `
DELETE FROM actor_following WHERE actor_id=? AND target_actor_id=?
`
const out = await db.prepare(query).bind(actor.id.toString(), target.id.toString()).run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
}
export async function getFollowingAcct(db: D1Database, actor: Actor): Promise<Array<string>> {
const query = `
SELECT target_actor_acct FROM actor_following WHERE actor_id=? AND state=?
`
const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_ACCEPTED).all()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
if (out.results !== null) {
return out.results.map((x: any) => x.target_actor_acct)
} else {
return []
}
}
export async function getFollowingRequestedAcct(db: D1Database, actor: Actor): Promise<Array<string>> {
const query = `
SELECT target_actor_acct FROM actor_following WHERE actor_id=? AND state=?
`
const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_PENDING).all()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
if (out.results !== null) {
return out.results.map((x: any) => x.target_actor_acct)
} else {
return []
}
}
export async function getFollowingId(db: D1Database, actor: Actor): Promise<Array<string>> {
const query = `
SELECT target_actor_id FROM actor_following WHERE actor_id=? AND state=?
`
const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_ACCEPTED).all()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
if (out.results !== null) {
return out.results.map((x: any) => x.target_actor_id)
} else {
return []
}
}
export async function getFollowers(db: D1Database, actor: Actor): Promise<Array<string>> {
const query = `
SELECT actor_id FROM actor_following WHERE target_actor_id=? AND state=?
`
const out: any = await db.prepare(query).bind(actor.id.toString(), STATE_ACCEPTED).all()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
if (out.results !== null) {
return out.results.map((x: any) => x.actor_id)
} else {
return []
}
}

Wyświetl plik

@ -0,0 +1,32 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
export async function insertLike(db: D1Database, actor: Actor, obj: Object) {
const id = crypto.randomUUID()
const query = `
INSERT INTO actor_favourites (id, actor_id, object_id)
VALUES (?, ?, ?)
`
const out = await db.prepare(query).bind(id, actor.id.toString(), obj.id.toString()).run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
}
export async function getLikes(db: D1Database, obj: Object): Promise<Array<string>> {
const query = `
SELECT actor_id FROM actor_favourites WHERE object_id=?
`
const out: any = await db.prepare(query).bind(obj.id.toString()).all()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
if (out.results !== null) {
return out.results.map((x: any) => x.actor_id)
} else {
return []
}
}

Wyświetl plik

@ -0,0 +1,239 @@
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import { urlToHandle } from 'wildebeest/backend/src/utils/handle'
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { generateWebPushMessage } from 'wildebeest/backend/src/webpush'
import { getPersonById } from 'wildebeest/backend/src/activitypub/actors'
import type { WebPushInfos, WebPushMessage } from 'wildebeest/backend/src/webpush/webpushinfos'
import { WebPushResult } from 'wildebeest/backend/src/webpush/webpushinfos'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { NotificationType, Notification } from 'wildebeest/backend/src/types/notification'
import type { Subscription } from 'wildebeest/backend/src/mastodon/subscription'
import { getSubscriptionForAllClients } from 'wildebeest/backend/src/mastodon/subscription'
import { getVAPIDKeys } from 'wildebeest/backend/src/mastodon/subscription'
import * as config from 'wildebeest/backend/src/config'
export async function createNotification(
db: D1Database,
type: NotificationType,
actor: Actor,
fromActor: Actor,
obj: Object
): Promise<string> {
const query = `
INSERT INTO actor_notifications (type, actor_id, from_actor_id, object_id)
VALUES (?, ?, ?, ?)
RETURNING id
`
const row: any = await db
.prepare(query)
.bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString())
.first()
return row.id
}
export async function insertFollowNotification(db: D1Database, actor: Actor, fromActor: Actor): Promise<string> {
const type: NotificationType = 'follow'
const query = `
INSERT INTO actor_notifications (type, actor_id, from_actor_id)
VALUES (?, ?, ?)
RETURNING id
`
const row: any = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first()
return row.id
}
export async function sendFollowNotification(db: D1Database, follower: Actor, actor: Actor, notificationId: string) {
const sub = await config.get(db, 'email')
const data = {
preferred_locale: 'en',
notification_type: 'follow',
notification_id: notificationId,
icon: follower.icon!.url,
title: 'New follower',
body: `${follower.name} is now following you`,
}
const message: WebPushMessage = {
data: JSON.stringify(data),
urgency: 'normal',
sub,
ttl: 60 * 24 * 7,
}
return sendNotification(db, actor, message)
}
export async function sendLikeNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) {
const sub = await config.get(db, 'email')
const data = {
preferred_locale: 'en',
notification_type: 'favourite',
notification_id: notificationId,
icon: fromActor.icon!.url,
title: 'New favourite',
body: `${fromActor.name} favourited your status`,
}
const message: WebPushMessage = {
data: JSON.stringify(data),
urgency: 'normal',
sub,
ttl: 60 * 24 * 7,
}
return sendNotification(db, actor, message)
}
export async function sendMentionNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) {
const sub = await config.get(db, 'email')
const data = {
preferred_locale: 'en',
notification_type: 'favourite',
notification_id: notificationId,
icon: fromActor.icon!.url,
title: 'New favourite',
body: `${fromActor.name} favourited your status`,
}
const message: WebPushMessage = {
data: JSON.stringify(data),
urgency: 'normal',
sub,
ttl: 60 * 24 * 7,
}
return sendNotification(db, actor, message)
}
export async function sendReblogNotification(db: D1Database, fromActor: Actor, actor: Actor, notificationId: string) {
const sub = await config.get(db, 'email')
const data = {
preferred_locale: 'en',
notification_type: 'reblog',
notification_id: notificationId,
icon: fromActor.icon!.url,
title: 'New boost',
body: `${fromActor.name} boosted your status`,
}
const message: WebPushMessage = {
data: JSON.stringify(data),
urgency: 'normal',
sub,
ttl: 60 * 24 * 7,
}
return sendNotification(db, actor, message)
}
async function sendNotification(db: D1Database, actor: Actor, message: WebPushMessage) {
const vapidKeys = await getVAPIDKeys(db)
const subscriptions = await getSubscriptionForAllClients(db, actor)
const promises = subscriptions.map(async (subscription) => {
const device: WebPushInfos = {
endpoint: subscription.gateway.endpoint,
key: subscription.gateway.keys.p256dh,
auth: subscription.gateway.keys.auth,
}
const result = await generateWebPushMessage(message, device, vapidKeys)
if (result !== WebPushResult.Success) {
throw new Error('failed to send push notification')
}
})
await Promise.allSettled(promises)
}
export async function getNotifications(db: D1Database, actor: Actor): Promise<Array<Notification>> {
const query = `
SELECT
objects.*,
actor_notifications.type,
actor_notifications.actor_id,
actor_notifications.from_actor_id as notif_from_actor_id,
actor_notifications.cdate as notif_cdate,
actor_notifications.id as notif_id
FROM actor_notifications
LEFT JOIN objects ON objects.id=actor_notifications.object_id
WHERE actor_id=?
ORDER BY actor_notifications.cdate DESC
LIMIT 20
`
const stmt = db.prepare(query).bind(actor.id.toString())
const { results, success, error } = await stmt.all()
if (!success) {
throw new Error('SQL error: ' + error)
}
const out: Array<Notification> = []
if (!results || results.length === 0) {
return []
}
for (let i = 0, len = results.length; i < len; i++) {
const result = results[i] as any
const properties = JSON.parse(result.properties)
const notifFromActorId = new URL(result.notif_from_actor_id)
const notifFromActor = await getPersonById(db, notifFromActorId)
if (!notifFromActor) {
console.warn('unknown actor')
continue
}
const acct = urlToHandle(notifFromActorId)
const notifFromAccount = await loadExternalMastodonAccount(acct, notifFromActor)
const notif: Notification = {
id: result.notif_id.toString(),
type: result.type,
created_at: new Date(result.notif_cdate).toISOString(),
account: notifFromAccount,
}
if (result.type === 'mention' || result.type === 'favourite') {
const actorId = new URL(result.original_actor_id)
const actor = await actors.getAndCache(actorId, db)
const acct = urlToHandle(actorId)
const account = await loadExternalMastodonAccount(acct, actor)
notif.status = {
id: result.mastodon_id,
content: properties.content,
uri: result.id,
created_at: new Date(result.cdate).toISOString(),
emojis: [],
media_attachments: [],
tags: [],
mentions: [],
account,
// TODO: stub values
visibility: 'public',
spoiler_text: '',
}
}
out.push(notif)
}
return out
}
export async function pregenerateNotifications(db: D1Database, cache: KVNamespace, actor: Actor) {
const notifications = await getNotifications(db, actor)
await cache.put(actor.id + '/notifications', JSON.stringify(notifications))
}

Wyświetl plik

@ -0,0 +1,34 @@
// Also known as boost.
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
export async function insertReblog(db: D1Database, actor: Actor, obj: Object) {
const id = crypto.randomUUID()
const query = `
INSERT INTO actor_reblogs (id, actor_id, object_id)
VALUES (?, ?, ?)
`
const out = await db.prepare(query).bind(id, actor.id.toString(), obj.id.toString()).run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
}
export async function getReblogs(db: D1Database, obj: Object): Promise<Array<string>> {
const query = `
SELECT actor_id FROM actor_reblogs WHERE object_id=?
`
const out: any = await db.prepare(query).bind(obj.id.toString()).all()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
if (out.results !== null) {
return out.results.map((x: any) => x.actor_id)
} else {
return []
}
}

Wyświetl plik

@ -0,0 +1,58 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import { toMastodonStatusFromRow } from './status'
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
export async function insertReply(db: D1Database, actor: Actor, obj: Object, inReplyToObj: Object) {
const id = crypto.randomUUID()
const query = `
INSERT INTO actor_replies (id, actor_id, object_id, in_reply_to_object_id)
VALUES (?, ?, ?, ?)
`
const { success, error } = await db
.prepare(query)
.bind(id, actor.id.toString(), obj.id.toString(), inReplyToObj.id.toString())
.run()
if (!success) {
throw new Error('SQL error: ' + error)
}
}
export async function getReplies(domain: string, db: D1Database, obj: Object): Promise<Array<MastodonStatus>> {
const QUERY = `
SELECT objects.*,
actors.id as actor_id,
actors.cdate as actor_cdate,
actors.properties as actor_properties,
actor_replies.actor_id as publisher_actor_id,
(SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count,
(SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count,
(SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count
FROM actor_replies
INNER JOIN objects ON objects.id=actor_replies.object_id
INNER JOIN actors ON actors.id=actor_replies.actor_id
WHERE actor_replies.in_reply_to_object_id=?
ORDER by actor_replies.cdate DESC
LIMIT ?
`
const DEFAULT_LIMIT = 20
const { success, error, results } = await db.prepare(QUERY).bind(obj.id.toString(), DEFAULT_LIMIT).all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results) {
return []
}
const out: Array<MastodonStatus> = []
for (let i = 0, len = results.length; i < len; i++) {
const status = await toMastodonStatusFromRow(domain, db, results[i])
if (status !== null) {
out.push(status)
}
}
return out
}

Wyświetl plik

@ -0,0 +1,176 @@
import type { Handle } from '../utils/parse'
import type { MediaAttachment } from 'wildebeest/backend/src/types/media'
import type { UUID } from 'wildebeest/backend/src/types'
import { getObjectByMastodonId, getObjectById } from 'wildebeest/backend/src/activitypub/objects'
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
import type { Note } from 'wildebeest/backend/src/activitypub/objects/note'
import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import * as objects from 'wildebeest/backend/src/activitypub/objects'
import * as media from 'wildebeest/backend/src/media/'
import type { MastodonStatus } from 'wildebeest/backend/src/types'
import { parseHandle } from '../utils/parse'
import { urlToHandle } from '../utils/handle'
import { getLikes } from './like'
import { getReblogs } from './reblog'
export function getMentions(input: string): Array<Handle> {
const mentions: Array<Handle> = []
for (let i = 0, len = input.length; i < len; i++) {
if (input[i] === '@') {
i++
let buffer = ''
while (i < len && /[^\s<]/.test(input[i])) {
buffer += input[i]
i++
}
mentions.push(parseHandle(buffer))
}
}
return mentions
}
export async function toMastodonStatusFromObject(db: D1Database, obj: Note): Promise<MastodonStatus | null> {
if (obj.originalActorId === undefined) {
console.warn('missing `obj.originalActorId`')
return null
}
const actorId = new URL(obj.originalActorId)
const actor = await actors.getAndCache(actorId, db)
const acct = urlToHandle(actorId)
const account = await loadExternalMastodonAccount(acct, actor)
const favourites = await getLikes(db, obj)
const reblogs = await getReblogs(db, obj)
const mediaAttachments: Array<MediaAttachment> = []
if (Array.isArray(obj.attachment)) {
for (let i = 0, len = obj.attachment.length; i < len; i++) {
const document = await getObjectById(db, obj.attachment[i].id)
if (document === null) {
console.warn('missing attachment object: ' + obj.attachment[i].id)
continue
}
mediaAttachments.push(media.fromObject(document))
}
}
return {
// Default values
emojis: [],
tags: [],
mentions: [],
// TODO: stub values
visibility: 'public',
spoiler_text: '',
media_attachments: mediaAttachments,
content: obj.content || '',
id: obj.mastodonId || '',
uri: obj.url,
created_at: obj.published || '',
account,
favourites_count: favourites.length,
reblogs_count: reblogs.length,
}
}
// toMastodonStatusFromRow makes assumption about what field are available on
// the `row` object. This funciton is only used for timelines, which is optimized
// SQL. Otherwise don't use this function.
export async function toMastodonStatusFromRow(
domain: string,
db: D1Database,
row: any
): Promise<MastodonStatus | null> {
if (row.publisher_actor_id === undefined) {
console.warn('missing `row.publisher_actor_id`')
return null
}
const properties = JSON.parse(row.properties)
const actorId = new URL(row.publisher_actor_id)
const author = actors.personFromRow({
id: row.actor_id,
cdate: row.actor_cdate,
properties: row.actor_properties,
})
const acct = urlToHandle(actorId)
const account = await loadExternalMastodonAccount(acct, author)
if (row.favourites_count === undefined || row.reblogs_count === undefined || row.replies_count === undefined) {
throw new Error('logic error; missing fields.')
}
const mediaAttachments: Array<MediaAttachment> = []
if (Array.isArray(properties.attachment)) {
for (let i = 0, len = properties.attachment.length; i < len; i++) {
const document = properties.attachment[i]
mediaAttachments.push(media.fromObject(document))
}
}
const status: MastodonStatus = {
id: row.mastodon_id,
uri: row.id,
created_at: new Date(row.cdate).toISOString(),
emojis: [],
media_attachments: mediaAttachments,
tags: [],
mentions: [],
account,
// TODO: stub values
visibility: 'public',
spoiler_text: '',
content: properties.content,
favourites_count: row.favourites_count,
reblogs_count: row.reblogs_count,
replies_count: row.replies_count,
reblogged: row.reblogged === 1,
favourited: row.favourited === 1,
}
if (properties.updated) {
status.edited_at = new Date(properties.updated).toISOString()
}
// FIXME: add unit tests for reblog
if (properties.attributedTo && properties.attributedTo !== row.publisher_actor_id) {
// The actor that introduced the Object in the instance isn't the same
// as the object has been attributed to. Likely means it's a reblog.
const actorId = new URL(properties.attributedTo)
const acct = urlToHandle(actorId)
const author = await actors.getAndCache(actorId, db)
const account = await loadExternalMastodonAccount(acct, author)
// Restore reblogged status
status.reblog = {
...status,
account,
}
}
return status
}
export async function getMastodonStatusById(db: D1Database, id: UUID): Promise<MastodonStatus | null> {
const obj = await getObjectByMastodonId(db, id)
if (obj === null) {
return null
}
return toMastodonStatusFromObject(db, obj as Note)
}

Wyświetl plik

@ -0,0 +1,138 @@
import type { Actor } from 'wildebeest/backend/src/activitypub/actors'
import type { JWK } from 'wildebeest/backend/src/webpush/jwk'
import { b64ToUrlEncoded, exportPublicKeyPair } from 'wildebeest/backend/src/webpush/util'
import { Client } from './client'
export type PushSubscription = {
endpoint: string
keys: {
p256dh: string
auth: string
}
}
export interface CreateRequest {
subscription: PushSubscription
data: {
alerts: {
mention?: boolean
status?: boolean
reblog?: boolean
follow?: boolean
follow_request?: boolean
favourite?: boolean
poll?: boolean
update?: boolean
admin_sign_up?: boolean
admin_report?: boolean
}
policy: string
}
}
export type Subscription = {
id: string
gateway: PushSubscription
}
export async function createSubscription(
db: D1Database,
actor: Actor,
client: Client,
req: CreateRequest
): Promise<Subscription> {
const id = crypto.randomUUID()
const query = `
INSERT INTO subscriptions (id, actor_id, client_id, endpoint, key_p256dh, key_auth, alert_mention, alert_status, alert_reblog, alert_follow, alert_follow_request, alert_favourite, alert_poll, alert_update, alert_admin_sign_up, alert_admin_report, policy)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
const out = await db
.prepare(query)
.bind(
id,
actor.id.toString(),
client.id,
req.subscription.endpoint,
req.subscription.keys.p256dh,
req.subscription.keys.auth,
req.data.alerts.mention ? 1 : 0,
req.data.alerts.status ? 1 : 0,
req.data.alerts.reblog ? 1 : 0,
req.data.alerts.follow ? 1 : 0,
req.data.alerts.follow_request ? 1 : 0,
req.data.alerts.favourite ? 1 : 0,
req.data.alerts.poll ? 1 : 0,
req.data.alerts.update ? 1 : 0,
req.data.alerts.admin_sign_up ? 1 : 0,
req.data.alerts.admin_report ? 1 : 0,
req.data.policy
)
.run()
if (!out.success) {
throw new Error('SQL error: ' + out.error)
}
return { id, gateway: req.subscription }
}
export async function getSubscription(db: D1Database, actor: Actor, client: Client): Promise<Subscription | null> {
const query = `
SELECT * FROM subscriptions WHERE actor_id=? AND client_id=?
`
const { success, error, results } = await db.prepare(query).bind(actor.id.toString(), client.id).all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results || results.length === 0) {
return null
}
const row: any = results[0]
return subscriptionFromRow(row)
}
export async function getSubscriptionForAllClients(db: D1Database, actor: Actor): Promise<Array<Subscription>> {
const query = `
SELECT * FROM subscriptions WHERE actor_id=? ORDER BY cdate DESC LIMIT 5
`
const { success, error, results } = await db.prepare(query).bind(actor.id.toString()).all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results) {
return []
}
return results.map(subscriptionFromRow)
}
function subscriptionFromRow(row: any): Subscription {
return {
id: row.id,
gateway: {
endpoint: row.endpoint,
keys: {
p256dh: row.key_p256dh,
auth: row.key_auth,
},
},
}
}
export async function getVAPIDKeys(db: D1Database): Promise<JWK> {
const row: any = await db.prepare("SELECT value FROM instance_config WHERE key = 'vapid_jwk'").first()
if (!row) {
throw new Error('missing VAPID keys')
}
const value = JSON.parse(row.value)
return value
}
export function VAPIDPublicKey(keys: JWK): string {
return b64ToUrlEncoded(exportPublicKeyPair(keys))
}

Wyświetl plik

@ -0,0 +1,124 @@
import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
import { getFollowingId } from 'wildebeest/backend/src/mastodon/follow'
import type { Actor } from 'wildebeest/backend/src/activitypub/actors/'
import { toMastodonStatusFromRow } from './status'
import { emailSymbol } from 'wildebeest/backend/src/activitypub/actors/'
export async function pregenerateTimelines(domain: string, db: D1Database, cache: KVNamespace, actor: Actor) {
const timeline = await getHomeTimeline(domain, db, actor)
await cache.put(actor.id + '/timeline/home', JSON.stringify(timeline))
}
export async function getHomeTimeline(domain: string, db: D1Database, actor: Actor): Promise<Array<MastodonStatus>> {
const following = await getFollowingId(db, actor)
// follow ourself to see our statuses in the our home timeline
following.push(actor.id.toString())
const QUERY = `
SELECT objects.*,
actors.id as actor_id,
actors.cdate as actor_cdate,
actors.properties as actor_properties,
outbox_objects.actor_id as publisher_actor_id,
(SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count,
(SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count,
(SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count,
(SELECT count(*) > 0 FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id AND actor_reblogs.actor_id=?) as reblogged,
(SELECT count(*) > 0 FROM actor_favourites WHERE actor_favourites.object_id=objects.id AND actor_favourites.actor_id=?) as favourited
FROM outbox_objects
INNER JOIN objects ON objects.id = outbox_objects.object_id
INNER JOIN actors ON actors.id = outbox_objects.actor_id
WHERE
objects.type = 'Note'
AND outbox_objects.actor_id IN (SELECT value FROM json_each(?))
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
ORDER by outbox_objects.published_date DESC
LIMIT ?
`
const DEFAULT_LIMIT = 20
const { success, error, results } = await db
.prepare(QUERY)
.bind(actor.id.toString(), actor.id.toString(), JSON.stringify(following), DEFAULT_LIMIT)
.all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results) {
return []
}
const out: Array<MastodonStatus> = []
for (let i = 0, len = results.length; i < len; i++) {
const status = await toMastodonStatusFromRow(domain, db, results[i])
if (status !== null) {
out.push(status)
}
}
return out
}
export enum LocalPreference {
NotSet,
OnlyLocal,
OnlyRemote,
}
function localPreferenceQuery(preference: LocalPreference): string {
switch (preference) {
case LocalPreference.NotSet:
return '1'
case LocalPreference.OnlyLocal:
return 'objects.local = 1'
case LocalPreference.OnlyRemote:
return 'objects.local = 0'
}
}
export async function getPublicTimeline(
domain: string,
db: D1Database,
localPreference: LocalPreference,
offset: number = 0
): Promise<Array<MastodonStatus>> {
const QUERY = `
SELECT objects.*,
actors.id as actor_id,
actors.cdate as actor_cdate,
actors.properties as actor_properties,
outbox_objects.actor_id as publisher_actor_id,
(SELECT count(*) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count,
(SELECT count(*) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count,
(SELECT count(*) FROM actor_replies WHERE actor_replies.in_reply_to_object_id=objects.id) as replies_count
FROM outbox_objects
INNER JOIN objects ON objects.id=outbox_objects.object_id
INNER JOIN actors ON actors.id=outbox_objects.actor_id
WHERE objects.type='Note'
AND ${localPreferenceQuery(localPreference)}
AND json_extract(objects.properties, '$.inReplyTo') IS NULL
ORDER by outbox_objects.published_date DESC
LIMIT ?1 OFFSET ?2
`
const DEFAULT_LIMIT = 20
const { success, error, results } = await db.prepare(QUERY).bind(DEFAULT_LIMIT, offset).all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results) {
return []
}
const out: Array<MastodonStatus> = []
for (let i = 0, len = results.length; i < len; i++) {
const status = await toMastodonStatusFromRow(domain, db, results[i])
if (status !== null) {
out.push(status)
}
}
return out
}

Wyświetl plik

@ -0,0 +1,51 @@
import type { MediaAttachment } from 'wildebeest/backend/src/types/media'
export type Config = {
accountId: string
apiToken: string
}
type APIResult<T> = {
success: boolean
errors: Array<any>
messages: Array<any>
result: T
}
type UploadResult = {
id: string
filename: string
metadata: object
requireSignedURLs: boolean
variants: Array<string>
uploaded: string
}
export async function uploadImage(file: File, config: Config): Promise<URL> {
const formData = new FormData()
const url = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1`
formData.set('file', file)
const res = await fetch(url, {
method: 'POST',
body: formData,
headers: {
authorization: 'Bearer ' + config.apiToken,
},
})
if (!res.ok) {
const body = await res.text()
throw new Error(`Cloudflare Images returned ${res.status}: ${body}`)
}
const data = await res.json<APIResult<UploadResult>>()
if (!data.success) {
const body = await res.text()
throw new Error(`Cloudflare Images returned ${res.status}: ${body}`)
}
// We assume there's only one variant for now.
const variant = data.result.variants[0]
return new URL(variant)
}

Wyświetl plik

@ -0,0 +1,81 @@
import type { MediaAttachment } from 'wildebeest/backend/src/types/media'
import type { Document } from 'wildebeest/backend/src/activitypub/objects'
import { IMAGE } from 'wildebeest/backend/src/activitypub/objects/image'
import type { Object } from 'wildebeest/backend/src/activitypub/objects'
export function fromObject(obj: Object): MediaAttachment {
if (obj.type === IMAGE) {
return fromObjectImage(obj)
} else if (obj.type === 'Document') {
if (obj.mediaType === 'image/jpeg' || obj.mediaType === 'image/png') {
return fromObjectImage(obj)
} else if (obj.mediaType === 'video/mp4') {
return {
url: new URL(obj.url),
preview_url: new URL(obj.url),
id: obj.url.toString(),
type: 'video',
meta: {
length: '0:01:28.65',
duration: 88.65,
fps: 24,
size: '1280x720',
width: 1280,
height: 720,
aspect: 1.7777777777777777,
audio_encode: 'aac (LC) (mp4a / 0x6134706D)',
audio_bitrate: '44100 Hz',
audio_channels: 'stereo',
original: {
width: 1280,
height: 720,
frame_rate: '6159375/249269',
duration: 88.654,
bitrate: 862056,
},
small: {
width: 400,
height: 225,
size: '400x225',
aspect: 1.7777777777777777,
},
},
description: 'test media description',
blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}',
}
} else {
throw new Error(`unsupported media type ${obj.type}: ${JSON.stringify(obj)}`)
}
} else {
throw new Error(`unsupported media type ${obj.type}: ${JSON.stringify(obj)}`)
}
}
function fromObjectImage(obj: Object): MediaAttachment {
return {
url: new URL(obj.url),
id: obj.mastodonId || obj.url.toString(),
preview_url: new URL(obj.url),
type: 'image',
meta: {
original: {
width: 640,
height: 480,
size: '640x480',
aspect: 1.3333333333333333,
},
small: {
width: 461,
height: 346,
size: '461x346',
aspect: 1.3323699421965318,
},
focus: {
x: -0.27,
y: 0.51,
},
},
description: 'test media description',
blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}',
}
}

Wyświetl plik

@ -0,0 +1,11 @@
/**
* A Pages middleware function that logs errors to the console and responds with 500 errors and stack-traces.
*/
export async function errorHandling(context: EventContext<unknown, any, any>) {
try {
return await context.next()
} catch (err: any) {
console.log(err.stack)
return new Response(`${err.message}\n${err.stack}`, { status: 500 })
}
}

Wyświetl plik

@ -0,0 +1,15 @@
/**
* A Pages middleware function that logs requests/responses to the console.
*/
export async function logger(context: EventContext<unknown, any, any>) {
const { method, url } = context.request
console.log(`-> ${method} ${url} `)
const res = await context.next()
if (context.data.connectedActor) {
console.log(`<- ${res.status} (${context.data.connectedActor.id})`)
} else {
console.log(`<- ${res.status}`)
}
return res
}

Wyświetl plik

@ -0,0 +1,120 @@
import * as access from 'wildebeest/backend/src/access'
import * as actors from 'wildebeest/backend/src/activitypub/actors'
import type { Env } from 'wildebeest/backend/src/types/env'
import type { Identity, ContextData } from 'wildebeest/backend/src/types/context'
import * as errors from 'wildebeest/backend/src/errors'
import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
async function loadContextData(db: D1Database, clientId: string, email: string, ctx: any): Promise<boolean> {
const query = `
SELECT
actors.*,
(SELECT value FROM instance_config WHERE key='accessAud') as accessAud,
(SELECT value FROM instance_config WHERE key='accessDomain') as accessDomain
FROM actors
WHERE email=? AND type='Person'
`
const { results, success, error } = await db.prepare(query).bind(email).all()
if (!success) {
throw new Error('SQL error: ' + error)
}
if (!results || results.length === 0) {
console.warn('no results')
return false
}
const row: any = results[0]
if (!row.id) {
console.warn('person not found')
return false
}
if (!row.accessDomain || !row.accessAud) {
console.warn('access configuration not found')
return false
}
const person = actors.personFromRow(row)
ctx.data.connectedActor = person
ctx.data.identity = { email }
ctx.data.clientId = clientId
ctx.data.accessDomain = row.accessDomain
ctx.data.accessAud = row.accessAud
return true
}
export async function main(context: EventContext<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',
'content-type': 'application/json',
}
return new Response('', { headers })
}
const url = new URL(context.request.url)
if (
url.pathname === '/oauth/token' ||
url.pathname === '/oauth/authorize' || // Cloudflare Access runs on /oauth/authorize
url.pathname === '/api/v1/instance' ||
url.pathname === '/api/v2/instance' ||
url.pathname === '/api/v1/apps' ||
url.pathname === '/api/v1/timelines/public' ||
url.pathname === '/api/v1/custom_emojis' ||
url.pathname === '/.well-known/webfinger' ||
url.pathname === '/start-instance' || // Access is required by the handler
url.pathname === '/start-instance-test-access' || // Access is required by the handler
url.pathname.startsWith('/ap/') // all ActivityPub endpoints
) {
return context.next()
} else {
try {
const authorization = context.request.headers.get('Authorization') || ''
const token = authorization.replace('Bearer ', '')
if (token === '') {
return errors.notAuthorized('missing authorization')
}
const parts = token.split('.')
const [clientId, ...jwtParts] = parts
const jwt = jwtParts.join('.')
const payload = access.getPayload(jwt)
if (!payload.email) {
return errors.notAuthorized('missing email')
}
// Load the user associated with the email in the payload *before*
// verifying the JWT validity.
// This is because loading the context will also load the access
// configuration, which are used to verify the JWT.
if (!(await loadContextData(context.env.DATABASE, clientId, payload.email, context))) {
return errors.notAuthorized('failed to load context data')
}
const validatate = access.generateValidator({
jwt,
domain: context.data.accessDomain,
aud: context.data.accessAud,
})
await validatate(context.request)
const identity = await access.getIdentity({ jwt, domain: context.data.accessDomain })
if (!identity) {
return errors.notAuthorized('failed to load identity')
}
return context.next()
} catch (err: any) {
console.warn(err.stack)
return errors.notAuthorized('unknown error occurred')
}
}
}

Wyświetl plik

@ -0,0 +1,70 @@
// https://docs.joinmastodon.org/entities/Account/
// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Account.java
export interface MastodonAccount {
id: string
username: string
acct: string
url: string
display_name: string
note: string
avatar: string
avatar_static: string
header: string
header_static: string
created_at: string
locked?: boolean
bot?: boolean
discoverable?: boolean
group?: boolean
followers_count?: number
following_count?: number
statuses_count?: number
emojis: Array<any>
fields: Array<Field>
}
// https://docs.joinmastodon.org/entities/Relationship/
// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Relationship.java
export type Relationship = {
id: string
}
export type Privacy = 'public' | 'unlisted' | 'private' | 'direct'
// https://docs.joinmastodon.org/entities/Account/#CredentialAccount
export interface CredentialAccount extends MastodonAccount {
source: {
note: string
fields: Array<Field>
privacy: Privacy
sensitive: boolean
language: string
follow_requests_count: number
}
role: Role
}
// https://docs.joinmastodon.org/entities/Role/
export type Role = {
id: string
name: string
color: string
position: number
// https://docs.joinmastodon.org/entities/Role/#permission-flags
permissions: number
highlighted: boolean
created_at: string
updated_at: string
}
export type Field = {
name: string
value: string
verified_at?: string
}

Wyświetl plik

@ -0,0 +1,21 @@
// https://docs.joinmastodon.org/entities/Instance/
export type InstanceConfig = {
uri: string
title: string
languages: Array<string>
email: string
description: string
short_description?: string
rules: Array<Rule>
}
// https://docs.joinmastodon.org/entities/Rule/
export type Rule = {
id: string
text: string
}
export type DefaultImages = {
avatar: string
header: string
}

Wyświetl plik

@ -0,0 +1,21 @@
import type { MastodonAccount } from 'wildebeest/backend/src/types/account'
import type { Person } from 'wildebeest/backend/src/activitypub/actors'
export type Identity = {
email: string
}
export type ContextData = {
// ActivityPub Person object of the logged in user
connectedActor: Person
// Configure for Cloudflare Access
accessDomain: string
accessAud: string
// Object returned by Cloudflare Access' provider
identity: Identity
// Client or app identifier
clientId: string
}

Wyświetl plik

@ -0,0 +1,7 @@
export interface Env {
DATABASE: D1Database
KV_CACHE: KVNamespace
userKEK: string
CF_ACCOUNT_ID: string
CF_API_TOKEN: string
}

Wyświetl plik

@ -0,0 +1,4 @@
export * from './status'
export * from './account'
export type UUID = string

Wyświetl plik

@ -0,0 +1,11 @@
export type MediaType = 'unknown' | 'image' | 'gifv' | 'video' | 'audio'
export type MediaAttachment = {
id: string
type: MediaType
url: URL
preview_url: URL
meta: any
description: string
blurhash: string
}

Wyświetl plik

@ -0,0 +1,22 @@
import type { MastodonAccount } from 'wildebeest/backend/src/types/account'
import type { MastodonStatus } from 'wildebeest/backend/src/types/status'
export type NotificationType =
| 'mention'
| 'status'
| 'reblog'
| 'follow'
| 'follow_request'
| 'favourite'
| 'poll'
| 'update'
| 'admin.sign_up'
| 'admin.report'
export type Notification = {
id: string
type: NotificationType
created_at: string
account: MastodonAccount
status?: MastodonStatus
}

Wyświetl plik

@ -0,0 +1,34 @@
import type { MastodonAccount } from './account'
import type { MediaAttachment } from './media'
import type { UUID } from 'wildebeest/backend/src/types'
type Visibility = 'public' | 'unlisted' | 'private' | 'direct'
// https://docs.joinmastodon.org/entities/Status/
// https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java
export type MastodonStatus = {
id: UUID
uri: URL
created_at: string
account: MastodonAccount
content: string
visibility: Visibility
spoiler_text: string
emojis: Array<any>
media_attachments: Array<MediaAttachment>
mentions: Array<any>
tags: Array<any>
favourites_count?: number
reblogs_count?: number
reblog?: MastodonStatus
edited_at?: string
replies_count?: number
reblogged?: boolean
favourited?: boolean
}
// https://docs.joinmastodon.org/entities/Context/
export type Context = {
ancestors: Array<MastodonStatus>
descendants: Array<MastodonStatus>
}

Wyświetl plik

@ -0,0 +1,10 @@
// Naive way of transforming an Actor ObjectID into a handle like WebFinger uses
export function urlToHandle(input: URL): string {
const { pathname, host } = input
const parts = pathname.split('/')
if (parts.length === 0) {
throw new Error('malformed URL')
}
const localPart = parts[parts.length - 1]
return `${localPart}@${host}`
}

Wyświetl plik

@ -0,0 +1,170 @@
// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3.1
export type Parameter = 'created' | 'expires' | 'nonce' | 'alg' | 'keyid' | string
export type Component =
| '@method'
| '@target-uri'
| '@authority'
| '@scheme'
| '@request-target'
| '@path'
| '@query'
| '@query-params'
| string
export type ResponseComponent = '@status' | '@request-response' | Component
export type Parameters = { [name: Parameter]: string | number | Date | { [Symbol.toStringTag]: () => string } }
export type Algorithm = 'rsa-v1_5-sha256' | 'ecdsa-p256-sha256' | 'hmac-sha256' | 'rsa-pss-sha512'
export interface Signer {
(data: string): Promise<Uint8Array>
alg: Algorithm
}
export type SignOptions = {
components?: Component[]
parameters?: Parameters
keyId: string
signer: Signer
}
export const defaultSigningComponents: Component[] = ['@request-target', 'content-type', 'digest', 'content-digest']
const ALG_MAP: { [name: string]: string } = {
'rsa-v1_5-sha256': 'rsa-sha256',
}
export function extractHeader({ headers }: Request, header: string): string {
const lcHeader = header.toLowerCase()
const key = Array.from(headers.keys()).find((name) => name.toLowerCase() === lcHeader)
if (!key) {
throw new Error(`Unable to extract header "${header}" from message`)
}
let val = key ? headers.get(key) ?? '' : ''
if (Array.isArray(val)) {
val = val.join(', ')
}
return val.toString().replace(/\s+/g, ' ')
}
// see https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures-06#section-2.3
export function extractComponent(message: Request, component: string): string {
switch (component) {
case '@request-target': {
const { pathname, search } = new URL(message.url)
return `${message.method.toLowerCase()} ${pathname}${search}`
}
default:
throw new Error(`Unknown specialty component ${component}`)
}
}
export function buildSignedData(request: Request, components: Component[], params: Parameters): string {
const payloadParts: Parameters = {}
const paramNames = Object.keys(params)
if (components.includes('@request-target')) {
Object.assign(payloadParts, {
'(request-target)': extractComponent(request, '@request-target'),
})
}
if (paramNames.includes('created')) {
Object.assign(payloadParts, {
'(created)': params.created,
})
}
if (paramNames.includes('expires')) {
Object.assign(payloadParts, {
'(expires)': params.expires,
})
}
components.forEach((name) => {
if (!name.startsWith('@')) {
Object.assign(payloadParts, {
[name.toLowerCase()]: extractHeader(request, name),
})
}
})
return Object.entries(payloadParts)
.map(([name, value]) => {
if (value instanceof Date) {
return `${name}: ${Math.floor(value.getTime() / 1000)}`
} else {
return `${name}: ${value.toString()}`
}
})
.join('\n')
}
export function buildSignatureInputString(componentNames: Component[], parameters: Parameters): string {
const params: Parameters = Object.entries(parameters).reduce((normalised, [name, value]) => {
switch (name.toLowerCase()) {
case 'keyid':
return Object.assign(normalised, {
keyId: value,
})
case 'alg':
return Object.assign(normalised, {
algorithm: ALG_MAP[value as string] ?? value,
})
default:
return Object.assign(normalised, {
[name]: value,
})
}
}, {})
const headers = []
const paramNames = Object.keys(params)
if (componentNames.includes('@request-target')) {
headers.push('(request-target)')
}
if (paramNames.includes('created')) {
headers.push('(created)')
}
if (paramNames.includes('expires')) {
headers.push('(expires)')
}
componentNames.forEach((name) => {
if (!name.startsWith('@')) {
headers.push(name.toLowerCase())
}
})
return `${Object.entries(params)
.map(([name, value]) => {
if (typeof value === 'number') {
return `${name}=${value}`
} else if (value instanceof Date) {
return `${name}=${Math.floor(value.getTime() / 1000)}`
} else {
return `${name}="${value.toString()}"`
}
})
.join(',')},headers="${headers.join(' ')}"`
}
function uint8ArrayToBase64(a: Uint8Array): string {
const a_s = Array.prototype.map.call(a, (c) => String.fromCharCode(c)).join(String())
return btoa(a_s)
}
export async function generateDigestHeader(body: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(body)
const hash = uint8ArrayToBase64(new Uint8Array(await crypto.subtle.digest('SHA-256', data)))
return `SHA-256=${hash}`
}
export async function sign(request: Request, opts: SignOptions): Promise<void> {
const signingComponents: Component[] = opts.components ?? defaultSigningComponents
const signingParams: Parameters = {
...opts.parameters,
keyid: opts.keyId,
alg: opts.signer.alg,
}
const signatureInputString = buildSignatureInputString(signingComponents, signingParams)
const dataToSign = buildSignedData(request, signingComponents, signingParams)
const signature = await opts.signer(dataToSign)
const sigBase64 = uint8ArrayToBase64(signature)
request.headers.set('Signature', `${signatureInputString},signature="${sigBase64}"`)
}

Wyświetl plik

@ -0,0 +1,36 @@
import { Algorithm, sign } from './http-signing-cavage'
import { str2ab } from './key-ops'
export async function signRequest(request: Request, key: CryptoKey, keyId: URL): Promise<void> {
const mySigner = async (data: string) =>
new Uint8Array(
await crypto.subtle.sign(
{
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256',
},
key,
str2ab(data as string)
)
)
mySigner.alg = 'hs2019' as Algorithm
if (!request.headers.has('Host')) {
const url = new URL(request.url)
request.headers.set('Host', url.host)
}
let components = ['@request-target', 'host']
if (request.method == 'POST') {
components.push('digest')
}
await sign(request, {
components: components,
parameters: {
created: Math.floor(Date.now() / 1000),
},
keyId: keyId.toString(),
signer: mySigner,
})
}

Wyświetl plik

@ -0,0 +1,18 @@
Copyright Joyent, Inc. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.

Wyświetl plik

@ -0,0 +1,365 @@
// @ts-nocheck
// Copyright 2012 Joyent, Inc. All rights reserved.
import { HEADER, HttpSignatureError, InvalidAlgorithmError, validateAlgorithm } from './utils'
///--- Globals
let State = {
New: 0,
Params: 1,
}
let ParamsState = {
Name: 0,
Quote: 1,
Value: 2,
Comma: 3,
Number: 4,
}
///--- Specific Errors
class ExpiredRequestError extends HttpSignatureError {
constructor(message: string) {
super(message, ExpiredRequestError)
}
}
class InvalidHeaderError extends HttpSignatureError {
constructor(message: string) {
super(message, InvalidHeaderError)
}
}
class InvalidParamsError extends HttpSignatureError {
constructor(message: string) {
super(message, InvalidParamsError)
}
}
class MissingHeaderError extends HttpSignatureError {
constructor(message: string) {
super(message, MissingHeaderError)
}
}
class StrictParsingError extends HttpSignatureError {
constructor(message: string) {
super(message, StrictParsingError)
}
}
type Options = {
clockSkew: number
headers: string[]
strict: boolean
}
export type ParsedSignature = {
signature: string
keyId: string
signingString: string
algorithm: string
}
///--- Exported API
/**
* Parses the 'Authorization' header out of an http.ServerRequest object.
*
* Note that this API will fully validate the Authorization header, and throw
* on any error. It will not however check the signature, or the keyId format
* as those are specific to your environment. You can use the options object
* to pass in extra constraints.
*
* As a response object you can expect this:
*
* {
* "scheme": "Signature",
* "params": {
* "keyId": "foo",
* "algorithm": "rsa-sha256",
* "headers": [
* "date" or "x-date",
* "digest"
* ],
* "signature": "base64"
* },
* "signingString": "ready to be passed to crypto.verify()"
* }
*
* @param {Object} request an http.ServerRequest.
* @param {Object} options an optional options object with:
* - clockSkew: allowed clock skew in seconds (default 300).
* - headers: required header names (def: date or x-date)
* - strict: should enforce latest spec parsing
* (default: false).
* @return {Object} parsed out object (see above).
* @throws {TypeError} on invalid input.
* @throws {InvalidHeaderError} on an invalid Authorization header error.
* @throws {InvalidParamsError} if the params in the scheme are invalid.
* @throws {MissingHeaderError} if the params indicate a header not present,
* either in the request headers from the params,
* or not in the params from a required header
* in options.
* @throws {StrictParsingError} if old attributes are used in strict parsing
* mode.
* @throws {ExpiredRequestError} if the value of date or x-date exceeds skew.
*/
export function parseRequest(request: Request, options?: Options): ParsedSignature {
if (options === undefined) {
options = {
clockSkew: 300,
headers: ['host', '(request-target)'],
strict: false,
}
}
if (request.method == 'POST') {
options.headers.push('digest')
}
let headers = [request.headers.has('x-date') ? 'x-date' : 'date']
if (options.headers !== undefined) {
headers = options.headers
}
let authz = request.headers.get(HEADER.AUTH) || request.headers.get(HEADER.SIG)
if (!authz) {
let errHeader = HEADER.AUTH + ' or ' + HEADER.SIG
throw new MissingHeaderError('no ' + errHeader + ' header ' + 'present in the request')
}
options.clockSkew = options.clockSkew || 300
let i = 0
let state = authz === request.headers.get(HEADER.SIG) ? State.Params : State.New
let substate = ParamsState.Name
let tmpName = ''
let tmpValue = ''
let parsed = {
scheme: authz === request.headers.get(HEADER.SIG) ? 'Signature' : '',
params: {},
signingString: '',
}
for (i = 0; i < authz.length; i++) {
let c = authz.charAt(i)
let code = c.charCodeAt(0)
switch (Number(state)) {
case State.New:
if (c !== ' ') parsed.scheme += c
else state = State.Params
break
case State.Params:
switch (Number(substate)) {
case ParamsState.Name:
// restricted name of A-Z / a-z
if (
(code >= 0x41 && code <= 0x5a) || // A-Z
(code >= 0x61 && code <= 0x7a)
) {
// a-z
tmpName += c
} else if (c === '=') {
if (tmpName.length === 0) throw new InvalidHeaderError('bad param format')
substate = ParamsState.Quote
} else {
throw new InvalidHeaderError('bad param format')
}
break
case ParamsState.Quote:
if (c === '"') {
tmpValue = ''
substate = ParamsState.Value
} else {
//number
substate = ParamsState.Number
code = c.charCodeAt(0)
if (code < 0x30 || code > 0x39) {
//character not in 0-9
throw new InvalidHeaderError('bad param format')
}
tmpValue = c
}
break
case ParamsState.Value:
if (c === '"') {
parsed.params[tmpName] = tmpValue
substate = ParamsState.Comma
} else {
tmpValue += c
}
break
case ParamsState.Number:
if (c === ',') {
parsed.params[tmpName] = parseInt(tmpValue, 10)
tmpName = ''
substate = ParamsState.Name
} else {
code = c.charCodeAt(0)
if (code < 0x30 || code > 0x39) {
//character not in 0-9
throw new InvalidHeaderError('bad param format')
}
tmpValue += c
}
break
case ParamsState.Comma:
if (c === ',') {
tmpName = ''
substate = ParamsState.Name
} else {
throw new InvalidHeaderError('bad param format')
}
break
default:
throw new Error('Invalid substate')
}
break
default:
throw new Error('Invalid substate')
}
}
if (!parsed.params.headers || parsed.params.headers === '') {
if (request.headers.has('x-date')) {
parsed.params.headers = ['x-date']
} else {
parsed.params.headers = ['date']
}
} else {
parsed.params.headers = parsed.params.headers.split(' ')
}
// Minimally validate the parsed object
if (!parsed.scheme || parsed.scheme !== 'Signature') throw new InvalidHeaderError('scheme was not "Signature"')
if (!parsed.params.keyId) throw new InvalidHeaderError('keyId was not specified')
if (!parsed.params.algorithm) throw new InvalidHeaderError('algorithm was not specified')
if (!parsed.params.signature) throw new InvalidHeaderError('signature was not specified')
if (['date', 'x-date', '(created)'].every((hdr) => parsed.params.headers.indexOf(hdr) < 0)) {
throw new MissingHeaderError('no signed date header')
}
// Check the algorithm against the official list
try {
validateAlgorithm(parsed.params.algorithm, 'rsa')
} catch (e) {
if (e instanceof InvalidAlgorithmError)
throw new InvalidParamsError(parsed.params.algorithm + ' is not ' + 'supported')
else throw e
}
// Build the signingString
for (i = 0; i < parsed.params.headers.length; i++) {
let h = parsed.params.headers[i].toLowerCase()
parsed.params.headers[i] = h
if (h === 'request-line') {
if (!options.strict) {
/*
* We allow headers from the older spec drafts if strict parsing isn't
* specified in options.
*/
parsed.signingString += request.method + ' ' + request.url + ' ' + request.cf?.httpProtocol
} else {
/* Strict parsing doesn't allow older draft headers. */
throw new StrictParsingError('request-line is not a valid header ' + 'with strict parsing enabled.')
}
} else if (h === '(request-target)') {
const { pathname, search } = new URL(request.url)
parsed.signingString += '(request-target): ' + `${request.method.toLowerCase()} ${pathname}${search}`
} else if (h === '(keyid)') {
parsed.signingString += '(keyid): ' + parsed.params.keyId
} else if (h === '(algorithm)') {
parsed.signingString += '(algorithm): ' + parsed.params.algorithm
} else if (h === '(opaque)') {
let opaque = parsed.params.opaque
if (opaque === undefined) {
throw new MissingHeaderError('opaque param was not in the ' + authzHeaderName + ' header')
}
parsed.signingString += '(opaque): ' + opaque
} else if (h === '(created)') {
parsed.signingString += '(created): ' + parsed.params.created
} else if (h === '(expires)') {
parsed.signingString += '(expires): ' + parsed.params.expires
} else {
let value = request.headers.get(h)
if (value === null) throw new MissingHeaderError(h + ' was not in the request')
parsed.signingString += h + ': ' + value
}
if (i + 1 < parsed.params.headers.length) parsed.signingString += '\n'
}
// Check against the constraints
let date
let skew
if (request.headers.date || request.headers.has('x-date')) {
if (request.headers.has('x-date')) {
date = new Date(request.headers.get('x-date') as string)
} else {
date = new Date(request.headers.date)
}
let now = new Date()
skew = Math.abs(now.getTime() - date.getTime())
if (skew > options.clockSkew * 1000) {
throw new ExpiredRequestError('clock skew of ' + skew / 1000 + 's was greater than ' + options.clockSkew + 's')
}
}
if (parsed.params.created) {
skew = parsed.params.created - Math.floor(Date.now() / 1000)
if (skew > options.clockSkew) {
throw new ExpiredRequestError(
'Created lies in the future (with ' + 'skew ' + skew + 's greater than allowed ' + options.clockSkew + 's'
)
}
if (Math.abs(skew) > options.clockSkew) {
throw new ExpiredRequestError(
'clock skew of ' + Math.abs(skew) + 's greater than allowed ' + options.clockSkew + 's'
)
}
}
if (parsed.params.expires) {
let expiredSince = Math.floor(Date.now() / 1000) - parsed.params.expires
if (expiredSince > options.clockSkew) {
throw new ExpiredRequestError(
'Request expired with skew ' + expiredSince + 's greater than allowed ' + options.clockSkew + 's'
)
}
}
headers.forEach(function (hdr) {
// Remember that we already checked any headers in the params
// were in the request, so if this passes we're good.
if (parsed.params.headers.indexOf(hdr.toLowerCase()) < 0)
throw new MissingHeaderError(hdr + ' was not a signed header')
})
parsed.params.algorithm = parsed.params.algorithm.toLowerCase()
parsed.algorithm = parsed.params.algorithm.toUpperCase()
parsed.keyId = parsed.params.keyId
parsed.opaque = parsed.params.opaque
parsed.signature = parsed.params.signature
return parsed
}

Wyświetl plik

@ -0,0 +1,53 @@
// Copyright 2012 Joyent, Inc. All rights reserved.
export const HASH_ALGOS = new Set<string>(['sha1', 'sha256', 'sha512'])
export const PK_ALGOS = new Set<string>(['rsa', 'dsa', 'ecdsa'])
export const HEADER = {
AUTH: 'authorization',
SIG: 'signature',
}
export class HttpSignatureError extends Error {
constructor(message: string, caller: any) {
super(message)
if (Error.captureStackTrace) Error.captureStackTrace(this, caller || HttpSignatureError)
this.message = message
this.name = caller.name
}
}
export class InvalidAlgorithmError extends HttpSignatureError {
constructor(message: string) {
super(message, InvalidAlgorithmError)
}
}
/**
* @param algorithm {String} the algorithm of the signature
* @param publicKeyType {String?} fallback algorithm (public key type) for
* hs2019
* @returns {[string, string]}
*/
export function validateAlgorithm(algorithm: string, publicKeyType?: string): [string, string] {
var alg = algorithm.toLowerCase().split('-')
if (alg[0] === 'hs2019') {
return publicKeyType !== undefined ? validateAlgorithm(publicKeyType + '-sha256') : ['hs2019', 'sha256']
}
if (alg.length !== 2) {
throw new InvalidAlgorithmError(alg[0].toUpperCase() + ' is not a ' + 'valid algorithm')
}
if (alg[0] !== 'hmac' && !PK_ALGOS.has(alg[0])) {
throw new InvalidAlgorithmError(alg[0].toUpperCase() + ' type keys ' + 'are not supported')
}
if (!HASH_ALGOS.has(alg[1])) {
throw new InvalidAlgorithmError(alg[1].toUpperCase() + ' is not a ' + 'supported hash algorithm')
}
return alg as [string, string]
}

Wyświetl plik

@ -0,0 +1,29 @@
import { importPublicKey, str2ab } from '../key-ops'
import { ParsedSignature } from './parser'
interface Profile {
publicKey: {
id: string
owner: string
publicKeyPem: string
}
}
export async function verifySignature(parsedSignature: ParsedSignature, key: CryptoKey): Promise<boolean> {
return crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
key,
str2ab(atob(parsedSignature.signature)),
str2ab(parsedSignature.signingString)
)
}
export async function fetchKey(parsedSignature: ParsedSignature): Promise<CryptoKey> {
const response = await fetch(parsedSignature.keyId, {
method: 'GET',
headers: { Accept: 'application/activity+json' },
})
const parsedResponse = (await response.json()) as Profile
return importPublicKey(parsedResponse.publicKey.publicKeyPem)
}

Wyświetl plik

@ -0,0 +1,163 @@
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = ''
const bytes = new Uint8Array(buffer)
const len = bytes.byteLength
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i])
}
return btoa(binary)
}
// from https://developers.google.com/web/updates/2012/06/How-to-convert-ArrayBuffer-to-and-from-String
export function str2ab(str: string): ArrayBuffer {
const buf = new ArrayBuffer(str.length)
const bufView = new Uint8Array(buf)
for (let i = 0, strLen = str.length; i < strLen; i++) {
bufView[i] = str.charCodeAt(i)
}
return buf
}
/*
Get some key material to use as input to the deriveKey method.
The key material is a password not stored in the DB.
*/
function getKeyMaterial(password: string): Promise<CryptoKey> {
const enc = new TextEncoder()
return crypto.subtle.importKey('raw', enc.encode(password), { name: 'PBKDF2' }, false, ['deriveBits', 'deriveKey'])
}
/*
Given some key material and some random salt
derive an AES-KW key using PBKDF2.
*/
function getKey(keyMaterial: CryptoKey, salt: ArrayBuffer): Promise<CryptoKey> {
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 10000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey']
)
}
/*
Wrap the given key.
*/
async function wrapCryptoKey(
keyToWrap: CryptoKey,
userKEK: string
): Promise<{ wrappedPrivKey: ArrayBuffer; salt: Uint8Array }> {
// get the key encryption key
const keyMaterial = await getKeyMaterial(userKEK)
const salt = crypto.getRandomValues(new Uint8Array(16))
const wrappingKey = await getKey(keyMaterial, salt)
const bytesToWrap = await crypto.subtle.exportKey('pkcs8', keyToWrap)
const wrappedPrivKey = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: salt,
},
wrappingKey,
bytesToWrap as ArrayBuffer
)
return { wrappedPrivKey, salt }
}
/*
Generate a new wrapped user key
*/
export async function generateUserKey(
userKEK: string
): Promise<{ wrappedPrivKey: ArrayBuffer; salt: Uint8Array; pubKey: string }> {
const keyPair = await crypto.subtle.generateKey(
{
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 4096,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['sign', 'verify']
)
const { wrappedPrivKey, salt } = await wrapCryptoKey((keyPair as CryptoKeyPair).privateKey, userKEK)
const pubKeyBuf = (await crypto.subtle.exportKey('spki', (keyPair as CryptoKeyPair).publicKey)) as ArrayBuffer
const pubKeyAsBase64 = arrayBufferToBase64(pubKeyBuf)
const pubKey = `-----BEGIN PUBLIC KEY-----\n${pubKeyAsBase64}\n-----END PUBLIC KEY-----`
return { wrappedPrivKey, salt, pubKey }
}
/*
Unwrap and import private key
*/
export async function unwrapPrivateKey(
userKEK: string,
wrappedPrivKey: ArrayBuffer,
salt: Uint8Array
): Promise<CryptoKey> {
const keyMaterial = await getKeyMaterial(userKEK)
const wrappingKey = await getKey(keyMaterial, salt)
const keyBytes = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: salt,
},
wrappingKey,
wrappedPrivKey
)
return await crypto.subtle.importKey(
'pkcs8',
keyBytes,
{
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256',
},
true,
['sign']
)
}
/*
Import public key
*/
export async function importPublicKey(exportedKey: string): Promise<CryptoKey> {
// fetch the part of the PEM string between header and footer
const trimmed = exportedKey.trim()
const pemHeader = '-----BEGIN PUBLIC KEY-----'
const pemFooter = '-----END PUBLIC KEY-----'
const pemContents = trimmed.substring(pemHeader.length, trimmed.length - pemFooter.length)
// base64 decode the string to get the binary data
const binaryDerString = atob(pemContents)
// convert from a binary string to an ArrayBuffer
const binaryDer = str2ab(binaryDerString)
return crypto.subtle.importKey(
'spki',
binaryDer,
{
name: 'RSASSA-PKCS1-v1_5',
hash: 'SHA-256',
},
true,
['verify']
)
}
const DEC = {
'-': '+',
_: '/',
'.': '=',
}
export function urlsafeBase64Decode(v: string) {
return atob(v.replace(/[-_.]/g, (m: string) => (DEC as any)[m]))
}

Wyświetl plik

@ -0,0 +1,21 @@
export type Handle = {
localPart: string
domain: string | null
}
export function parseHandle(query: string): Handle {
// Remove the leading @, if there's one.
if (query.startsWith('@')) {
query = query.substring(1)
}
// In case the handle has been URL encoded
query = decodeURIComponent(query)
const parts = query.split('@')
if (parts.length > 1) {
return { localPart: parts[0], domain: parts[1] }
} else {
return { localPart: parts[0], domain: null }
}
}

Wyświetl plik

@ -0,0 +1,48 @@
import { MastodonAccount } from '../types/account'
import * as config from 'wildebeest/backend/src/config'
import * as actors from '../activitypub/actors'
import type { Actor } from '../activitypub/actors'
export type WebFingerResponse = {
subject: string
aliases: Array<string>
links: Array<any>
}
const headers = {
accept: 'application/jrd+json',
}
export async function queryAcct(domain: string, acct: string): Promise<Actor | null> {
const url = await queryAcctLink(domain, acct)
if (url === null) {
return null
}
return actors.get(url)
}
export async function queryAcctLink(domain: string, acct: string): Promise<URL | null> {
const params = new URLSearchParams({ resource: `acct:${acct}` })
let res
try {
const url = new URL('/.well-known/webfinger?' + params, 'https://' + domain)
console.log('query', url.href)
res = await fetch(url, { headers })
if (!res.ok) {
throw new Error(`WebFinger API returned: ${res.status}`)
}
} catch (err) {
console.warn('failed to query WebFinger:', err)
return null
}
const data = await res.json<WebFingerResponse>()
for (let i = 0, len = data.links.length; i < len; i++) {
const link = data.links[i]
if (link.rel === 'self' && link.type === 'application/activity+json') {
return new URL(link.href)
}
}
return null
}

Wyświetl plik

@ -0,0 +1,18 @@
export async function hmacSign(ikm: Uint8Array | ArrayBuffer, input: ArrayBuffer): Promise<ArrayBuffer> {
const key = await crypto.subtle.importKey('raw', ikm, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
return await crypto.subtle.sign('HMAC', key, input)
}
export async function hkdfGenerate(
ikm: ArrayBuffer,
salt: Uint8Array,
info: Uint8Array,
byteLength: number
): Promise<ArrayBuffer> {
const fullInfoBuffer = new Uint8Array(info.byteLength + 1)
fullInfoBuffer.set(info, 0)
fullInfoBuffer.set(new Uint8Array(1).fill(1), info.byteLength)
const prk = await hmacSign(salt, ikm)
const nextPrk = await hmacSign(prk, fullInfoBuffer)
return nextPrk.slice(0, byteLength)
}

Wyświetl plik

@ -0,0 +1,48 @@
import type { JWK } from './jwk'
import { WebPushInfos, WebPushMessage, WebPushResult } from './webpushinfos'
import { generateAESGCMEncryptedMessage } from './message'
import { generateV1Headers } from './vapid'
export async function generateWebPushMessage(
message: WebPushMessage,
deviceData: WebPushInfos,
applicationServerKeys: JWK
): Promise<WebPushResult> {
const [authHeaders, encryptedPayloadDetails] = await Promise.all([
generateV1Headers(deviceData.endpoint, applicationServerKeys, message.sub),
generateAESGCMEncryptedMessage(message.data, deviceData),
])
const headers: { [headerName: string]: string } = { ...authHeaders }
headers['Encryption'] = `salt=${encryptedPayloadDetails.salt}`
headers['Crypto-Key'] = `dh=${encryptedPayloadDetails.publicServerKey};${headers['Crypto-Key']}`
headers['Content-Encoding'] = 'aesgcm'
headers['Content-Type'] = 'application/octet-stream'
// setup message headers
headers['TTL'] = `${message.ttl}`
headers['Urgency'] = `${message.urgency}`
const res = await fetch(deviceData.endpoint, {
method: 'POST',
headers,
body: encryptedPayloadDetails.cipherText,
})
switch (res.status) {
case 200: // http ok
case 201: // http created
case 204: // http no content
return WebPushResult.Success
case 400: // http bad request
case 401: // http unauthorized
case 404: // http not found
case 410: // http gone
return WebPushResult.NotSubscribed
}
console.warn(`WebPush res: ${res.status} body: ${await res.text()}`)
return WebPushResult.Error
}

Wyświetl plik

@ -0,0 +1,9 @@
export interface JWK {
crv: string
kty: string
key_ops: string[]
ext: boolean
d: string
x: string
y: string
}

Wyświetl plik

@ -0,0 +1,174 @@
import type { JWK } from './jwk'
import type { WebPushInfos } from './webpushinfos'
import {
b64ToUrlEncoded,
cryptoKeysToUint8Array,
exportPublicKeyPair,
joinUint8Arrays,
stringToU8Array,
u8ToString,
} from './util'
import { hkdfGenerate } from './hkdf'
import { urlsafeBase64Decode } from 'wildebeest/backend/src/utils/key-ops'
const encoder = new TextEncoder()
type mKeyPair = {
publicKey: CryptoKey
privateKey: CryptoKey
}
async function generateSalt(): Promise<Uint8Array> {
return crypto.getRandomValues(new Uint8Array(16))
}
async function getSubKeyAsCryptoKey(subscription: WebPushInfos): Promise<CryptoKey> {
const key = urlsafeBase64Decode(subscription.key)
const publicKey = await crypto.subtle.importKey(
'jwk',
{
kty: 'EC',
crv: 'P-256',
x: b64ToUrlEncoded(btoa(key.slice(1, 33))),
y: b64ToUrlEncoded(btoa(key.slice(33, 65))),
ext: true,
},
{
name: 'ECDH',
namedCurve: 'P-256',
},
true,
[]
)
return publicKey
}
async function getSharedSecret(subscription: WebPushInfos, serverKeys: mKeyPair): Promise<ArrayBuffer> {
const publicKey = await getSubKeyAsCryptoKey(subscription)
const algorithm = {
name: 'ECDH',
namedCurve: 'P-256',
public: publicKey,
}
return await crypto.subtle.deriveBits(algorithm, serverKeys.privateKey, 256)
}
export async function generateContext(subscription: WebPushInfos, serverKeys: mKeyPair): Promise<Uint8Array> {
const subKey = await getSubKeyAsCryptoKey(subscription)
const [clientPublicKey, serverPublicKey] = await Promise.all([
cryptoKeysToUint8Array(subKey).then((key) => key.publicKey),
cryptoKeysToUint8Array(serverKeys.publicKey).then((key) => key.publicKey),
])
const labelUnit8Array = stringToU8Array('P-256\x00')
const clientPublicKeyLengthUnit8Array = new Uint8Array(2)
clientPublicKeyLengthUnit8Array[0] = 0x00
clientPublicKeyLengthUnit8Array[1] = clientPublicKey.byteLength
const serverPublicKeyLengthBuffer = new Uint8Array(2)
serverPublicKeyLengthBuffer[0] = 0x00
serverPublicKeyLengthBuffer[1] = serverPublicKey.byteLength
return joinUint8Arrays([
labelUnit8Array,
clientPublicKeyLengthUnit8Array,
clientPublicKey,
serverPublicKeyLengthBuffer,
serverPublicKey,
])
}
async function generatePRK(subscription: WebPushInfos, serverKeys: mKeyPair): Promise<ArrayBuffer> {
const sharedSecret = await getSharedSecret(subscription, serverKeys)
const token = 'Content-Encoding: auth\x00'
const authInfoUint8Array = stringToU8Array(token)
return await hkdfGenerate(
sharedSecret,
stringToU8Array(urlsafeBase64Decode(subscription.auth)),
authInfoUint8Array,
32
)
}
async function generateCEKInfo(subscription: WebPushInfos, serverKeys: mKeyPair): Promise<Uint8Array> {
const token = 'Content-Encoding: aesgcm\x00'
const contentEncoding8Array = stringToU8Array(token)
const contextBuffer = await generateContext(subscription, serverKeys)
return joinUint8Arrays([contentEncoding8Array, contextBuffer])
}
async function generateNonceInfo(subscription: WebPushInfos, serverKeys: mKeyPair): Promise<Uint8Array> {
const token = 'Content-Encoding: nonce\x00'
const contentEncoding8Array = stringToU8Array(token)
const contextBuffer = await generateContext(subscription, serverKeys)
return joinUint8Arrays([contentEncoding8Array, contextBuffer])
}
export async function generateEncryptionKeys(
subscription: WebPushInfos,
salt: Uint8Array,
serverKeys: mKeyPair
): Promise<{ contentEncryptionKey: ArrayBuffer; nonce: ArrayBuffer }> {
const [prk, cekInfo, nonceInfo] = await Promise.all([
generatePRK(subscription, serverKeys),
generateCEKInfo(subscription, serverKeys),
generateNonceInfo(subscription, serverKeys),
])
const [contentEncryptionKey, nonce] = await Promise.all([
hkdfGenerate(prk, salt, cekInfo, 16),
hkdfGenerate(prk, salt, nonceInfo, 12),
])
return { contentEncryptionKey, nonce }
}
async function generateServerKey(): Promise<mKeyPair> {
return (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [
'deriveBits',
])) as unknown as mKeyPair
}
export async function generateAESGCMEncryptedMessage(
payloadText: string,
subscription: WebPushInfos
): Promise<{
cipherText: ArrayBuffer
salt: string
publicServerKey: string
}> {
const salt = await generateSalt()
const serverKeys = await generateServerKey()
const exportedServerKey = (await crypto.subtle.exportKey('jwk', serverKeys.publicKey)) as unknown as JWK
const encryptionKeys = await generateEncryptionKeys(subscription, salt, serverKeys)
const contentEncryptionCryptoKey = await crypto.subtle.importKey(
'raw',
encryptionKeys.contentEncryptionKey,
'AES-GCM',
true,
['decrypt', 'encrypt']
)
const paddingBytes = 0
const paddingUnit8Array = new Uint8Array(2 + paddingBytes)
const payloadUint8Array = encoder.encode(payloadText)
const recordUint8Array = new Uint8Array(paddingUnit8Array.byteLength + payloadUint8Array.byteLength)
recordUint8Array.set(paddingUnit8Array, 0)
recordUint8Array.set(payloadUint8Array, paddingUnit8Array.byteLength)
const encryptedPayloadArrayBuffer = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
tagLength: 128,
iv: encryptionKeys.nonce,
},
contentEncryptionCryptoKey,
recordUint8Array
)
return {
cipherText: encryptedPayloadArrayBuffer,
salt: b64ToUrlEncoded(btoa(u8ToString(salt))),
publicServerKey: b64ToUrlEncoded(exportPublicKeyPair(exportedServerKey)),
}
}

Wyświetl plik

@ -0,0 +1,81 @@
// note:
// all util functions return normal b64 NOT URL safe b64
// use b64ToUrlEncoded to convert to URL safe b64
function ArrayToHex(byteArray: Uint8Array): string {
return Array.prototype.map
.call(byteArray, (byte: number) => {
return ('0' + (byte & 0xff).toString(16)).slice(-2)
})
.join('')
}
export function generateRandomId(size = 16): string {
const buffer = new Uint8Array(size)
crypto.getRandomValues(buffer)
return ArrayToHex(buffer)
}
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
let bin = ''
const uint8 = new Uint8Array(buffer)
uint8.forEach((code: number) => {
bin += String.fromCharCode(code)
})
return btoa(bin)
}
export function b64ToUrlEncoded(str: string): string {
return str.replaceAll(/\+/g, '-').replaceAll(/\//g, '_').replace(/=+/g, '')
}
export function urlEncodedToB64(str: string): string {
const padding = '='.repeat((4 - (str.length % 4)) % 4)
return str.replaceAll(/-/g, '+').replaceAll(/_/g, '/') + padding
}
export function stringToU8Array(str: string): Uint8Array {
return new Uint8Array(str.split('').map((c) => c.charCodeAt(0)))
}
export function u8ToString(u8: Uint8Array): string {
return String.fromCharCode.apply(null, u8 as unknown as number[])
}
export function exportPublicKeyPair<T extends { x: string; y: string }>(key: T): string {
return btoa('\x04' + atob(urlEncodedToB64(key.x)) + atob(urlEncodedToB64(key.y)))
}
export function joinUint8Arrays(allUint8Arrays: Array<Uint8Array>): Uint8Array {
return allUint8Arrays.reduce(function (cumulativeValue, nextValue) {
const joinedArray = new Uint8Array(cumulativeValue.byteLength + nextValue.byteLength)
joinedArray.set(cumulativeValue, 0)
joinedArray.set(nextValue, cumulativeValue.byteLength)
return joinedArray
}, new Uint8Array())
}
function base64UrlToUint8Array(base64UrlData: string): Uint8Array {
const base64 = urlEncodedToB64(base64UrlData)
const rawData = atob(base64)
return stringToU8Array(rawData)
}
export async function cryptoKeysToUint8Array(
pubKey: CryptoKey,
privKey?: CryptoKey
): Promise<{ publicKey: Uint8Array; privateKey?: Uint8Array }> {
const jwk: any = await crypto.subtle.exportKey('jwk', pubKey)
const x = base64UrlToUint8Array(jwk.x as string)
const y = base64UrlToUint8Array(jwk.y as string)
const publicKey = new Uint8Array(65)
publicKey.set([0x04], 0)
publicKey.set(x, 1)
publicKey.set(y, 33)
if (privKey) {
const jwk: any = await crypto.subtle.exportKey('jwk', privKey)
const privateKey = base64UrlToUint8Array(jwk.d as string)
return { publicKey, privateKey }
}
return { publicKey }
}

Wyświetl plik

@ -0,0 +1,48 @@
import type { JWK } from './jwk'
import { arrayBufferToBase64, b64ToUrlEncoded, exportPublicKeyPair, stringToU8Array } from './util'
const objToUrlB64 = (obj: { [key: string]: string | number | null }) => b64ToUrlEncoded(btoa(JSON.stringify(obj)))
async function signData(token: string, applicationKeys: JWK): Promise<string> {
const key = await crypto.subtle.importKey('jwk', applicationKeys, { name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
])
const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: { name: 'SHA-256' } }, key, stringToU8Array(token))
return b64ToUrlEncoded(arrayBufferToBase64(sig))
}
async function generateHeaders(
endpoint: string,
applicationServerKeys: JWK,
sub: string
): Promise<{ token: string; serverKey: string }> {
const serverKey = b64ToUrlEncoded(exportPublicKeyPair(applicationServerKeys))
const pushService = new URL(endpoint)
const header = {
typ: 'JWT',
alg: 'ES256',
}
const body = {
aud: `${pushService.protocol}//${pushService.host}`,
exp: Math.floor(Date.now() / 1000) + 12 * 60 * 60,
sub: 'mailto:' + sub,
}
const unsignedToken = objToUrlB64(header) + '.' + objToUrlB64(body)
const signature = await signData(unsignedToken, applicationServerKeys)
const token = `${unsignedToken}.${signature}`
return { token, serverKey }
}
export async function generateV1Headers(
endpoint: string,
applicationServerKeys: JWK,
sub: string
): Promise<{ [headerName in 'Crypto-Key' | 'Authorization']: string }> {
const headers = await generateHeaders(endpoint, applicationServerKeys, sub)
return { Authorization: `WebPush ${headers.token}`, 'Crypto-Key': `p256ecdsa=${headers.serverKey}` }
}

Wyświetl plik

@ -0,0 +1,22 @@
export interface WebPushInfos {
endpoint: string
key: string
auth: string
// supportedAlgorithms: string[]; // this will be used in future
}
type Urgency = 'very-low' | 'low' | 'normal' | 'high'
export interface WebPushMessage {
data: string
urgency: Urgency
sub: string
ttl: number
}
export enum WebPushResult {
Success = 0,
Error = 1,
NotSubscribed = 2,
}

Wyświetl plik

@ -0,0 +1,345 @@
import { makeDB, assertCache, isUrlValid } from './utils'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config'
import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { strict as assert } from 'node:assert/strict'
import { cacheObject, createObject } from 'wildebeest/backend/src/activitypub/objects/'
import * as ap_users from 'wildebeest/functions/ap/users/[id]'
import * as ap_outbox from 'wildebeest/functions/ap/users/[id]/outbox'
import * as ap_outbox_page from 'wildebeest/functions/ap/users/[id]/outbox/page'
const userKEK = 'test_kek5'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const domain = 'cloudflare.com'
describe('ActivityPub', () => {
test('fetch non-existant user by id', async () => {
const db = await makeDB()
const res = await ap_users.handleRequest(domain, db, 'nonexisting')
assert.equal(res.status, 404)
})
test('fetch user by id', async () => {
const db = await makeDB()
const properties = { summary: 'test summary' }
const pubKey =
'-----BEGIN PUBLIC KEY-----MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApnI8FHJQXqqAdM87YwVseRUqbNLiw8nQ0zHBUyLylzaORhI4LfW4ozguiw8cWYgMbCufXMoITVmdyeTMGbQ3Q1sfQEcEjOZZXEeCCocmnYjK6MFSspjFyNw6GP0a5A/tt1tAcSlgALv8sg1RqMhSE5Kv+6lSblAYXcIzff7T2jh9EASnimaoAAJMaRH37+HqSNrouCxEArcOFhmFETadXsv+bHZMozEFmwYSTugadr4WD3tZd+ONNeimX7XZ3+QinMzFGOW19ioVHyjt3yCDU1cPvZIDR17dyEjByNvx/4N4Zly7puwBn6Ixy/GkIh5BWtL5VOFDJm/S+zcf1G1WsOAXMwKL4Nc5UWKfTB7Wd6voId7vF7nI1QYcOnoyh0GqXWhTPMQrzie4nVnUrBedxW0s/0vRXeR63vTnh5JrTVu06JGiU2pq2kvwqoui5VU6rtdImITybJ8xRkAQ2jo4FbbkS6t49PORIuivxjS9wPl7vWYazZtDVa5g/5eL7PnxOG3HsdIJWbGEh1CsG83TU9burHIepxXuQ+JqaSiKdCVc8CUiO++acUqKp7lmbYR9E/wRmvxXDFkxCZzA0UL2mRoLLLOe4aHvRSTsqiHC5Wwxyew5bb+eseJz3wovid9ZSt/tfeMAkCDmaCxEK+LGEbJ9Ik8ihis8Esm21N0A54sCAwEAAQ==-----END PUBLIC KEY-----'
await db
.prepare('INSERT INTO actors (id, email, type, properties, pubkey) VALUES (?, ?, ?, ?, ?)')
.bind(`https://${domain}/ap/users/sven`, 'sven@cloudflare.com', 'Person', JSON.stringify(properties), pubKey)
.run()
const res = await ap_users.handleRequest(domain, db, 'sven')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.summary, 'test summary')
assert(data.discoverable)
assert(data['@context'])
assert(isUrlValid(data.id))
assert(isUrlValid(data.url))
assert(isUrlValid(data.inbox))
assert(isUrlValid(data.outbox))
assert(isUrlValid(data.following))
assert(isUrlValid(data.followers))
assert.equal(data.publicKey.publicKeyPem, pubKey)
})
describe('Accept', () => {
beforeEach(() => {
globalThis.fetch = async (input: RequestInfo) => {
throw new Error('unexpected request to ' + input)
}
})
test('Accept follow request stores in db', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor, actor2, 'not needed')
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Accept',
actor: { id: 'https://' + domain + '/ap/users/sven2' },
object: {
type: 'Follow',
actor: actor.id,
object: 'https://' + domain + '/ap/users/sven2',
},
}
await activityHandler.handle(domain, activity, db, userKEK, 'inbox')
const row = await db
.prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`)
.bind(actor.id.toString())
.first()
assert(row)
assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2')
assert.equal(row.state, 'accepted')
})
test('Object must be an object', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Accept',
actor: 'https://example.com/actor',
object: 'a',
}
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), {
message: '`activity.object` must be of type object',
})
})
})
describe('Create', () => {
test('Object must be an object', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Create',
actor: 'https://example.com/actor',
object: 'a',
}
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), {
message: '`activity.object` must be of type object',
})
})
})
describe('Update', () => {
test('Object must be an object', async () => {
const db = await makeDB()
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Update',
actor: 'https://example.com/actor',
object: 'a',
}
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), {
message: '`activity.object` must be of type object',
})
})
test('Object must exist', async () => {
const db = await makeDB()
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Update',
actor: 'https://example.com/actor',
object: {
id: 'https://example.com/note2',
type: 'Note',
content: 'test note',
},
}
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), {
message: 'object https://example.com/note2 does not exist',
})
})
test('Object must have the same origin', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const object = {
id: 'https://example.com/note2',
type: 'Note',
content: 'test note',
}
const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false)
assert.notEqual(obj, null, 'could not create object')
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Update',
actor: 'https://example.com/actor',
object: object,
}
await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, 'inbox'), {
message: 'actorid mismatch when updating object',
})
})
test('Object is updated', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const object = {
id: 'https://example.com/note2',
type: 'Note',
content: 'test note',
}
const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false)
assert.notEqual(obj, null, 'could not create object')
const newObject = {
id: 'https://example.com/note2',
type: 'Note',
content: 'new test note',
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Update',
actor: actor.id,
object: newObject,
}
await activityHandler.handle(domain, activity, db, userKEK, 'inbox')
const updatedObject = await db.prepare('SELECT * FROM objects WHERE original_object_id=?').bind(object.id).first()
assert(updatedObject)
assert.equal(JSON.parse(updatedObject.properties).content, newObject.content)
})
})
describe('Outbox', () => {
test('return outbox', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my first status', actor))
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor))
const res = await ap_outbox.handleRequest(domain, db, 'sven', userKEK)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.type, 'OrderedCollection')
assert.equal(data.totalItems, 2)
})
test('return outbox page', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my first status', actor))
await sleep(10)
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'my second status', actor))
const res = await ap_outbox_page.handleRequest(domain, db, 'sven', userKEK)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.type, 'OrderedCollectionPage')
assert.equal(data.orderedItems.length, 2)
assert.equal(data.orderedItems[0].object.content, 'my second status')
assert.equal(data.orderedItems[1].object.content, 'my first status')
})
})
describe('Announce', () => {
test('Announce objects are stored and added to the remote actors outbox', async () => {
const remoteActorId = 'https://example.com/actor'
const objectId = 'https://example.com/some-object'
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === remoteActorId) {
return new Response(
JSON.stringify({
id: remoteActorId,
icon: { url: 'img.com' },
type: 'Person',
})
)
}
if (input.toString() === objectId) {
return new Response(
JSON.stringify({
id: objectId,
type: 'Note',
content: 'foo',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const activity: any = {
type: 'Announce',
actor: remoteActorId,
to: [],
cc: [],
object: objectId,
}
await activityHandler.handle(domain, activity, db, userKEK, 'inbox')
const object = await db.prepare('SELECT * FROM objects').bind(remoteActorId).first()
assert(object)
assert.equal(object.type, 'Note')
assert.equal(object.original_actor_id, remoteActorId)
const outbox_object = await db
.prepare('SELECT * FROM outbox_objects WHERE actor_id=?')
.bind(remoteActorId)
.first()
assert(outbox_object)
assert.equal(outbox_object.actor_id, remoteActorId)
})
})
describe('Objects', () => {
test('cacheObject deduplicates object', async () => {
const db = await makeDB()
const properties = { type: 'Note', a: 1, b: 2 }
const actorId = new URL(await createPerson(domain, db, userKEK, 'a@cloudflare.com'))
const originalObjectId = new URL('https://example.com/object1')
let result: any
// Cache object once adds it to the database
const obj1: any = await cacheObject(domain, db, properties, actorId, originalObjectId, false)
assert.equal(obj1.a, 1)
assert.equal(obj1.b, 2)
result = await db.prepare('SELECT count(*) as count from objects').first()
assert.equal(result.count, 1)
// Cache object second time updates the first one
properties.a = 3
const obj2: any = await cacheObject(domain, db, properties, actorId, originalObjectId, false)
// The creation date and properties don't change
assert.equal(obj1.a, obj2.a)
assert.equal(obj1.b, obj2.b)
assert.equal(obj1.published, obj2.published)
result = await db.prepare('SELECT count(*) as count from objects').first()
assert.equal(result.count, 1)
})
})
})

Wyświetl plik

@ -0,0 +1,183 @@
import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle'
import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config'
import * as ap_followers_page from 'wildebeest/functions/ap/users/[id]/followers/page'
import * as ap_following_page from 'wildebeest/functions/ap/users/[id]/following/page'
import * as ap_followers from 'wildebeest/functions/ap/users/[id]/followers'
import * as ap_following from 'wildebeest/functions/ap/users/[id]/following'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { strict as assert } from 'node:assert/strict'
import { makeDB, assertCache, isUrlValid } from '../utils'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
const userKEK = 'test_kek10'
const domain = 'cloudflare.com'
describe('ActivityPub', () => {
describe('Follow', () => {
let receivedActivity: any = null
beforeEach(() => {
receivedActivity = null
globalThis.fetch = async (input: any) => {
if (input.url === `https://${domain}/ap/users/sven2/inbox`) {
assert.equal(input.method, 'POST')
const data = await input.json()
receivedActivity = data
console.log({ receivedActivity })
return new Response('')
}
throw new Error('unexpected request to ' + input.url)
}
})
test('Receive follow with Accept reply', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Follow',
actor: actor2.id.toString(),
object: actor.id.toString(),
}
await activityHandler.handle(domain, activity, db, userKEK, 'inbox')
const row = await db
.prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`)
.bind(actor2.id.toString())
.first()
assert(row)
assert.equal(row.target_actor_id.toString(), actor.id.toString())
assert.equal(row.state, 'accepted')
assert(receivedActivity)
assert.equal(receivedActivity.type, 'Accept')
assert.equal(receivedActivity.actor.toString(), actor.id.toString())
assert.equal(receivedActivity.object.actor, activity.actor)
assert.equal(receivedActivity.object.type, activity.type)
})
test('list actor following', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
const actor3: any = {
id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com'),
}
await addFollowing(db, actor, actor2, 'not needed')
await acceptFollowing(db, actor, actor2)
await addFollowing(db, actor, actor3, 'not needed')
await acceptFollowing(db, actor, actor3)
const res = await ap_following.handleRequest(domain, db, 'sven')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.type, 'OrderedCollection')
assert.equal(data.totalItems, 2)
})
test('list actor following page', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
const actor3: any = {
id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com'),
}
await addFollowing(db, actor, actor2, 'not needed')
await acceptFollowing(db, actor, actor2)
await addFollowing(db, actor, actor3, 'not needed')
await acceptFollowing(db, actor, actor3)
const res = await ap_following_page.handleRequest(domain, db, 'sven')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.type, 'OrderedCollectionPage')
assert.equal(data.orderedItems[0], `https://${domain}/ap/users/sven2`)
assert.equal(data.orderedItems[1], `https://${domain}/ap/users/sven3`)
})
test('list actor follower', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor2, actor, 'not needed')
await acceptFollowing(db, actor2, actor)
const res = await ap_followers.handleRequest(domain, db, 'sven')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.type, 'OrderedCollection')
assert.equal(data.totalItems, 1)
})
test('list actor follower page', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor2, actor, 'not needed')
await acceptFollowing(db, actor2, actor)
const res = await ap_followers_page.handleRequest(domain, db, 'sven')
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.type, 'OrderedCollectionPage')
assert.equal(data.orderedItems[0], `https://${domain}/ap/users/sven2`)
})
test('creates a notification', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Follow',
actor: actor2.id,
object: actor.id,
}
await activityHandler.handle(domain, activity, db, userKEK, 'inbox')
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
assert.equal(entry.type, 'follow')
assert.equal(entry.actor_id.toString(), actor.id.toString())
assert.equal(entry.from_actor_id.toString(), actor2.id.toString())
})
})
})

Wyświetl plik

@ -0,0 +1,322 @@
import { makeDB, assertCache, isUrlValid } from '../utils'
import { generateVAPIDKeys, configure } from 'wildebeest/backend/src/config'
import * as objects from 'wildebeest/backend/src/activitypub/objects'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import * as ap_inbox from 'wildebeest/functions/ap/users/[id]/inbox'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { strict as assert } from 'node:assert/strict'
const userKEK = 'test_kek9'
const domain = 'cloudflare.com'
const kv_cache: any = {
async put() {},
}
const waitUntil = async (p: Promise<any>) => await p
describe('ActivityPub', () => {
test('send Note to non existant user', async () => {
const db = await makeDB()
const activity: any = {}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil)
assert.equal(res.status, 404)
})
test('send Note to inbox stores in DB', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actorId,
to: [actorId],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db
.prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id')
.first()
const properties = JSON.parse(entry.properties)
assert.equal(properties.content, 'test note')
})
test("send Note adds in remote actor's outbox", async () => {
const remoteActorId = 'https://example.com/actor'
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === remoteActorId) {
return new Response(
JSON.stringify({
id: remoteActorId,
type: 'Person',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const activity: any = {
type: 'Create',
actor: remoteActorId,
to: [],
cc: [],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'test note',
},
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM outbox_objects WHERE actor_id=?').bind(remoteActorId).first()
assert.equal(entry.actor_id, remoteActorId)
})
test('local actor sends Note with mention create notification', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actorB,
to: [actorA],
cc: [],
object: {
id: 'https://example.com/note2',
type: 'Note',
content: 'test note',
},
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
assert.equal(entry.type, 'mention')
assert.equal(entry.actor_id.toString(), actorA.toString())
assert.equal(entry.from_actor_id.toString(), actorB.toString())
})
test('remote actor sends Note with mention create notification and download actor', async () => {
const actorB = 'https://remote.com/actorb'
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === actorB) {
return new Response(
JSON.stringify({
id: actorB,
type: 'Person',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com')
const activity: any = {
type: 'Create',
actor: actorB,
to: [actorA],
cc: [],
object: {
id: 'https://example.com/note3',
type: 'Note',
content: 'test note',
},
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actors WHERE id=?').bind(actorB).first()
assert.equal(entry.id, actorB)
})
test('send Note records reply', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
{
const activity: any = {
type: 'Create',
actor: actorId,
to: [actorId],
object: {
id: 'https://example.com/note1',
type: 'Note',
content: 'post',
},
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
}
{
const activity: any = {
type: 'Create',
actor: actorId,
to: [actorId],
object: {
inReplyTo: 'https://example.com/note1',
id: 'https://example.com/note2',
type: 'Note',
content: 'reply',
},
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'sven', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
}
const entry = await db.prepare('SELECT * FROM actor_replies').first()
assert.equal(entry.actor_id, actorId.toString())
const obj: any = await objects.getObjectById(db, entry.object_id)
assert(obj)
assert.equal(obj.originalObjectId, 'https://example.com/note2')
const inReplyTo: any = await objects.getObjectById(db, entry.in_reply_to_object_id)
assert(inReplyTo)
assert.equal(inReplyTo.originalObjectId, 'https://example.com/note1')
})
describe('Announce', () => {
test('records reblog in db', async () => {
const db = await makeDB()
await generateVAPIDKeys(db)
await configure(db, { title: 'title', description: 'a', email: 'email' })
const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') }
const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actorA)
const activity: any = {
type: 'Announce',
actor: actorB.id,
object: note.id,
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actor_reblogs').first()
assert.equal(entry.actor_id.toString(), actorB.id.toString())
assert.equal(entry.object_id.toString(), note.id.toString())
})
test('creates notification', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') }
const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actorA)
const activity: any = {
type: 'Announce',
actor: actorB.id,
object: note.id,
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
assert(entry)
assert.equal(entry.type, 'reblog')
assert.equal(entry.actor_id.toString(), actorA.id.toString())
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
})
})
describe('Like', () => {
test('records like in db', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') }
const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actorA)
const activity: any = {
type: 'Like',
actor: actorB.id,
object: note.id,
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actor_favourites').first()
assert.equal(entry.actor_id.toString(), actorB.id.toString())
assert.equal(entry.object_id.toString(), note.id.toString())
})
test('creates notification', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') }
const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actorA)
const activity: any = {
type: 'Like',
actor: actorB.id,
object: note.id,
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actor_notifications').first()
assert.equal(entry.type, 'favourite')
assert.equal(entry.actor_id.toString(), actorA.id.toString())
assert.equal(entry.from_actor_id.toString(), actorB.id.toString())
})
test('records like in db', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actorA: any = { id: await createPerson(domain, db, userKEK, 'a@cloudflare.com') }
const actorB: any = { id: await createPerson(domain, db, userKEK, 'b@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actorA)
const activity: any = {
type: 'Like',
actor: actorB.id,
object: note.id,
}
const res = await ap_inbox.handleRequest(domain, db, kv_cache, 'a', activity, userKEK, waitUntil)
assert.equal(res.status, 200)
const entry = await db.prepare('SELECT * FROM actor_favourites').first()
assert.equal(entry.actor_id.toString(), actorB.id.toString())
assert.equal(entry.object_id.toString(), note.id.toString())
})
})
})

Wyświetl plik

@ -0,0 +1,246 @@
import { strict as assert } from 'node:assert/strict'
import * as v1_instance from 'wildebeest/functions/api/v1/instance'
import * as v2_instance from 'wildebeest/functions/api/v2/instance'
import * as apps from 'wildebeest/functions/api/v1/apps'
import * as custom_emojis from 'wildebeest/functions/api/v1/custom_emojis'
import * as notifications from 'wildebeest/functions/api/v1/notifications'
import { defaultImages } from 'wildebeest/config/accounts'
import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache, streamToArrayBuffer, createTestClient } from './utils'
import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account'
import { getSigningKey } from 'wildebeest/backend/src/mastodon/account'
import { Actor, createPerson, getPersonById } from 'wildebeest/backend/src/activitypub/actors'
import { createClient, getClientById } from '../src/mastodon/client'
import { createSubscription } from '../src/mastodon/subscription'
import * as subscription from 'wildebeest/functions/api/v1/push/subscription'
import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config'
const userKEK = 'test_kek'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('instance', () => {
test('return the instance infos v1', async () => {
const db = await makeDB()
const data = {
title: 'title',
uri: 'uri',
email: 'email',
description: 'description',
accessAud: '1',
accessDomain: 'foo',
}
await configure(db, data)
const res = await v1_instance.handleRequest(domain, db)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
assertCache(res, 180)
{
const data = await res.json<any>()
assert.equal(data.rules.length, 0)
assert.equal(data.uri, domain)
}
})
test('return the instance infos v2', async () => {
const db = await makeDB()
const data = {
title: 'title',
uri: 'uri',
email: 'email',
description: 'description',
accessAud: '1',
accessDomain: 'foo',
}
await configure(db, data)
const res = await v2_instance.handleRequest(domain, db)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
assertCache(res, 180)
})
test('adds a short_description if missing', async () => {
const db = await makeDB()
const data = {
title: 'title',
uri: 'uri',
email: 'email',
description: 'description',
accessAud: '1',
accessDomain: 'foo',
}
await configure(db, data)
const res = await v1_instance.handleRequest(domain, db)
assert.equal(res.status, 200)
{
const data = await res.json<any>()
assert.equal(data.short_description, 'description')
}
})
})
describe('apps', () => {
test('return the app infos', async () => {
const db = await makeDB()
await generateVAPIDKeys(db)
const request = new Request('https://example.com', {
method: 'POST',
body: '{"redirect_uris":"mastodon://joinmastodon.org/oauth","website":"https://app.joinmastodon.org/ios","client_name":"Mastodon for iOS","scopes":"read write follow push"}',
})
const res = await apps.handleRequest(db, request)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
const { name, website, redirect_uri, client_id, client_secret, vapid_key, ...rest } = await res.json<
Record<string, string>
>()
assert.equal(name, 'Mastodon for iOS')
assert.equal(website, 'https://app.joinmastodon.org/ios')
assert.equal(redirect_uri, 'mastodon://joinmastodon.org/oauth')
assert.deepEqual(rest, {})
})
test('returns 404 for GET request', async () => {
const request = new Request('https://example.com')
const ctx: any = {
next: () => new Response(),
data: null,
env: {},
request,
}
const res = await apps.onRequest(ctx)
assert.equal(res.status, 400)
})
})
describe('custom emojis', () => {
test('returns an empty array', async () => {
const res = await custom_emojis.onRequest()
assert.equal(res.status, 200)
assertJSON(res)
assertCORS(res)
assertCache(res, 300)
const data = await res.json<any>()
assert.equal(data.length, 0)
})
})
describe('subscriptions', () => {
test('get non existing subscription', async () => {
const db = await makeDB()
const req = new Request('https://example.com')
const client = await createTestClient(db)
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const res = await subscription.handleGetRequest(db, req, connectedActor, client.id)
assert.equal(res.status, 404)
})
test('get existing subscription', async () => {
const db = await makeDB()
const req = new Request('https://example.com')
const client = await createTestClient(db)
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
await createSubscription(db, connectedActor, client, data)
const res = await subscription.handleGetRequest(db, req, connectedActor, client.id)
assert.equal(res.status, 200)
const out = await res.json<any>()
assert.equal(typeof out.id, 'number')
assert.equal(out.endpoint, data.subscription.endpoint)
})
test('create subscription', async () => {
const db = await makeDB()
const client = await createTestClient(db)
await generateVAPIDKeys(db)
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(data),
})
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id)
assert.equal(res.status, 200)
const row: any = await db.prepare('SELECT * FROM subscriptions').first()
assert.equal(row.actor_id, connectedActor.id.toString())
assert.equal(row.client_id, client.id)
assert.equal(row.endpoint, data.subscription.endpoint)
})
test('create subscriptions only creates one', async () => {
const db = await makeDB()
const client = await createTestClient(db)
await generateVAPIDKeys(db)
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const data: any = {
subscription: {
endpoint: 'https://endpoint.com',
keys: {
p256dh: 'p256dh',
auth: 'auth',
},
},
data: {
alerts: {},
policy: 'all',
},
}
await createSubscription(db, connectedActor, client, data)
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(data),
})
const res = await subscription.handlePostRequest(db, req, connectedActor, client.id)
assert.equal(res.status, 200)
const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first()
assert.equal(count, 1)
})
})
})

Wyświetl plik

@ -0,0 +1,883 @@
import { strict as assert } from 'node:assert/strict'
import { configure, generateVAPIDKeys } from 'wildebeest/backend/src/config'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import * as accounts_following from 'wildebeest/functions/api/v1/accounts/[id]/following'
import * as accounts_featured_tags from 'wildebeest/functions/api/v1/accounts/[id]/featured_tags'
import * as accounts_lists from 'wildebeest/functions/api/v1/accounts/[id]/lists'
import * as accounts_relationships from 'wildebeest/functions/api/v1/accounts/relationships'
import * as accounts_followers from 'wildebeest/functions/api/v1/accounts/[id]/followers'
import * as accounts_follow from 'wildebeest/functions/api/v1/accounts/[id]/follow'
import * as accounts_unfollow from 'wildebeest/functions/api/v1/accounts/[id]/unfollow'
import * as accounts_statuses from 'wildebeest/functions/api/v1/accounts/[id]/statuses'
import * as accounts_get from 'wildebeest/functions/api/v1/accounts/[id]'
import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache, streamToArrayBuffer } from '../utils'
import * as accounts_verify_creds from 'wildebeest/functions/api/v1/accounts/verify_credentials'
import * as accounts_update_creds from 'wildebeest/functions/api/v1/accounts/update_credentials'
import { createPerson, getPersonById } from 'wildebeest/backend/src/activitypub/actors'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
const userKEK = 'test_kek2'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('accounts', () => {
beforeEach(() => {
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/sven',
},
],
})
)
}
if (input.toString() === 'https://social.com/sven') {
return new Response(
JSON.stringify({
id: 'sven@remote.com',
type: 'Person',
preferredUsername: 'sven',
name: 'sven ssss',
icon: { url: 'icon.jpg' },
image: { url: 'image.jpg' },
})
)
}
throw new Error('unexpected request to ' + input)
}
})
test('missing identity', async () => {
const data = {
cloudflareAccess: {
JWT: {
getIdentity() {
return null
},
},
},
}
const context: any = { data }
const res = await accounts_verify_creds.onRequest(context)
assert.equal(res.status, 401)
})
test('verify the credentials', async () => {
const db = await makeDB()
const connectedActor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
name: 'foo',
}
const context: any = { data: { connectedActor }, env: { DATABASE: db } }
const res = await accounts_verify_creds.onRequest(context)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
const data = await res.json<any>()
assert.equal(data.display_name, 'foo')
// Mastodon app expects the id to be a number (as string), it uses
// it to construct an URL. ActivityPub uses URL as ObjectId so we
// make sure we don't return the URL.
assert(!isUrlValid(data.id))
})
test('update credentials', async () => {
const db = await makeDB()
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const updates = new FormData()
updates.set('display_name', 'newsven')
updates.set('note', 'hein')
const req = new Request('https://example.com', {
method: 'PATCH',
body: updates,
})
const res = await accounts_update_creds.handleRequest(
db,
req,
connectedActor,
'CF_ACCOUNT_ID',
'CF_API_TOKEN',
userKEK
)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.display_name, 'newsven')
assert.equal(data.note, 'hein')
const updatedActor: any = await getPersonById(db, connectedActor.id)
assert(updatedActor)
assert.equal(updatedActor.name, 'newsven')
assert.equal(updatedActor.summary, 'hein')
})
test('update credentials sends update', async () => {
const db = await makeDB()
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') }
await addFollowing(db, actor2, connectedActor, 'sven2@' + domain)
await acceptFollowing(db, actor2, connectedActor)
let receivedActivity: any = null
globalThis.fetch = async (input: any) => {
if (input.url.toString() === `https://${domain}/ap/users/sven2/inbox`) {
assert.equal(input.method, 'POST')
receivedActivity = await input.json()
return new Response('')
}
throw new Error('unexpected request to ' + input.url)
}
const updates = new FormData()
updates.set('display_name', 'newsven')
const req = new Request('https://example.com', {
method: 'PATCH',
body: updates,
})
const res = await accounts_update_creds.handleRequest(
db,
req,
connectedActor,
'CF_ACCOUNT_ID',
'CF_API_TOKEN',
userKEK
)
assert.equal(res.status, 200)
assert(receivedActivity)
assert.equal(receivedActivity.type, 'Update')
assert.equal(receivedActivity.object.id.toString(), connectedActor.id.toString())
assert.equal(receivedActivity.object.name, 'newsven')
})
test('update credentials avatar and header', async () => {
globalThis.fetch = async (input: RequestInfo, data: any) => {
if (input === 'https://api.cloudflare.com/client/v4/accounts/CF_ACCOUNT_ID/images/v1') {
assert.equal(data.method, 'POST')
const file: any = data.body.get('file')
return new Response(
JSON.stringify({
success: true,
result: {
variants: ['https://example.com/' + file.name],
},
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const updates = new FormData()
updates.set('avatar', new File(['bytes'], 'selfie.jpg', { type: 'image/jpeg' }))
updates.set('header', new File(['bytes2'], 'mountain.jpg', { type: 'image/jpeg' }))
const req = new Request('https://example.com', {
method: 'PATCH',
body: updates,
})
const res = await accounts_update_creds.handleRequest(
db,
req,
connectedActor,
'CF_ACCOUNT_ID',
'CF_API_TOKEN',
userKEK
)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.avatar, 'https://example.com/selfie.jpg')
assert.equal(data.header, 'https://example.com/mountain.jpg')
})
test('get remote actor by id', async () => {
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asven%40social.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/someone',
},
],
})
)
}
if (input.toString() === 'https://social.com/someone') {
return new Response(
JSON.stringify({
id: 'https://social.com/someone',
url: 'https://social.com/@someone',
type: 'Person',
preferredUsername: 'sven',
outbox: 'https://social.com/someone/outbox',
following: 'https://social.com/someone/following',
followers: 'https://social.com/someone/followers',
})
)
}
if (input.toString() === 'https://social.com/someone/following') {
return new Response(
JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://social.com/someone/following',
type: 'OrderedCollection',
totalItems: 123,
first: 'https://social.com/someone/following/page',
})
)
}
if (input.toString() === 'https://social.com/someone/followers') {
return new Response(
JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://social.com/someone/followers',
type: 'OrderedCollection',
totalItems: 321,
first: 'https://social.com/someone/followers/page',
})
)
}
if (input.toString() === 'https://social.com/someone/outbox') {
return new Response(
JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
id: 'https://social.com/someone/outbox',
type: 'OrderedCollection',
totalItems: 890,
first: 'https://social.com/someone/outbox/page',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const res = await accounts_get.handleRequest(domain, 'sven@social.com', db)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.username, 'sven')
assert.equal(data.acct, 'sven@social.com')
assert(isUrlValid(data.url))
assert(data.url, 'https://social.com/@someone')
assert.equal(data.followers_count, 321)
assert.equal(data.following_count, 123)
assert.equal(data.statuses_count, 890)
})
test('get unknown local actor by id', async () => {
const db = await makeDB()
const res = await accounts_get.handleRequest(domain, 'sven', db)
assert.equal(res.status, 404)
})
test('get local actor by id', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') }
const actor3: any = { id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') }
await addFollowing(db, actor, actor2, 'sven2@' + domain)
await acceptFollowing(db, actor, actor2)
await addFollowing(db, actor, actor3, 'sven3@' + domain)
await acceptFollowing(db, actor, actor3)
await addFollowing(db, actor3, actor, 'sven@' + domain)
await acceptFollowing(db, actor3, actor)
const firstNote = await createPublicNote(domain, db, 'my first status', actor)
await addObjectInOutbox(db, actor, firstNote)
const res = await accounts_get.handleRequest(domain, 'sven', db)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.username, 'sven')
assert.equal(data.acct, 'sven')
assert.equal(data.followers_count, 1)
assert.equal(data.following_count, 2)
assert.equal(data.statuses_count, 1)
assert(isUrlValid(data.url))
assert(data.url.includes(domain))
})
test('get local actor statuses', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const firstNote = await createPublicNote(domain, db, 'my first status', actor)
await addObjectInOutbox(db, actor, firstNote)
await insertLike(db, actor, firstNote)
await sleep(10)
const secondNode = await createPublicNote(domain, db, 'my second status', actor)
await addObjectInOutbox(db, actor, secondNode)
await insertReblog(db, actor, secondNode)
const req = new Request('https://' + domain)
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 2)
assert.equal(data[0].content, 'my second status')
assert.equal(data[0].account.acct, 'sven@' + domain)
assert.equal(data[0].favourites_count, 0)
assert.equal(data[0].reblogs_count, 1)
assert.equal(data[1].content, 'my first status')
assert.equal(data[1].favourites_count, 1)
assert.equal(data[1].reblogs_count, 0)
})
test('get pinned statuses', async () => {
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const req = new Request('https://' + domain + '?pinned=true')
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 0)
})
test('get local actor statuses with max_id', async () => {
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
await db
.prepare("INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id')")
.bind('object1', 'Note', JSON.stringify({ content: 'my first status' }))
.run()
await db
.prepare("INSERT INTO objects (id, type, properties, local, mastodon_id) VALUES (?, ?, ?, 1, 'mastodon_id2')")
.bind('object2', 'Note', JSON.stringify({ content: 'my second status' }))
.run()
await db
.prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)')
.bind('outbox1', actorId.toString(), 'object1', '2022-12-16 08:14:48')
.run()
await db
.prepare('INSERT INTO outbox_objects (id, actor_id, object_id, cdate) VALUES (?, ?, ?, ?)')
.bind('outbox2', actorId.toString(), 'object2', '2022-12-16 10:14:48')
.run()
{
// Query statuses after object1, should only see object2.
const req = new Request('https://' + domain + '?max_id=object1')
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
assert.equal(data[0].content, 'my second status')
assert.equal(data[0].account.acct, 'sven@' + domain)
}
{
// Query statuses after object2, nothing is after.
const req = new Request('https://' + domain + '?max_id=object2')
const res = await accounts_statuses.handleRequest(req, db, 'sven@' + domain, userKEK)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 0)
}
})
test('get remote actor statuses', async () => {
const db = await makeDB()
await configure(db, { title: 'title', description: 'a', email: 'email' })
await generateVAPIDKeys(db)
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const localNote = await createPublicNote(domain, db, 'my localnote status', actor)
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/someone',
},
],
})
)
}
if (input.toString() === 'https://social.com/someone') {
return new Response(
JSON.stringify({
id: 'https://social.com/someone',
type: 'Person',
preferredUsername: 'someone',
outbox: 'https://social.com/outbox',
})
)
}
if (input.toString() === 'https://mastodon.social/users/someone') {
return new Response(
JSON.stringify({
id: 'https://mastodon.social/users/someone',
type: 'Person',
})
)
}
if (input.toString() === 'https://social.com/outbox') {
return new Response(
JSON.stringify({
first: 'https://social.com/outbox/page1',
})
)
}
if (input.toString() === 'https://social.com/outbox/page1') {
return new Response(
JSON.stringify({
orderedItems: [
{
id: 'https://mastodon.social/users/a/statuses/b/activity',
type: 'Create',
actor: 'https://mastodon.social/users/someone',
published: '2022-12-10T23:48:38Z',
object: {
id: 'https://example.com/object1',
type: 'Note',
content: '<p>p</p>',
},
},
{
id: 'https://mastodon.social/users/c/statuses/d/activity',
type: 'Announce',
actor: 'https://mastodon.social/users/someone',
published: '2022-12-10T23:48:38Z',
object: localNote.id,
},
],
})
)
}
throw new Error('unexpected request to ' + input)
}
const req = new Request('https://example.com')
const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com', userKEK)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
assert.equal(data[0].content, '<p>p</p>')
assert.equal(data[0].account.username, 'someone')
// Statuses were imported locally and once was a reblog of an already
// existing local object.
const row = await db.prepare(`SELECT count(*) as count FROM objects`).first()
assert.equal(row.count, 2)
})
test('get remote actor statuses ignoring object that fail to download', async () => {
const db = await makeDB()
await generateVAPIDKeys(db)
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const localNote = await createPublicNote(domain, db, 'my localnote status', actor)
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === 'https://social.com/.well-known/webfinger?resource=acct%3Asomeone%40social.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/someone',
},
],
})
)
}
if (input.toString() === 'https://social.com/someone') {
return new Response(
JSON.stringify({
id: 'https://social.com/someone',
type: 'Person',
preferredUsername: 'someone',
outbox: 'https://social.com/outbox',
})
)
}
if (input.toString() === 'https://social.com/outbox') {
return new Response(
JSON.stringify({
first: 'https://social.com/outbox/page1',
})
)
}
if (input.toString() === 'https://nonexistingobject.com/') {
return new Response('', { status: 400 })
}
if (input.toString() === 'https://social.com/outbox/page1') {
return new Response(
JSON.stringify({
orderedItems: [
{
id: 'https://mastodon.social/users/c/statuses/d/activity',
type: 'Announce',
actor: 'https://mastodon.social/users/someone',
published: '2022-12-10T23:48:38Z',
object: 'https://nonexistingobject.com',
},
],
})
)
}
throw new Error('unexpected request to ' + input)
}
const req = new Request('https://example.com')
const res = await accounts_statuses.handleRequest(req, db, 'someone@social.com', userKEK)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 0)
})
test('get remote actor followers', async () => {
const db = await makeDB()
const connectedActor: any = { id: 'someid' }
const req = new Request(`https://${domain}`)
const res = await accounts_followers.handleRequest(req, db, 'sven@example.com', connectedActor)
assert.equal(res.status, 403)
})
test('get local actor followers', async () => {
globalThis.fetch = async (input: any, opts: any) => {
if (input.toString() === 'https://' + domain + '/ap/users/sven2') {
return new Response(
JSON.stringify({
id: 'https://example.com/actor',
type: 'Person',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor2, actor, 'sven@' + domain)
await acceptFollowing(db, actor2, actor)
const connectedActor = actor
const req = new Request(`https://${domain}`)
const res = await accounts_followers.handleRequest(req, db, 'sven', connectedActor)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
})
test('get local actor following', async () => {
globalThis.fetch = async (input: any, opts: any) => {
if (input.toString() === 'https://' + domain + '/ap/users/sven2') {
return new Response(
JSON.stringify({
id: 'https://example.com/foo',
type: 'Person',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor, actor2, 'sven@' + domain)
await acceptFollowing(db, actor, actor2)
const connectedActor = actor
const req = new Request(`https://${domain}`)
const res = await accounts_following.handleRequest(req, db, 'sven', connectedActor)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
})
test('get remote actor following', async () => {
const db = await makeDB()
const connectedActor: any = { id: 'someid' }
const req = new Request(`https://${domain}`)
const res = await accounts_following.handleRequest(req, db, 'sven@example.com', connectedActor)
assert.equal(res.status, 403)
})
test('get remote actor featured_tags', async () => {
const res = await accounts_featured_tags.onRequest()
assert.equal(res.status, 200)
})
test('get remote actor lists', async () => {
const res = await accounts_lists.onRequest()
assert.equal(res.status, 200)
})
describe('relationships', () => {
test('relationships missing ids', async () => {
const db = await makeDB()
const connectedActor: any = { id: 'someid' }
const req = new Request('https://mastodon.example/api/v1/accounts/relationships')
const res = await accounts_relationships.handleRequest(req, db, connectedActor)
assert.equal(res.status, 400)
})
test('relationships with ids', async () => {
const db = await makeDB()
const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first&id[]=second')
const connectedActor: any = { id: 'someid' }
const res = await accounts_relationships.handleRequest(req, db, connectedActor)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
const data = await res.json<Array<any>>()
assert.equal(data.length, 2)
assert.equal(data[0].id, 'first')
assert.equal(data[0].following, false)
assert.equal(data[1].id, 'second')
assert.equal(data[1].following, false)
})
test('relationships with one id', async () => {
const db = await makeDB()
const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=first')
const connectedActor: any = { id: 'someid' }
const res = await accounts_relationships.handleRequest(req, db, connectedActor)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
assert.equal(data[0].id, 'first')
assert.equal(data[0].following, false)
})
test('relationships following', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor, actor2, 'sven2@' + domain)
await acceptFollowing(db, actor, actor2)
const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain)
const res = await accounts_relationships.handleRequest(req, db, actor)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
assert.equal(data[0].following, true)
})
test('relationships following request', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
await addFollowing(db, actor, actor2, 'sven2@' + domain)
const req = new Request('https://mastodon.example/api/v1/accounts/relationships?id[]=sven2@' + domain)
const res = await accounts_relationships.handleRequest(req, db, actor)
assert.equal(res.status, 200)
const data = await res.json<Array<any>>()
assert.equal(data.length, 1)
assert.equal(data[0].requested, true)
assert.equal(data[0].following, false)
})
})
test('follow local account', async () => {
const db = await makeDB()
const connectedActor: any = {
id: 'connectedActor',
}
const req = new Request('https://example.com', { method: 'POST' })
const res = await accounts_follow.handleRequest(req, db, 'localuser', connectedActor, userKEK)
assert.equal(res.status, 403)
})
describe('follow', () => {
let receivedActivity: any = null
beforeEach(() => {
receivedActivity = null
globalThis.fetch = async (input: any, opts: any) => {
if (
input.toString() ===
'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + ''
) {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/sven',
},
],
})
)
}
if (input.toString() === 'https://social.com/sven') {
return new Response(
JSON.stringify({
id: `https://${domain}/ap/users/actor`,
type: 'Person',
inbox: 'https://example.com/inbox',
})
)
}
if (input.url === 'https://example.com/inbox') {
assert.equal(input.method, 'POST')
receivedActivity = await input.json()
return new Response('')
}
throw new Error('unexpected request to ' + input)
}
})
test('follow account', async () => {
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const connectedActor: any = {
id: actorId,
}
const req = new Request('https://example.com', { method: 'POST' })
const res = await accounts_follow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
assert(receivedActivity)
assert.equal(receivedActivity.type, 'Follow')
const row = await db
.prepare(`SELECT target_actor_acct, target_actor_id, state FROM actor_following WHERE actor_id=?`)
.bind(actorId.toString())
.first()
assert(row)
assert.equal(row.target_actor_acct, 'actor@' + domain)
assert.equal(row.target_actor_id, `https://${domain}/ap/users/actor`)
assert.equal(row.state, 'pending')
})
test('unfollow account', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const follower: any = {
id: await createPerson(domain, db, userKEK, 'actor@cloudflare.com'),
}
await addFollowing(db, actor, follower, 'not needed')
const connectedActor: any = actor
const req = new Request('https://example.com', { method: 'POST' })
const res = await accounts_unfollow.handleRequest(req, db, 'actor@' + domain, connectedActor, userKEK)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
assert(receivedActivity)
assert.equal(receivedActivity.type, 'Undo')
assert.equal(receivedActivity.object.type, 'Follow')
const row = await db
.prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`)
.bind(actor.id.toString())
.first()
assert(row)
assert.equal(row.count, 0)
})
})
})
})

Wyświetl plik

@ -0,0 +1,58 @@
import * as media from 'wildebeest/functions/api/v2/media'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { strict as assert } from 'node:assert/strict'
import { makeDB, assertJSON, isUrlValid } from '../utils'
import * as objects from 'wildebeest/backend/src/activitypub/objects'
const userKEK = 'test_kek10'
const CF_ACCOUNT_ID = 'testaccountid'
const CF_API_TOKEN = 'testtoken'
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('media', () => {
test('upload image creates object', async () => {
globalThis.fetch = async (input: RequestInfo, data: any) => {
if (input === 'https://api.cloudflare.com/client/v4/accounts/testaccountid/images/v1') {
return new Response(
JSON.stringify({
success: true,
result: {
id: 'abcd',
variants: ['https://example.com/' + file.name],
},
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const file = new File(['abc'], 'image.jpeg', { type: 'image/jpeg' })
const body = new FormData()
body.set('file', file)
const req = new Request('https://example.com/api/v2/media', {
method: 'POST',
body,
})
const res = await media.handleRequest(req, db, connectedActor, CF_ACCOUNT_ID, CF_API_TOKEN)
assert.equal(res.status, 200)
assertJSON(res)
const data = await res.json<any>()
assert(!isUrlValid(data.id))
assert(isUrlValid(data.url))
assert(isUrlValid(data.preview_url))
const obj = await objects.getObjectByMastodonId(db, data.id)
assert(obj)
assert(obj.mastodonId)
assert.equal(obj.type, 'Image')
assert.equal(obj.originalActorId, connectedActor.id.toString())
})
})
})

Wyświetl plik

@ -0,0 +1,154 @@
import * as notifications_get from 'wildebeest/functions/api/v1/notifications/[id]'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { createNotification, insertFollowNotification } from 'wildebeest/backend/src/mastodon/notification'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import * as notifications from 'wildebeest/functions/api/v1/notifications'
import { makeDB, assertJSON, assertCORS, createTestClient } from '../utils'
import { strict as assert } from 'node:assert/strict'
import { sendLikeNotification } from 'wildebeest/backend/src/mastodon/notification'
import { createSubscription } from 'wildebeest/backend/src/mastodon/subscription'
import { generateVAPIDKeys, configure } from 'wildebeest/backend/src/config'
import { arrayBufferToBase64 } from 'wildebeest/backend/src/utils/key-ops'
import { getNotifications } from 'wildebeest/backend/src/mastodon/notification'
const userKEK = 'test_kek15'
const domain = 'cloudflare.com'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function parseCryptoKey(s: string): any {
const parts = s.split(';')
const out: any = {}
for (let i = 0, len = parts.length; i < len; i++) {
const parts2 = parts[i].split('=')
out[parts2[0]] = parts2[1]
}
return out
}
describe('Mastodon APIs', () => {
describe('notifications', () => {
test('returns notifications stored in KV cache', async () => {
const connectedActor: any = { id: 'id' }
const kv_cache: any = {
async get(key: string) {
assert.equal(key, 'id/notifications')
return 'cached data'
},
}
const req = new Request('https://' + domain)
const data = await notifications.handleRequest(req, kv_cache, connectedActor)
assert.equal(await data.text(), 'cached data')
})
test('returns notifications stored in db', async () => {
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const fromActorId = await createPerson(domain, db, userKEK, 'from@cloudflare.com')
const connectedActor: any = {
id: actorId,
}
const note = await createPublicNote(domain, db, 'my first status', connectedActor)
const fromActor: any = {
id: fromActorId,
}
await insertFollowNotification(db, connectedActor, fromActor)
await sleep(10)
await createNotification(db, 'favourite', connectedActor, fromActor, note)
await sleep(10)
await createNotification(db, 'mention', connectedActor, fromActor, note)
const notifications: any = await getNotifications(db, connectedActor)
assert.equal(notifications[0].type, 'mention')
assert.equal(notifications[0].account.username, 'from')
assert.equal(notifications[0].status.id, note.mastodonId)
assert.equal(notifications[1].type, 'favourite')
assert.equal(notifications[1].account.username, 'from')
assert.equal(notifications[1].status.id, note.mastodonId)
assert.equal(notifications[1].status.account.username, 'sven')
assert.equal(notifications[2].type, 'follow')
assert.equal(notifications[2].account.username, 'from')
assert.equal(notifications[2].status, undefined)
})
test('get single non existant notification', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const fromActor: any = { id: await createPerson(domain, db, userKEK, 'from@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actor)
await createNotification(db, 'favourite', actor, fromActor, note)
const res = await notifications_get.handleRequest(domain, '1', db, actor)
assert.equal(res.status, 200)
assertJSON(res)
const data = await res.json<any>()
assert.equal(data.id, '1')
assert.equal(data.type, 'favourite')
assert.equal(data.account.acct, 'from@cloudflare.com')
assert.equal(data.status.content, 'my first status')
})
test('send like notification', async () => {
const db = await makeDB()
await generateVAPIDKeys(db)
await configure(db, { title: 'title', description: 'a', email: 'email' })
const clientKeys = (await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
'sign',
'verify',
])) as CryptoKeyPair
globalThis.fetch = async (input: RequestInfo, data: any) => {
if (input === 'https://push.com') {
assert(data.headers['Authorization'].includes('WebPush'))
const cryptoKeyHeader = parseCryptoKey(data.headers['Crypto-Key'])
assert(cryptoKeyHeader.dh)
assert(cryptoKeyHeader.p256ecdsa)
// Ensure the data has a valid signature using the client public key
const sign = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, clientKeys.privateKey, data.body)
assert(await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, clientKeys.publicKey, sign, data.body))
// TODO: eventually decrypt what the server pushed
return new Response()
}
throw new Error('unexpected request to ' + input)
}
const client = await createTestClient(db)
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const p256dh = arrayBufferToBase64((await crypto.subtle.exportKey('raw', clientKeys.publicKey)) as ArrayBuffer)
const auth = arrayBufferToBase64(crypto.getRandomValues(new Uint8Array(16)))
await createSubscription(db, actor, client, {
subscription: {
endpoint: 'https://push.com',
keys: {
p256dh,
auth,
},
},
data: {
alerts: {},
policy: 'all',
},
})
const fromActor: any = {
id: await createPerson(domain, db, userKEK, 'from@cloudflare.com'),
icon: { url: 'icon.com' },
}
await sendLikeNotification(db, fromActor, actor, 'notifid')
})
})
})

Wyświetl plik

@ -0,0 +1,220 @@
import { getSigningKey } from 'wildebeest/backend/src/mastodon/account'
import * as oauth_authorize from 'wildebeest/functions/oauth/authorize'
import * as first_login from 'wildebeest/functions/first-login'
import * as oauth_token from 'wildebeest/functions/oauth/token'
import {
isUrlValid,
makeDB,
assertCORS,
assertJSON,
assertCache,
streamToArrayBuffer,
createTestClient,
} from '../utils'
import { TEST_JWT, ACCESS_CERTS } from '../test-data'
import { strict as assert } from 'node:assert/strict'
import { configureAccess } from 'wildebeest/backend/src/config/index'
const userKEK = 'test_kek3'
const accessDomain = 'access.com'
const accessAud = 'abcd'
describe('Mastodon APIs', () => {
describe('oauth', () => {
beforeEach(() => {
globalThis.fetch = async (input: RequestInfo) => {
if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') {
return new Response(JSON.stringify(ACCESS_CERTS))
}
if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') {
return new Response(
JSON.stringify({
email: 'some@cloudflare.com',
})
)
}
throw new Error('unexpected request to ' + input)
}
})
test('authorize missing params', async () => {
const db = await makeDB()
await configureAccess(db, accessDomain, accessAud)
let req = new Request('https://example.com/oauth/authorize')
let res = await oauth_authorize.handleRequest(req, db, userKEK)
assert.equal(res.status, 400)
req = new Request('https://example.com/oauth/authorize?scope=foobar')
res = await oauth_authorize.handleRequest(req, db, userKEK)
assert.equal(res.status, 400)
})
test('authorize unsupported response_type', async () => {
const db = await makeDB()
await configureAccess(db, accessDomain, accessAud)
const params = new URLSearchParams({
redirect_uri: 'https://example.com',
response_type: 'hein',
client_id: 'client_id',
})
const req = new Request('https://example.com/oauth/authorize?' + params)
const res = await oauth_authorize.handleRequest(req, db, userKEK)
assert.equal(res.status, 400)
})
test("authorize redirect_uri doesn't match client redirect_uris", async () => {
const db = await makeDB()
const client = await createTestClient(db, 'https://redirect.com')
await configureAccess(db, accessDomain, accessAud)
const params = new URLSearchParams({
redirect_uri: 'https://example.com/a',
response_type: 'code',
client_id: client.id,
})
const headers = { 'Cf-Access-Jwt-Assertion': TEST_JWT }
const req = new Request('https://example.com/oauth/authorize?' + params, {
headers,
})
const res = await oauth_authorize.handleRequest(req, db, userKEK)
assert.equal(res.status, 403)
})
test('authorize redirects with code on success and show first login', async () => {
const db = await makeDB()
const client = await createTestClient(db)
await configureAccess(db, accessDomain, accessAud)
const params = new URLSearchParams({
redirect_uri: client.redirect_uris,
response_type: 'code',
client_id: client.id,
})
const headers = { 'Cf-Access-Jwt-Assertion': TEST_JWT }
const req = new Request('https://example.com/oauth/authorize?' + params, {
headers,
})
const res = await oauth_authorize.handleRequest(req, db, userKEK)
assert.equal(res.status, 302)
const location = new URL(res.headers.get('location') || '')
assert.equal(
location.searchParams.get('redirect_uri'),
encodeURIComponent(`${client.redirect_uris}?code=${client.id}.${TEST_JWT}`)
)
// actor isn't created yet
const { count } = await db.prepare('SELECT count(*) as count FROM actors').first()
assert.equal(count, 0)
})
test('first login creates the user and redirects', async () => {
const db = await makeDB()
const params = new URLSearchParams({
redirect_uri: 'https://redirect.com/a',
email: 'a@cloudflare.com',
})
const formData = new FormData()
formData.set('username', 'username')
formData.set('name', 'name')
const req = new Request('https://example.com/first-login?' + params, {
method: 'POST',
body: formData,
})
const res = await first_login.handlePostRequest(req, db, userKEK)
assert.equal(res.status, 302)
const location = res.headers.get('location')
assert.equal(location, 'https://redirect.com/a')
const actor = await db.prepare('SELECT * FROM actors').first()
const properties = JSON.parse(actor.properties)
assert.equal(actor.email, 'a@cloudflare.com')
assert.equal(properties.preferredUsername, 'username')
assert.equal(properties.name, 'name')
assert(isUrlValid(actor.id))
// ensure that we generate a correct key pairs for the user
assert((await getSigningKey(userKEK, db, actor)) instanceof CryptoKey)
})
test('token error on unknown client', async () => {
const db = await makeDB()
const body = { code: 'some-code' }
const req = new Request('https://example.com/oauth/token', {
method: 'POST',
body: JSON.stringify(body),
})
const res = await oauth_token.handleRequest(db, req)
assert.equal(res.status, 403)
})
test('token returns auth infos', async () => {
const db = await makeDB()
const testScope = 'test abcd'
const client = await createTestClient(db, 'https://localhost', testScope)
const body = {
code: client.id + '.some-code',
}
const req = new Request('https://example.com/oauth/token', {
method: 'POST',
body: JSON.stringify(body),
})
const res = await oauth_token.handleRequest(db, req)
assert.equal(res.status, 200)
assertCORS(res)
assertJSON(res)
const data = await res.json<any>()
assert.equal(data.access_token, body.code)
assert.equal(data.scope, testScope)
})
test('token handles empty code', async () => {
const db = await makeDB()
const body = { code: '' }
const req = new Request('https://example.com/oauth/token', {
method: 'POST',
body: JSON.stringify(body),
})
const res = await oauth_token.handleRequest(db, req)
assert.equal(res.status, 401)
})
test('token returns CORS', async () => {
const db = await makeDB()
const req = new Request('https://example.com/oauth/token', {
method: 'OPTIONS',
})
const res = await oauth_token.handleRequest(db, req)
assert.equal(res.status, 200)
assertCORS(res)
})
test('authorize returns CORS', async () => {
const db = await makeDB()
const req = new Request('https://example.com/oauth/authorize', {
method: 'OPTIONS',
})
const res = await oauth_authorize.handleRequest(req, db, userKEK)
assert.equal(res.status, 200)
assertCORS(res)
})
})
})

Wyświetl plik

@ -0,0 +1,172 @@
import * as search from 'wildebeest/functions/api/v2/search'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { defaultImages } from 'wildebeest/config/accounts'
import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache } from '../utils'
import { strict as assert } from 'node:assert/strict'
const userKEK = 'test_kek11'
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('search', () => {
beforeEach(() => {
globalThis.fetch = async (input: RequestInfo) => {
if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/sven',
},
],
})
)
}
if (
input.toString() ===
'https://remote.com/.well-known/webfinger?resource=acct%3Adefault-avatar-and-header%40remote.com'
) {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/default-avatar-and-header',
},
],
})
)
}
if (input.toString() === 'https://social.com/sven') {
return new Response(
JSON.stringify({
id: 'https://social.com/sven',
type: 'Person',
preferredUsername: 'sven',
name: 'sven ssss',
icon: { url: 'icon.jpg' },
image: { url: 'image.jpg' },
})
)
}
if (input.toString() === 'https://social.com/default-avatar-and-header') {
return new Response(
JSON.stringify({
id: 'https://social.com/default-avatar-and-header',
type: 'Person',
preferredUsername: 'sven',
name: 'sven ssss',
})
)
}
throw new Error(`unexpected request to "${input}"`)
}
})
test('no query returns an error', async () => {
const db = await makeDB()
const req = new Request('https://example.com/api/v2/search')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 400)
})
test('empty results', async () => {
const db = await makeDB()
const req = new Request('https://example.com/api/v2/search?q=non-existing-local-user')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 200)
assertJSON(res)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.accounts.length, 0)
assert.equal(data.statuses.length, 0)
assert.equal(data.hashtags.length, 0)
})
test('queries WebFinger when remote account', async () => {
const db = await makeDB()
const req = new Request('https://example.com/api/v2/search?q=@sven@remote.com&resolve=true')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 200)
assertJSON(res)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.accounts.length, 1)
assert.equal(data.statuses.length, 0)
assert.equal(data.hashtags.length, 0)
const account = data.accounts[0]
assert.equal(account.id, 'sven@remote.com')
assert.equal(account.username, 'sven')
assert.equal(account.acct, 'sven@remote.com')
})
test('queries WebFinger when remote account with default avatar / header', async () => {
const db = await makeDB()
const req = new Request('https://example.com/api/v2/search?q=@default-avatar-and-header@remote.com&resolve=true')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 200)
assertJSON(res)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.accounts.length, 1)
assert.equal(data.statuses.length, 0)
assert.equal(data.hashtags.length, 0)
const account = data.accounts[0]
assert.equal(account.avatar, defaultImages.avatar)
assert.equal(account.header, defaultImages.header)
})
test("don't queries WebFinger when resolve is set to false", async () => {
const db = await makeDB()
globalThis.fetch = () => {
throw new Error('unreachable')
}
const req = new Request('https://example.com/api/v2/search?q=@sven@remote.com&resolve=false')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 200)
assertJSON(res)
assertCORS(res)
})
test('search local actors', async () => {
const db = await makeDB()
await createPerson(domain, db, userKEK, 'username@cloudflare.com', { name: 'foo' })
await createPerson(domain, db, userKEK, 'username2@cloudflare.com', { name: 'bar' })
{
const req = new Request('https://example.com/api/v2/search?q=foo&resolve=false')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.accounts.length, 1)
assert.equal(data.accounts[0].display_name, 'foo')
}
{
const req = new Request('https://example.com/api/v2/search?q=user&resolve=false')
const res = await search.handleRequest(db, req)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.accounts.length, 2)
assert.equal(data.accounts[0].display_name, 'foo')
assert.equal(data.accounts[1].display_name, 'bar')
}
})
})
})

Wyświetl plik

@ -0,0 +1,487 @@
import { strict as assert } from 'node:assert/strict'
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
import { getMentions } from 'wildebeest/backend/src/mastodon/status'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
import * as statuses from 'wildebeest/functions/api/v1/statuses'
import * as statuses_get from 'wildebeest/functions/api/v1/statuses/[id]'
import * as statuses_favourite from 'wildebeest/functions/api/v1/statuses/[id]/favourite'
import * as statuses_reblog from 'wildebeest/functions/api/v1/statuses/[id]/reblog'
import * as statuses_context from 'wildebeest/functions/api/v1/statuses/[id]/context'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
import { isUrlValid, makeDB, assertCORS, assertJSON, assertCache, streamToArrayBuffer } from '../utils'
import * as note from 'wildebeest/backend/src/activitypub/objects/note'
const userKEK = 'test_kek4'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('statuses', () => {
test('create new status missing params', async () => {
const db = await makeDB()
const body = { status: 'my status' }
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(body),
})
const connectedActor: any = {}
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
assert.equal(res.status, 400)
})
test('create new status creates Note', async () => {
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const body = {
status: 'my status',
visibility: 'public',
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(body),
})
const connectedActor: any = { id: actorId }
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
assert.equal(res.status, 200)
assertJSON(res)
const data = await res.json<any>()
assert(data.uri.includes('example.com'))
assert(data.uri.includes(data.id))
// Required fields from https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java
assert(data.created_at !== undefined)
assert(data.account !== undefined)
assert(data.visibility !== undefined)
assert(data.spoiler_text !== undefined)
assert(data.media_attachments !== undefined)
assert(data.mentions !== undefined)
assert(data.tags !== undefined)
assert(data.emojis !== undefined)
assert(!isUrlValid(data.id))
const row = await db
.prepare(
`
SELECT
json_extract(properties, '$.content') as content,
original_actor_id,
original_object_id
FROM objects
`
)
.first()
assert.equal(row.content, 'my status')
assert.equal(row.original_actor_id.toString(), actorId.toString())
assert.equal(row.original_object_id, null)
})
test("create new status adds to Actor's outbox", async () => {
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const body = {
status: 'my status',
visibility: 'public',
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(body),
})
const connectedActor: any = { id: actorId }
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
assert.equal(res.status, 200)
const data = await res.json<any>()
const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first()
assert.equal(row.count, 1)
})
test('create new status with mention delivers ActivityPub Note', async () => {
let deliveredNote: any = null
globalThis.fetch = async (input: RequestInfo, data: any) => {
if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') {
return new Response(
JSON.stringify({
links: [
{
rel: 'self',
type: 'application/activity+json',
href: 'https://social.com/sven',
},
],
})
)
}
if (input.toString() === 'https://social.com/sven') {
return new Response(
JSON.stringify({
id: 'https://social.com/sven',
inbox: 'https://social.com/sven/inbox',
})
)
}
if (input === 'https://social.com/sven/inbox') {
assert.equal(data.method, 'POST')
const body = JSON.parse(data.body)
deliveredNote = body
return new Response()
}
// @ts-ignore: shut up
if (Object.keys(input).includes('url') && input.url === 'https://social.com/sven/inbox') {
const request = input as Request
assert.equal(request.method, 'POST')
const bodyB = await streamToArrayBuffer(request.body as ReadableStream)
const dec = new TextDecoder()
const body = JSON.parse(dec.decode(bodyB))
deliveredNote = body
return new Response()
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const actorId = await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
const body = {
status: '@sven@remote.com my status',
visibility: 'public',
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(body),
})
const connectedActor: any = { id: actorId, type: 'Person' }
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
assert.equal(res.status, 200)
assert(deliveredNote)
assert.equal(deliveredNote.type, 'Create')
assert.equal(deliveredNote.actor, `https://${domain}/ap/users/sven`)
assert.equal(deliveredNote.object.attributedTo, `https://${domain}/ap/users/sven`)
assert.equal(deliveredNote.object.type, 'Note')
assert(deliveredNote.object.to.includes(note.PUBLIC))
assert.equal(deliveredNote.object.cc.length, 1)
})
test('create new status with image', async () => {
const db = await makeDB()
const connectedActor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const properties = { url: 'foo' }
const image = await createImage(domain, db, connectedActor, properties)
const body = {
status: 'my status',
media_ids: [image.mastodonId],
visibility: 'public',
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(body),
})
const res = await statuses.handleRequest(req, db, connectedActor, userKEK)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert(!isUrlValid(data.id))
})
test('favourite status sends Like activity', async () => {
let deliveredActivity: any = null
const db = await makeDB()
const actor = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const originalObjectId = 'https://example.com/note123'
await db
.prepare(
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)'
)
.bind(
'https://example.com/object1',
'Note',
JSON.stringify({ content: 'my first status' }),
actor.id.toString(),
originalObjectId,
'mastodonid1'
)
.run()
globalThis.fetch = async (input: any, data: any) => {
if (input === actor.id.toString()) {
return new Response(
JSON.stringify({
id: actor.id,
inbox: 'https://social.com/sven/inbox',
})
)
}
if (input.url === 'https://social.com/sven/inbox') {
assert.equal(input.method, 'POST')
const body = await input.json()
deliveredActivity = body
return new Response()
}
throw new Error('unexpected request to ' + JSON.stringify(input))
}
const connectedActor: any = actor
const res = await statuses_favourite.handleRequest(db, 'mastodonid1', connectedActor, userKEK)
assert.equal(res.status, 200)
assert(deliveredActivity)
assert.equal(deliveredActivity.type, 'Like')
assert.equal(deliveredActivity.object, originalObjectId)
})
test('favourite records in db', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actor)
const connectedActor: any = actor
const res = await statuses_favourite.handleRequest(db, note.mastodonId!, connectedActor, userKEK)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.favourited, true)
const row = await db.prepare(`SELECT * FROM actor_favourites`).first()
assert.equal(row.actor_id, actor.id.toString())
assert.equal(row.object_id, note.id.toString())
})
test('get mentions from status', () => {
{
const mentions = getMentions('test status')
assert.equal(mentions.length, 0)
}
{
const mentions = getMentions('@sven@instance.horse test status')
assert.equal(mentions.length, 1)
assert.equal(mentions[0].localPart, 'sven')
assert.equal(mentions[0].domain, 'instance.horse')
}
{
const mentions = getMentions('@sven test status')
assert.equal(mentions.length, 1)
assert.equal(mentions[0].localPart, 'sven')
assert.equal(mentions[0].domain, null)
}
{
const mentions = getMentions('@sven @james @pete')
assert.deepEqual(mentions, [
{ localPart: 'sven', domain: null },
{ localPart: 'james', domain: null },
{ localPart: 'pete', domain: null },
])
}
{
const mentions = getMentions('<p>@sven</p>')
assert.deepEqual(mentions, [{ localPart: 'sven', domain: null }])
}
})
test('get status count likes', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') }
const actor3: any = { id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actor)
await insertLike(db, actor2, note)
await insertLike(db, actor3, note)
const res = await statuses_get.handleRequest(db, note.mastodonId!)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.favourites_count, 2)
})
test('get status with image', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const properties = { url: 'https://example.com/image.jpg' }
const mediaAttachments = [await createImage(domain, db, actor, properties)]
const note = await createPublicNote(domain, db, 'my first status', actor, mediaAttachments)
const res = await statuses_get.handleRequest(db, note.mastodonId!)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.media_attachments.length, 1)
assert.equal(data.media_attachments[0].url, properties.url)
assert.equal(data.media_attachments[0].preview_url, properties.url)
assert.equal(data.media_attachments[0].type, 'image')
})
test('status context shows descendants', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note = await createPublicNote(domain, db, 'a post', actor)
await addObjectInOutbox(db, actor, note)
await sleep(10)
const inReplyTo = note.id
const reply = await createPublicNote(domain, db, 'a reply', actor, [], { inReplyTo })
await addObjectInOutbox(db, actor, reply)
await sleep(10)
await insertReply(db, actor, reply, note)
const res = await statuses_context.handleRequest(domain, db, note.mastodonId!)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.ancestors.length, 0)
assert.equal(data.descendants.length, 1)
assert.equal(data.descendants[0].content, 'a reply')
})
describe('reblog', () => {
test('get status count reblogs', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const actor2: any = { id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') }
const actor3: any = { id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actor)
await insertReblog(db, actor2, note)
await insertReblog(db, actor3, note)
const res = await statuses_get.handleRequest(db, note.mastodonId!)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.reblogs_count, 2)
})
test('reblog records in db', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note = await createPublicNote(domain, db, 'my first status', actor)
const connectedActor: any = actor
const res = await statuses_reblog.handleRequest(db, note.mastodonId!, connectedActor, userKEK)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data.reblogged, true)
const row = await db.prepare(`SELECT * FROM actor_reblogs`).first()
assert.equal(row.actor_id, actor.id.toString())
assert.equal(row.object_id, note.id.toString())
})
test('reblog status adds in actor outbox', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const originalObjectId = 'https://example.com/note123'
await db
.prepare(
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, mastodon_id, local) VALUES (?, ?, ?, ?, ?, ?, 0)'
)
.bind(
'https://example.com/object1',
'Note',
JSON.stringify({ content: 'my first status' }),
actor.id.toString(),
originalObjectId,
'mastodonid1'
)
.run()
const connectedActor: any = actor
const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK)
assert.equal(res.status, 200)
const row = await db.prepare(`SELECT * FROM outbox_objects`).first()
assert.equal(row.actor_id, actor.id.toString())
assert.equal(row.object_id, 'https://example.com/object1')
})
test('reblog remote status status sends Announce activity to author', async () => {
let deliveredActivity: any = null
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const originalObjectId = 'https://example.com/note123'
await db
.prepare(
'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, mastodon_id, local) VALUES (?, ?, ?, ?, ?, ?, 0)'
)
.bind(
'https://example.com/object1',
'Note',
JSON.stringify({ content: 'my first status' }),
actor.id.toString(),
originalObjectId,
'mastodonid1'
)
.run()
globalThis.fetch = async (input: any, data: any) => {
if (input === actor.id.toString()) {
return new Response(
JSON.stringify({
id: actor.id,
inbox: 'https://social.com/sven/inbox',
})
)
}
if (input.url === 'https://social.com/sven/inbox') {
assert.equal(input.method, 'POST')
const body = await input.json()
deliveredActivity = body
return new Response()
}
throw new Error('unexpected request to ' + JSON.stringify(input))
}
const connectedActor: any = actor
const res = await statuses_reblog.handleRequest(db, 'mastodonid1', connectedActor, userKEK)
assert.equal(res.status, 200)
assert(deliveredActivity)
assert.equal(deliveredActivity.type, 'Announce')
assert.equal(deliveredActivity.actor, actor.id.toString())
assert.equal(deliveredActivity.object, originalObjectId)
})
})
})
})

Wyświetl plik

@ -0,0 +1,239 @@
import { strict as assert } from 'node:assert/strict'
import { insertReply } from 'wildebeest/backend/src/mastodon/reply'
import { createImage } from 'wildebeest/backend/src/activitypub/objects/image'
import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow'
import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note'
import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { makeDB, assertCORS, assertJSON, assertCache } from '../utils'
import * as timelines_home from 'wildebeest/functions/api/v1/timelines/home'
import * as timelines_public from 'wildebeest/functions/api/v1/timelines/public'
import * as timelines from 'wildebeest/backend/src/mastodon/timeline'
import { insertLike } from 'wildebeest/backend/src/mastodon/like'
import { insertReblog } from 'wildebeest/backend/src/mastodon/reblog'
const userKEK = 'test_kek6'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
const domain = 'cloudflare.com'
describe('Mastodon APIs', () => {
describe('timelines', () => {
test('home returns Notes in following Actors', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
const actor3: any = {
id: await createPerson(domain, db, userKEK, 'sven3@cloudflare.com'),
}
// Actor is following actor2, but not actor3.
await addFollowing(db, actor, actor2, 'not needed')
await acceptFollowing(db, actor, actor2)
// Actor 2 is posting
const firstNoteFromActor2 = await createPublicNote(domain, db, 'first status from actor2', actor2)
await addObjectInOutbox(db, actor2, firstNoteFromActor2)
await sleep(10)
await addObjectInOutbox(db, actor2, await createPublicNote(domain, db, 'second status from actor2', actor2))
await sleep(10)
await addObjectInOutbox(db, actor3, await createPublicNote(domain, db, 'first status from actor3', actor3))
await sleep(10)
await insertLike(db, actor, firstNoteFromActor2)
await insertReblog(db, actor, firstNoteFromActor2)
// Actor should only see posts from actor2 in the timeline
const connectedActor: any = actor
const data = await timelines.getHomeTimeline(domain, db, connectedActor)
assert.equal(data.length, 2)
assert(data[0].id)
assert.equal(data[0].content, 'second status from actor2')
assert.equal(data[0].account.username, 'sven2')
assert.equal(data[1].content, 'first status from actor2')
assert.equal(data[1].account.username, 'sven2')
assert.equal(data[1].favourites_count, 1)
assert.equal(data[1].reblogs_count, 1)
})
test('home returns Notes from ourself', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
// Actor is posting
await addObjectInOutbox(db, actor, await createPublicNote(domain, db, 'status from myself', actor))
// Actor should only see posts from actor2 in the timeline
const connectedActor: any = actor
const data = await timelines.getHomeTimeline(domain, db, connectedActor)
assert.equal(data.length, 1)
assert(data[0].id)
assert.equal(data[0].content, 'status from myself')
assert.equal(data[0].account.username, 'sven')
})
test('home returns cache', async () => {
const connectedActor: any = { id: 'id' }
const kv_cache: any = {
async get(key: string) {
assert.equal(key, 'id/timeline/home')
return 'cached data'
},
}
const req = new Request('https://' + domain)
const data = await timelines_home.handleRequest(req, kv_cache, connectedActor)
assert.equal(await data.text(), 'cached data')
})
test('public returns Notes', async () => {
const db = await makeDB()
const actor: any = {
id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com'),
}
const actor2: any = {
id: await createPerson(domain, db, userKEK, 'sven2@cloudflare.com'),
}
const statusFromActor = await createPublicNote(domain, db, 'status from actor', actor)
await addObjectInOutbox(db, actor, statusFromActor)
await sleep(10)
await addObjectInOutbox(db, actor2, await createPublicNote(domain, db, 'status from actor2', actor2))
await insertLike(db, actor, statusFromActor)
await insertReblog(db, actor, statusFromActor)
const res = await timelines_public.handleRequest(domain, db)
assert.equal(res.status, 200)
assertJSON(res)
assertCORS(res)
const data = await res.json<any>()
assert.equal(data.length, 2)
assert(data[0].id)
assert.equal(data[0].content, 'status from actor2')
assert.equal(data[0].account.username, 'sven2')
assert.equal(data[1].content, 'status from actor')
assert.equal(data[1].account.username, 'sven')
assert.equal(data[1].favourites_count, 1)
assert.equal(data[1].reblogs_count, 1)
// if we request only remote objects nothing should be returned
const remoteRes = await timelines_public.handleRequest(domain, db, {
local: false,
remote: true,
only_media: false,
})
assert.equal(remoteRes.status, 200)
assertJSON(remoteRes)
assertCORS(remoteRes)
const remoteData = await remoteRes.json<any>()
assert.equal(remoteData.length, 0)
})
test('public includes attachment', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const properties = { url: 'https://example.com/image.jpg' }
const mediaAttachments = [await createImage(domain, db, actor, properties)]
const note = await createPublicNote(domain, db, 'status from actor', actor, mediaAttachments)
await addObjectInOutbox(db, actor, note)
const res = await timelines_public.handleRequest(domain, db)
assert.equal(res.status, 200)
const data = await res.json<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('public timeline uses published_date', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note1 = await createPublicNote(domain, db, 'note1', actor)
const note2 = await createPublicNote(domain, db, 'note2', actor)
const note3 = await createPublicNote(domain, db, 'note3', actor)
await addObjectInOutbox(db, actor, note1, '2022-12-10T23:48:38Z')
await addObjectInOutbox(db, actor, note2, '2000-12-10T23:48:38Z')
await addObjectInOutbox(db, actor, note3, '2048-12-10T23:48:38Z')
const res = await timelines_public.handleRequest(domain, db)
assert.equal(res.status, 200)
const data = await res.json<any>()
assert.equal(data[0].content, 'note3')
assert.equal(data[1].content, 'note1')
assert.equal(data[2].content, 'note2')
})
test('timelines hides and counts replies', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note = await createPublicNote(domain, db, 'a post', actor)
await addObjectInOutbox(db, actor, note)
await sleep(10)
const inReplyTo = note.id
const reply = await createPublicNote(domain, db, 'a reply', actor, [], { inReplyTo })
await addObjectInOutbox(db, actor, reply)
await sleep(10)
await insertReply(db, actor, reply, note)
const connectedActor: any = actor
{
const data = await timelines.getHomeTimeline(domain, db, connectedActor)
assert.equal(data.length, 1)
assert.equal(data[0].content, 'a post')
assert.equal(data[0].replies_count, 1)
}
{
const data = await timelines.getPublicTimeline(domain, db, timelines.LocalPreference.NotSet)
assert.equal(data.length, 1)
assert.equal(data[0].content, 'a post')
assert.equal(data[0].replies_count, 1)
}
})
test('show status reblogged', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note = await createPublicNote(domain, db, 'a post', actor)
await addObjectInOutbox(db, actor, note)
await insertReblog(db, actor, note)
const connectedActor: any = actor
const data = await timelines.getHomeTimeline(domain, db, connectedActor)
assert.equal(data.length, 1)
assert.equal(data[0].reblogged, true)
})
test('show status favourited', async () => {
const db = await makeDB()
const actor: any = { id: await createPerson(domain, db, userKEK, 'sven@cloudflare.com') }
const note = await createPublicNote(domain, db, 'a post', actor)
await addObjectInOutbox(db, actor, note)
await insertLike(db, actor, note)
const connectedActor: any = actor
const data = await timelines.getHomeTimeline(domain, db, connectedActor)
assert.equal(data.length, 1)
assert.equal(data[0].favourited, true)
})
})
})

Wyświetl plik

@ -0,0 +1,16 @@
import { strict as assert } from 'node:assert/strict'
import * as trends_statuses from 'wildebeest/functions/api/v1/trends/statuses'
import { makeDB, assertJSON } from '../utils'
describe('Mastodon APIs', () => {
describe('trends', () => {
test('trending statuses return empty array', async () => {
const res = await trends_statuses.onRequest()
assert.equal(res.status, 200)
assertJSON(res)
const data = await res.json<any>()
assert.equal(data.length, 0)
})
})
})

Wyświetl plik

@ -0,0 +1,120 @@
import { isUrlValid, makeDB, assertCORS } from './utils'
import { createPerson } from 'wildebeest/backend/src/activitypub/actors'
import { TEST_JWT, ACCESS_CERTS } from './test-data'
import { strict as assert } from 'node:assert/strict'
import { configureAccess } from 'wildebeest/backend/src/config/index'
import * as middleware_main from 'wildebeest/backend/src/middleware/main'
const userKEK = 'test_kek12'
const domain = 'cloudflare.com'
const accessDomain = 'access.com'
const accessAud = 'abcd'
describe('middleware', () => {
test('CORS on OPTIONS', async () => {
const request = new Request('https://example.com', { method: 'OPTIONS' })
const ctx: any = {
request,
}
const res = await middleware_main.main(ctx)
assert.equal(res.status, 200)
assertCORS(res)
})
test('test no identity', async () => {
globalThis.fetch = async (input: RequestInfo) => {
if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') {
return new Response(JSON.stringify(ACCESS_CERTS))
}
if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') {
return new Response('', { status: 404 })
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const headers = { authorization: 'Bearer APPID.' + TEST_JWT }
const request = new Request('https://example.com', { headers })
const ctx: any = {
env: { DATABASE: db },
data: {},
request,
}
const res = await middleware_main.main(ctx)
assert.equal(res.status, 401)
})
test('test user not found', async () => {
globalThis.fetch = async (input: RequestInfo) => {
if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') {
return new Response(JSON.stringify(ACCESS_CERTS))
}
if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') {
return new Response(
JSON.stringify({
email: 'some@cloudflare.com',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
const headers = { authorization: 'Bearer APPID.' + TEST_JWT }
const request = new Request('https://example.com', { headers })
const ctx: any = {
env: { DATABASE: db },
data: {},
request,
}
const res = await middleware_main.main(ctx)
assert.equal(res.status, 401)
})
test('success passes data and calls next', async () => {
globalThis.fetch = async (input: RequestInfo) => {
if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') {
return new Response(JSON.stringify(ACCESS_CERTS))
}
if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') {
return new Response(
JSON.stringify({
email: 'sven@cloudflare.com',
})
)
}
throw new Error('unexpected request to ' + input)
}
const db = await makeDB()
await createPerson(domain, db, userKEK, 'sven@cloudflare.com')
await configureAccess(db, accessDomain, accessAud)
const headers = { authorization: 'Bearer APPID.' + TEST_JWT }
const request = new Request('https://example.com', { headers })
const ctx: any = {
next: () => new Response(),
data: {},
env: { DATABASE: db },
request,
}
const res = await middleware_main.main(ctx)
assert.equal(res.status, 200)
assert(!ctx.data.connectedUser)
assert(isUrlValid(ctx.data.connectedActor.id))
assert.equal(ctx.data.accessDomain, accessDomain)
assert.equal(ctx.data.accessAud, accessAud)
})
})

Wyświetl plik

@ -0,0 +1,50 @@
import * as startInstance from 'wildebeest/functions/start-instance'
import { TEST_JWT, ACCESS_CERTS } from './test-data'
import { strict as assert } from 'node:assert/strict'
import { makeDB } from './utils'
const accessDomain = 'access.com'
const accessAud = 'abcd'
describe('Wildebeest', () => {
globalThis.fetch = async (input: RequestInfo) => {
if (input === 'https://' + accessDomain + '/cdn-cgi/access/certs') {
return new Response(JSON.stringify(ACCESS_CERTS))
}
if (input === 'https://' + accessDomain + '/cdn-cgi/access/get-identity') {
return new Response(
JSON.stringify({
email: 'some@cloudflare.com',
})
)
}
throw new Error('unexpected request to ' + input)
}
test('start instance should generate a VAPID key and store a JWK', async () => {
const db = await makeDB()
const body = JSON.stringify({
title: 'title',
description: 'description',
email: 'email',
accessDomain,
accessAud,
})
const headers = {
cookie: 'CF_Authorization=' + TEST_JWT,
}
const req = new Request('https://example.com', { method: 'POST', body, headers })
const res = await startInstance.handlePostRequest(req, db)
assert.equal(res.status, 201)
const { value } = await db.prepare("SELECT value FROM instance_config WHERE key = 'vapid_jwk'").first()
const jwk = JSON.parse(value)
assert.equal(jwk.key_ops.length, 1)
assert.equal(jwk.key_ops[0], 'sign')
assert.equal(jwk.crv, 'P-256')
})
})

Wyświetl plik

@ -0,0 +1,38 @@
// Response from https://that-test.cloudflareaccess.com/cdn-cgi/access/certs
export const ACCESS_CERTS = {
keys: [
{
kid: 'd112849a221376416483d8ddd41dc301a2d4fa67c04a49e35ead3fb98908bc7e',
kty: 'RSA',
alg: 'RS256',
use: 'sig',
e: 'AQAB',
n: 'xV1PApGhRR3VTx2fxDfgIMKPO2y-31aZRGG03QyrVgOTgU7sVDQcri6d9Ae5KobNh2Xpyw5iLjme_KHraw_JMsvA4jqQrUZff6YYItMc3AI5N0jUj4MAC5JA_7nBrELO5XIldyXwNdruzrVSdZCxUIjCO_7wGIGt7t75wHxXo88ggPHp4qioGe5wXKkQOMGF1SpWyNBKIVzmCYcQwiku1KI8BqERbCq28zvdfursTv6mhkJ0hMnd0iTDCoxJtWyG5yZWsPdBMa8zjbfGcRVYCZulxR19KPY_UDQAx3AhNRMTS2JrAWIFRTPAf1OUcj0fxNAjhw0EgBRUH5SCVmb8yQ',
},
{
kid: '26edff6c8d4d065bc771a11424d2fbb4ce53352f508154895a93b1045d4d8de8',
kty: 'RSA',
alg: 'RS256',
use: 'sig',
e: 'AQAB',
n: 'vToHdbded4Qb3IJ94Jquh9rnAnsgzxg-0cqdDLan1pSo0KVq8oovVQ8N1736vtwMtQ18eHLUhBAwe0H_DG5PDvwwHXdACuJ1mPGdpqtlzTjFXfjwRcFKRBZxMYTEhOGixMvXpO4LPfbeDLLk2iBTTDhS3evrzHl9bbgkqBB-tOY2Jd2dASjthsrdUKV8ODoLI5CyzcsQHxS3_lqLnwvk4MThafoCbSftV0pN52jKxBygisCvD-uCzvTLK0XFjA5l4wLXF5vJHDMUpYRnv3HmfoiTlt6flZ6iTq8fDxzOHm1u2KjMUoSFNGJZdId3J19_6P6KwaBjxYAcKbTbZ-2myQ',
},
],
public_cert: {
kid: 'd112849a221376416483d8ddd41dc301a2d4fa67c04a49e35ead3fb98908bc7e',
cert: '-----BEGIN CERTIFICATE-----\nMIIDUDCCAjigAwIBAgIQalPMevEDwYmA8uOnTQbLtDANBgkqhkiG9w0BAQsFADBi\nMQswCQYDVQQGEwJVUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjET\nMBEGA1UEChMKQ2xvdWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5j\nb20wHhcNMjIxMTI1MjExODIzWhcNMjMxMjA5MjExODIzWjBiMQswCQYDVQQGEwJV\nUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjETMBEGA1UEChMKQ2xv\ndWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFXU8CkaFFHdVPHZ/EN+Agwo87bL7fVplE\nYbTdDKtWA5OBTuxUNByuLp30B7kqhs2HZenLDmIuOZ78oetrD8kyy8DiOpCtRl9/\nphgi0xzcAjk3SNSPgwALkkD/ucGsQs7lciV3JfA12u7OtVJ1kLFQiMI7/vAYga3u\n3vnAfFejzyCA8eniqKgZ7nBcqRA4wYXVKlbI0EohXOYJhxDCKS7UojwGoRFsKrbz\nO91+6uxO/qaGQnSEyd3SJMMKjEm1bIbnJlaw90ExrzONt8ZxFVgJm6XFHX0o9j9Q\nNADHcCE1ExNLYmsBYgVFM8B/U5RyPR/E0COHDQSAFFQflIJWZvzJAgMBAAGjAjAA\nMA0GCSqGSIb3DQEBCwUAA4IBAQA6Eir3jYipcJ9MdLxq4iMnDbWQT3F3tnsan9ni\nQa0N1YuAu6M9rsDbhCZz/igidUYqEFb4MEVMrQvPp6ChQc9J2hi8qGqAJoMZGZV6\nKCxSfwSrOdprDUYodoaTcEZ4oxcrx6vu6NX+2RluSu2Q04Co2+D/0jF3ABMm8fo6\n+oBLCJcHhNC57XEaMwtPCeA/SXareUAgl7mZDaHHWqLx0D5OEo4d1PEoJLyQdFcV\nIxq/vf8kE+dbY7OSwkcXOaScvxWm398GxV924zFxsijO6D0pOu7A0WTDH5n5fAIX\n4BaROg1WTOjiaL8XoqUOt0y1MSMp5HcjJnoFMImSlsHcoBMA\n-----END CERTIFICATE-----\n',
},
public_certs: [
{
kid: 'd112849a221376416483d8ddd41dc301a2d4fa67c04a49e35ead3fb98908bc7e',
cert: '-----BEGIN CERTIFICATE-----\nMIIDUDCCAjigAwIBAgIQalPMevEDwYmA8uOnTQbLtDANBgkqhkiG9w0BAQsFADBi\nMQswCQYDVQQGEwJVUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjET\nMBEGA1UEChMKQ2xvdWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5j\nb20wHhcNMjIxMTI1MjExODIzWhcNMjMxMjA5MjExODIzWjBiMQswCQYDVQQGEwJV\nUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjETMBEGA1UEChMKQ2xv\ndWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDFXU8CkaFFHdVPHZ/EN+Agwo87bL7fVplE\nYbTdDKtWA5OBTuxUNByuLp30B7kqhs2HZenLDmIuOZ78oetrD8kyy8DiOpCtRl9/\nphgi0xzcAjk3SNSPgwALkkD/ucGsQs7lciV3JfA12u7OtVJ1kLFQiMI7/vAYga3u\n3vnAfFejzyCA8eniqKgZ7nBcqRA4wYXVKlbI0EohXOYJhxDCKS7UojwGoRFsKrbz\nO91+6uxO/qaGQnSEyd3SJMMKjEm1bIbnJlaw90ExrzONt8ZxFVgJm6XFHX0o9j9Q\nNADHcCE1ExNLYmsBYgVFM8B/U5RyPR/E0COHDQSAFFQflIJWZvzJAgMBAAGjAjAA\nMA0GCSqGSIb3DQEBCwUAA4IBAQA6Eir3jYipcJ9MdLxq4iMnDbWQT3F3tnsan9ni\nQa0N1YuAu6M9rsDbhCZz/igidUYqEFb4MEVMrQvPp6ChQc9J2hi8qGqAJoMZGZV6\nKCxSfwSrOdprDUYodoaTcEZ4oxcrx6vu6NX+2RluSu2Q04Co2+D/0jF3ABMm8fo6\n+oBLCJcHhNC57XEaMwtPCeA/SXareUAgl7mZDaHHWqLx0D5OEo4d1PEoJLyQdFcV\nIxq/vf8kE+dbY7OSwkcXOaScvxWm398GxV924zFxsijO6D0pOu7A0WTDH5n5fAIX\n4BaROg1WTOjiaL8XoqUOt0y1MSMp5HcjJnoFMImSlsHcoBMA\n-----END CERTIFICATE-----\n',
},
{
kid: '26edff6c8d4d065bc771a11424d2fbb4ce53352f508154895a93b1045d4d8de8',
cert: '-----BEGIN CERTIFICATE-----\nMIIDUDCCAjigAwIBAgIQDHWwCqyvlIwH+WIPr57DNzANBgkqhkiG9w0BAQsFADBi\nMQswCQYDVQQGEwJVUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjET\nMBEGA1UEChMKQ2xvdWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5j\nb20wHhcNMjIxMTI1MjExODIzWhcNMjMxMjA5MjExODIzWjBiMQswCQYDVQQGEwJV\nUzEOMAwGA1UECBMFVGV4YXMxDzANBgNVBAcTBkF1c3RpbjETMBEGA1UEChMKQ2xv\ndWRmbGFyZTEdMBsGA1UEAxMUY2xvdWRmbGFyZWFjY2Vzcy5jb20wggEiMA0GCSqG\nSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC9Ogd1t153hBvcgn3gmq6H2ucCeyDPGD7R\nyp0MtqfWlKjQpWryii9VDw3Xvfq+3Ay1DXx4ctSEEDB7Qf8Mbk8O/DAdd0AK4nWY\n8Z2mq2XNOMVd+PBFwUpEFnExhMSE4aLEy9ek7gs99t4MsuTaIFNMOFLd6+vMeX1t\nuCSoEH605jYl3Z0BKO2Gyt1QpXw4OgsjkLLNyxAfFLf+WoufC+TgxOFp+gJtJ+1X\nSk3naMrEHKCKwK8P64LO9MsrRcWMDmXjAtcXm8kcMxSlhGe/ceZ+iJOW3p+VnqJO\nrx8PHM4ebW7YqMxShIU0Yll0h3cnX3/o/orBoGPFgBwptNtn7abJAgMBAAGjAjAA\nMA0GCSqGSIb3DQEBCwUAA4IBAQB2syAsQnUJizx0cjYq1bvVlpTdNKTj7aL6QRjC\nUqfJaLuI4ciP7DgPAIPlAcopU3S6KOXwjl8jZotBEB7wKnlatWPdCOGe1U6DozQn\n/qvSXWZ8N8gtyeXWOeh37JxRmg4qBO7h+QVto0WRX7P8WV3sS07yxcv5LlxxhQmD\nUXmoNEu/u4LP6fsa6Yibz6vtr4+Eu7TwkYq3C9dPvsBkMGDSkD8lqJ/Cu6TfoUC2\nEJAY3vcamqFdvlK6dfFM0nQ5JYubJwmn4stFqNDJaXQbxVvqeUokYw7aAyoLX6nu\nsK92TNP9E/VpOQfor2esOJBVYTtTmv/Q2QJrJpPRmUhpNlMc\n-----END CERTIFICATE-----\n',
},
],
}
export const TEST_JWT =
'eyJhbGciOiJSUzI1NiIsImtpZCI6ImQxMTI4NDlhMjIxMzc2NDE2NDgzZDhkZGQ0MWRjMzAxYTJkNGZhNjdjMDRhNDllMzVlYWQzZmI5ODkwOGJjN2UifQ.eyJhdWQiOlsiYjQ1YjE2Mjc0YTFhMjEyYjk1Y2JjNjA4MTAzNWM1Yzc2MWM4MTIyOTY5MzIzZjE2NDRjYWZkY2QwYjI3MzU1ZSJdLCJlbWFpbCI6InN2ZW5AY2xvdWRmbGFyZS5jb20iLCJleHAiOjE2NzA5Njg5OTMsImlhdCI6MTY3MDM2NDE5MywibmJmIjoxNjcwMzY0MTkzLCJpc3MiOiJodHRwczovL3RoYXQtdGVzdC5jbG91ZGZsYXJlYWNjZXNzLmNvbSIsInR5cGUiOiJhcHAiLCJpZGVudGl0eV9ub25jZSI6IjNHcmdlSExnUGNrOWNnbUEiLCJzdWIiOiI1YzlhZjE0NC0wOTc2LTQ4NTMtYjBjOC0zYWUyODkyYmQ2ZDAiLCJjb3VudHJ5IjoiRlIifQ.EvMo2vXL3-_qFrROO5Pk7r3oUQGmDF2HOzQq9OSMMBESdT3TESKZ48NC36hrmOfB-_6Pi_iQrc1EE_X6U3rs66UwEyGnF7NjEMiKMFaBRQp5wGANTTSuLz1VpDlzv7mTGqREd7kwTEOe0jzJMEtbhkbp8aQ_w01aBGmgyz2FM3FSTurzd3_r82nn9tqmsjpZXE0pGOzazjT8gPO6JRrwM5myCQ83f8NlZMIz8OXAk3Y-W0429tOiwZvPuVnyFb_vBEQmPlyDWeg_hSBVI1pTiyml_I9irMfaQhGmw3PDfMMkvQdOC-MfPO23Yu56awq_OVJoR8FjfHfPGYeLa-bvMQ'

Wyświetl plik

@ -0,0 +1,74 @@
import { strict as assert } from 'node:assert/strict'
import { parseHandle } from '../src/utils/parse'
import { urlToHandle } from '../src/utils/handle'
import { generateUserKey, unwrapPrivateKey, importPublicKey } from 'wildebeest/backend/src/utils/key-ops'
import { signRequest } from 'wildebeest/backend/src/utils/http-signing'
import { generateDigestHeader } from 'wildebeest/backend/src/utils/http-signing-cavage'
import { parseRequest } from 'wildebeest/backend/src/utils/httpsigjs/parser'
import { fetchKey, verifySignature } from 'wildebeest/backend/src/utils/httpsigjs/verifier'
describe('utils', () => {
test('user key lifecycle', async () => {
const userKEK = 'userkey'
const userKeyPair = await generateUserKey(userKEK)
await unwrapPrivateKey(userKEK, userKeyPair.wrappedPrivKey, userKeyPair.salt)
await importPublicKey(userKeyPair.pubKey)
})
test('request signing', async () => {
const body = '{"foo": "bar"}'
const digest = await generateDigestHeader(body)
const request = new Request('https://example.com', {
method: 'POST',
body: body,
headers: { header1: 'value1', Digest: digest },
})
const userKEK = 'userkey'
const userKeyPair = await generateUserKey(userKEK)
const privateKey = await unwrapPrivateKey(userKEK, userKeyPair.wrappedPrivKey, userKeyPair.salt)
const keyid = new URL('https://foo.com/key')
await signRequest(request, privateKey, keyid)
assert(request.headers.has('Signature'), 'no signature in signed request')
const parsedSignature = parseRequest(request)
const publicKey = await importPublicKey(userKeyPair.pubKey)
assert(await verifySignature(parsedSignature, publicKey), 'verify signature failed')
})
test('handle parsing', async () => {
let res
res = parseHandle('')
assert.equal(res.localPart, '')
assert.equal(res.domain, null)
res = parseHandle('@a')
assert.equal(res.localPart, 'a')
assert.equal(res.domain, null)
res = parseHandle('a')
assert.equal(res.localPart, 'a')
assert.equal(res.domain, null)
res = parseHandle('@a@remote.com')
assert.equal(res.localPart, 'a')
assert.equal(res.domain, 'remote.com')
res = parseHandle('a@remote.com')
assert.equal(res.localPart, 'a')
assert.equal(res.domain, 'remote.com')
res = parseHandle('a%40masto.ai')
assert.equal(res.localPart, 'a')
assert.equal(res.domain, 'masto.ai')
})
test('URL to handle', async () => {
let res
res = urlToHandle(new URL('https://host.org/users/foobar'))
assert.equal(res, 'foobar@host.org')
})
})

Wyświetl plik

@ -0,0 +1,66 @@
import { strict as assert } from 'node:assert/strict'
import { createClient } from 'wildebeest/backend/src/mastodon/client'
import type { Client } from 'wildebeest/backend/src/mastodon/client'
import { promises as fs } from 'fs'
import { BetaDatabase } from '@miniflare/d1'
import * as Database from 'better-sqlite3'
export function isUrlValid(s: string) {
let url
try {
url = new URL(s)
} catch (err) {
return false
}
return url.protocol === 'https:'
}
export async function makeDB(): Promise<any> {
const db = new Database(':memory:')
const db2 = new BetaDatabase(db)!
// Manually run our migrations since @miniflare/d1 doesn't support it (yet).
const initial = await fs.readFile('./migrations/0000_initial.sql', 'utf-8')
await db.exec(initial)
return db2
}
export function assertCORS(response: Response) {
assert(response.headers.has('Access-Control-Allow-Origin'))
assert(response.headers.has('Access-Control-Allow-Headers'))
}
export function assertJSON(response: Response) {
assert.equal(response.headers.get('content-type'), 'application/json; charset=utf-8')
}
export function assertCache(response: Response, maxge: number) {
assert(response.headers.has('cache-control'))
assert(response.headers.get('cache-control')!.includes('max-age=' + maxge))
}
export async function streamToArrayBuffer(stream: ReadableStream) {
let result = new Uint8Array(0)
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
const newResult = new Uint8Array(result.length + value.length)
newResult.set(result)
newResult.set(value, result.length)
result = newResult
}
return result
}
export async function createTestClient(
db: D1Database,
redirectUri: string = 'https://localhost',
scopes: string = 'read follow'
): Promise<Client> {
return createClient(db, 'test client', redirectUri, 'https://cloudflare.com', scopes)
}

Wyświetl plik

@ -0,0 +1,55 @@
import { makeDB, assertCache } from './utils'
import { strict as assert } from 'node:assert/strict'
import * as webfinger from 'wildebeest/functions/.well-known/webfinger'
describe('WebFinger', () => {
test('no resource queried', async () => {
const db = await makeDB()
const req = new Request('https://example.com/.well-known/webfinger')
const res = await webfinger.handleRequest(req, db)
assert.equal(res.status, 400)
})
test('invalid resource', async () => {
const db = await makeDB()
const req = new Request('https://example.com/.well-known/webfinger?resource=hein:a')
const res = await webfinger.handleRequest(req, db)
assert.equal(res.status, 400)
})
test('query local account', async () => {
const db = await makeDB()
const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven')
const res = await webfinger.handleRequest(req, db)
assert.equal(res.status, 400)
})
test('query remote non-existing account', async () => {
const db = await makeDB()
const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven@example.com')
const res = await webfinger.handleRequest(req, db)
assert.equal(res.status, 404)
})
test('query remote existing account', async () => {
const db = await makeDB()
await db
.prepare('INSERT INTO actors (id, email, type) VALUES (?, ?, ?)')
.bind('https://example.com/ap/users/sven', 'sven@cloudflare.com', 'Person')
.run()
const req = new Request('https://example.com/.well-known/webfinger?resource=acct:sven@example.com')
const res = await webfinger.handleRequest(req, db)
assert.equal(res.status, 200)
assert.equal(res.headers.get('content-type'), 'application/jrd+json')
assertCache(res, 3600)
const data = await res.json<any>()
assert.equal(data.links.length, 1)
})
})

19
cfsetup.yaml 100644
Wyświetl plik

@ -0,0 +1,19 @@
default-flavor: bullseye
template:
build:
builddeps: &builddeps
nodejs:
procps:
post-cache:
bullseye:
test:
builddeps:
<<: *builddeps
post-cache:
- yarn install
- yarn pretty
- yarn test
# Until https://github.com/cloudflare/wrangler2/issues/2463 is resolved.
# - yarn database:create-mock && yarn test:ui

Wyświetl plik

@ -0,0 +1,6 @@
import type { DefaultImages } from '../backend/src/types/configs'
export const defaultImages: DefaultImages = {
avatar: 'https://masto.ai/avatars/original/missing.png',
header: 'https://arrifana.org/system/cache/accounts/headers/109/541/309/468/846/872/original/89ed71066eac95f7.png',
}

Wyświetl plik

@ -0,0 +1,37 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:qwik/recommended'],
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
ecmaVersion: 2021,
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
plugins: ['@typescript-eslint'],
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-empty-interface': 'error',
'@typescript-eslint/no-namespace': 'error',
'@typescript-eslint/no-empty-function': 'error',
'@typescript-eslint/no-this-alias': 'error',
'@typescript-eslint/ban-types': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
'prefer-spread': 'error',
'no-case-declarations': 'error',
'no-console': 'error',
'@typescript-eslint/no-unused-vars': ['error'],
'prefer-const': 'error',
},
}

41
frontend/.gitignore vendored 100644
Wyświetl plik

@ -0,0 +1,41 @@
# Build
/dist
/lib
/lib-types
/server
# Development
node_modules
# Cache
.cache
.mf
.vscode
.rollup.cache
tsconfig.tsbuildinfo
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Editor
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Yarn
.yarn/*
!.yarn/releases
# Cloudflare
functions/**/*.js

17
frontend/README.md 100644
Wyświetl plik

@ -0,0 +1,17 @@
# Wildebeest UI
This directory contains a website that server-side renders a readonly public view of the data available via the REST APIs of the server.
The site is built using the Qwik framework, which consists of client-side JavaScript code, static assets and a server-side Cloudflare Pages Function to do the server-side rendering.
In the top level of the repository run the following to build the app and host the whole server:
```
yarn dev
```
If you make a change to the Qwik application, you can open a new terminal and run the following to regenerate the website code:
```
yarn --cwd ui build
```

Wyświetl plik

@ -0,0 +1,20 @@
import { cloudflarePagesAdaptor } from '@builder.io/qwik-city/adaptors/cloudflare-pages/vite'
import { extendConfig } from '@builder.io/qwik-city/vite'
import baseConfig from '../../vite.config'
export default extendConfig(baseConfig, () => {
return {
build: {
ssr: true,
rollupOptions: {
input: ['src/entry.cloudflare-pages.tsx', '@qwik-city-plan'],
},
},
plugins: [
cloudflarePagesAdaptor({
// Do not SSG as the D1 database is not available at build time, I think.
// staticGenerate: true,
}),
],
}
})

Wyświetl plik

@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest',
verbose: true,
testMatch: ["<rootDir>/test/**/(*.)+(spec|test).[jt]s?(x)"],
testTimeout:15000,
}

Wyświetl plik

@ -0,0 +1,42 @@
import { createPerson, getPersonByEmail, type Person } from 'wildebeest/backend/src/activitypub/actors'
import * as statusesAPI from 'wildebeest/functions/api/v1/statuses'
import { statuses } from 'wildebeest/frontend/src/dummyData'
import type { MastodonStatus } from 'wildebeest/frontend/src/types'
import type { MastodonAccount } from 'wildebeest/backend/src/types'
const kek = 'test-kek'
/**
* Run helper commands to initialize the database with actors, statuses, etc.
*/
export async function init(domain: string, db: D1Database) {
for (const status of statuses as MastodonStatus[]) {
const actor = await getOrCreatePerson(domain, db, status.account.username)
await createStatus(db, actor, status.content)
}
}
/**
* Create a status object in the given actors outbox.
*/
async function createStatus(db: D1Database, actor: Person, status: string, visibility = 'public') {
const body = {
status,
visibility,
}
const req = new Request('https://example.com', {
method: 'POST',
body: JSON.stringify(body),
})
await statusesAPI.handleRequest(req, db, actor, kek)
}
async function getOrCreatePerson(domain: string, db: D1Database, username: string): Promise<Person> {
const person = await getPersonByEmail(db, username)
if (person) return person
await createPerson(domain, db, kek, username)
const newPerson = await getPersonByEmail(db, username)
if (!newPerson) {
throw new Error('Could not create Actor ' + username)
}
return newPerson
}

Wyświetl plik

@ -0,0 +1,33 @@
import console from 'console';
import { dirname, resolve } from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
import { unstable_dev } from 'wrangler'
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* A simple utility to run a Cloudflare Worker that will populate a local D1 database with mock data.
*
* Uses Wrangler's `unstable_dev()` helper to execute the Worker and exit cleanly;
* this is much harder to do with the command line Wrangler binary.
*/
async function main() {
const options = {
local: true,
persist: true,
nodeCompat: true,
config: resolve(__dirname, '../../wrangler.toml'),
tsconfig: resolve(__dirname, '../../tsconfig.json'),
define: ['jest:{}'],
}
const workerPath = resolve(__dirname, "./worker.ts");
const worker = await unstable_dev(workerPath, options, { disableExperimentalWarning: true })
await worker.fetch()
await worker.stop()
}
main().catch((e) => {
console.error(e)
process.exitCode = 1
})

Wyświetl plik

@ -0,0 +1,35 @@
import { init } from './init'
interface Env {
DATABASE: D1Database
}
/**
* A Cloudflare Worker that will run helpers against a D1 database to populate it with mock data.
*/
const handler: ExportedHandler<Env> = {
async fetch(req, { DATABASE }) {
const domain = new URL(req.url).hostname
try {
await init(domain, DATABASE)
console.log('Database initialized.')
} catch (e) {
if (isD1ConstraintError(e)) {
console.log('Database already initialized.')
} else {
throw e
}
}
return new Response('OK')
},
}
/**
* Check whether the error is because of a SQL constraint,
* which will indicate that the database was already populated.
*/
function isD1ConstraintError(e: unknown) {
return (e as any).message === 'D1_RUN_ERROR' && (e as any).cause?.code === 'SQLITE_CONSTRAINT_PRIMARYKEY'
}
export default handler

Some files were not shown because too many files have changed in this diff Show More