kopia lustrzana https://github.com/cloudflare/wildebeest
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
commit
25be15b2a0
|
@ -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
|
|
@ -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',
|
||||||
|
},
|
||||||
|
}
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
yarn-error.log
|
||||||
|
package-lock.json
|
||||||
|
.wrangler/state/d1/*.sqlite3
|
|
@ -0,0 +1 @@
|
||||||
|
16.13
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Files Prettier should not format
|
||||||
|
**/*.log
|
||||||
|
**/.DS_Store
|
||||||
|
*.
|
||||||
|
dist
|
||||||
|
node_modules
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"useTabs": true,
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
|
@ -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
|
||||||
|
```
|
|
@ -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.
|
|
@ -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/
|
|
@ -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()
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>>()
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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 []
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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}',
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface Env {
|
||||||
|
DATABASE: D1Database
|
||||||
|
KV_CACHE: KVNamespace
|
||||||
|
userKEK: string
|
||||||
|
CF_ACCOUNT_ID: string
|
||||||
|
CF_API_TOKEN: string
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from './status'
|
||||||
|
export * from './account'
|
||||||
|
|
||||||
|
export type UUID = string
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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>
|
||||||
|
}
|
|
@ -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}`
|
||||||
|
}
|
|
@ -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}"`)
|
||||||
|
}
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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.
|
|
@ -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
|
||||||
|
}
|
|
@ -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]
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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]))
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface JWK {
|
||||||
|
crv: string
|
||||||
|
kty: string
|
||||||
|
key_ops: string[]
|
||||||
|
ext: boolean
|
||||||
|
d: string
|
||||||
|
x: string
|
||||||
|
y: string
|
||||||
|
}
|
|
@ -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)),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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}` }
|
||||||
|
}
|
|
@ -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,
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
|
@ -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'
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
|
@ -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',
|
||||||
|
}
|
|
@ -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',
|
||||||
|
},
|
||||||
|
}
|
|
@ -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
|
|
@ -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
|
||||||
|
```
|
|
@ -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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
|
@ -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,
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
||||||
|
})
|
|
@ -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
Ładowanie…
Reference in New Issue