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>
This commit is contained in:
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
Loading…
Reference in New Issue