diff --git a/.github/workflows/PRs.yml b/.github/workflows/PRs.yml index 2ebf305..807b7b4 100644 --- a/.github/workflows/PRs.yml +++ b/.github/workflows/PRs.yml @@ -53,6 +53,9 @@ jobs: - name: Check frontend linting run: yarn lint:frontend + - name: Check frontend types + run: yarn --cwd types-check + test-ui: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 24d4cb7..e3c45ed 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -273,6 +273,7 @@ jobs: echo -e "DOMAIN=\"${{ vars.CF_DEPLOY_DOMAIN }}\"\n" >> consumer/wrangler.toml echo -e "ADMIN_EMAIL=\"${{ vars.ADMIN_EMAIL }}\"\n" >> consumer/wrangler.toml + yarn yarn --cwd consumer/ echo "******" command: publish --config consumer/wrangler.toml diff --git a/backend/src/accounts/alias.ts b/backend/src/accounts/alias.ts new file mode 100644 index 0000000..999f70e --- /dev/null +++ b/backend/src/accounts/alias.ts @@ -0,0 +1,20 @@ +import { setActorAlias } from 'wildebeest/backend/src/activitypub/actors' +import type { Actor } from 'wildebeest/backend/src/activitypub/actors' +import { parseHandle } from 'wildebeest/backend/src/utils/parse' +import { queryAcct } from 'wildebeest/backend/src/webfinger' +import { type Database } from 'wildebeest/backend/src/database' + +export async function addAlias(db: Database, alias: string, connectedActor: Actor) { + const handle = parseHandle(alias) + const acct = `${handle.localPart}@${handle.domain}` + if (handle.domain === null) { + throw new Error("account migration within an instance isn't supported") + } + + const actor = await queryAcct(handle.domain, db, acct) + if (actor === null) { + throw new Error('actor not found') + } + + await setActorAlias(db, connectedActor.id, actor.id) +} diff --git a/backend/src/accounts/getAccount.ts b/backend/src/accounts/getAccount.ts index 304ba8a..959e3fc 100644 --- a/backend/src/accounts/getAccount.ts +++ b/backend/src/accounts/getAccount.ts @@ -24,7 +24,7 @@ export async function getAccount(domain: string, accountId: string, db: Database } } -async function getRemoteAccount(handle: Handle, acct: string, db: D1Database): Promise { +async function getRemoteAccount(handle: Handle, acct: string, db: Database): Promise { // TODO: using webfinger isn't the optimal implementation. We could cache // the object in D1 and directly query the remote API, indicated by the actor's // url field. For now, let's keep it simple. diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts index 7156827..3bd94e2 100644 --- a/backend/src/activitypub/actors/index.ts +++ b/backend/src/activitypub/actors/index.ts @@ -3,10 +3,12 @@ import { generateUserKey } from 'wildebeest/backend/src/utils/key-ops' import { type APObject, sanitizeContent, getTextContent } from '../objects' import { addPeer } from 'wildebeest/backend/src/activitypub/peers' import { type Database } from 'wildebeest/backend/src/database' +import { Buffer } from 'buffer' const PERSON = 'Person' const isTesting = typeof jest !== 'undefined' export const emailSymbol = Symbol() +export const isAdminSymbol = Symbol() export function actorURL(domain: string, id: string): URL { return new URL(`/ap/users/${id}`, 'https://' + domain) @@ -22,6 +24,7 @@ export interface Actor extends APObject { alsoKnownAs?: string [emailSymbol]: string + [isAdminSymbol]: boolean } // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-person @@ -150,7 +153,8 @@ export async function createPerson( db: Database, userKEK: string, email: string, - properties: PersonProperties = {} + properties: PersonProperties = {}, + admin: boolean = false ): Promise { const userKeyPair = await generateUserKey(userKEK) @@ -158,7 +162,7 @@ export async function createPerson( // 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) { + if (isTesting || db.client === 'neon') { privkey = Buffer.from(userKeyPair.wrappedPrivKey) salt = Buffer.from(userKeyPair.salt) } else { @@ -198,12 +202,12 @@ export async function createPerson( const row = await db .prepare( ` - INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties) - VALUES(?, ?, ?, ?, ?, ?, ?) + INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties, is_admin) + VALUES(?, ?, ?, ?, ?, ?, ?, ?) RETURNING * ` ) - .bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties)) + .bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties), admin ? 1 : null) .first() return personFromRow(row) @@ -211,7 +215,7 @@ export async function createPerson( export async function updateActorProperty(db: Database, actorId: URL, key: string, value: string) { const { success, error } = await db - .prepare(`UPDATE actors SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`) + .prepare(`UPDATE actors SET properties=${db.qb.jsonSet('properties', key, '?1')} WHERE id=?2`) .bind(value, actorId.toString()) .run() if (!success) { @@ -220,12 +224,24 @@ export async function updateActorProperty(db: Database, actorId: URL, key: strin } export async function setActorAlias(db: Database, actorId: URL, alias: URL) { - const { success, error } = await db - .prepare(`UPDATE actors SET properties=json_set(properties, '$.alsoKnownAs', json_array(?)) WHERE id=?`) - .bind(alias.toString(), actorId.toString()) - .run() - if (!success) { - throw new Error('SQL error: ' + error) + if (db.client === 'neon') { + const { success, error } = await db + .prepare(`UPDATE actors SET properties=${db.qb.jsonSet('properties', 'alsoKnownAs,0', '?1')} WHERE id=?2`) + .bind('"' + alias.toString() + '"', actorId.toString()) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } + } else { + const { success, error } = await db + .prepare( + `UPDATE actors SET properties=${db.qb.jsonSet('properties', 'alsoKnownAs', 'json_array(?1)')} WHERE id=?2` + ) + .bind(alias.toString(), actorId.toString()) + .run() + if (!success) { + throw new Error('SQL error: ' + error) + } } } @@ -240,7 +256,16 @@ export async function getActorById(db: Database, id: URL): Promise } export function personFromRow(row: any): Person { - const properties = JSON.parse(row.properties) as PersonProperties + let properties + if (typeof row.properties === 'object') { + // neon uses JSONB for properties which is returned as a deserialized + // object. + properties = row.properties as PersonProperties + } else { + // D1 uses a string for JSON properties + properties = JSON.parse(row.properties) as PersonProperties + } + const icon = properties.icon ?? { type: 'Image', mediaType: 'image/jpeg', @@ -296,6 +321,7 @@ export function personFromRow(row: any): Person { return { // Hidden values [emailSymbol]: row.email, + [isAdminSymbol]: row.is_admin === 1, ...properties, name, diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts index 9a1be6c..db736d1 100644 --- a/backend/src/activitypub/objects/index.ts +++ b/backend/src/activitypub/objects/index.ts @@ -128,7 +128,15 @@ export async function cacheObject( } { - const properties = JSON.parse(row.properties) + let properties + if (typeof row.properties === 'object') { + // neon uses JSONB for properties which is returned as a deserialized + // object. + properties = row.properties + } else { + // D1 uses a string for JSON properties + properties = JSON.parse(row.properties) + } const object = { published: new Date(row.cdate).toISOString(), ...properties, @@ -159,7 +167,7 @@ export async function updateObject(db: Database, properties: any, id: URL): Prom export async function updateObjectProperty(db: Database, obj: APObject, key: string, value: string) { const { success, error } = await db - .prepare(`UPDATE objects SET properties=json_set(properties, '$.${key}', ?) WHERE id=?`) + .prepare(`UPDATE objects SET properties=${db.qb.jsonSet('properties', key, '?1')} WHERE id=?2`) .bind(value, obj.id.toString()) .run() if (!success) { @@ -206,7 +214,15 @@ export async function getObjectBy(db: Database, key: ObjectByKey, value: string) } const result: any = results[0] - const properties = JSON.parse(result.properties) + let properties + if (typeof result.properties === 'object') { + // neon uses JSONB for properties which is returned as a deserialized + // object. + properties = result.properties + } else { + // D1 uses a string for JSON properties + properties = JSON.parse(result.properties) + } return { published: new Date(result.cdate).toISOString(), @@ -270,7 +286,8 @@ function getContentRewriter() { contentRewriter.on('*', { element(el) { if (!['p', 'span', 'br', 'a'].includes(el.tagName)) { - el.tagName = 'p' + const element = el as { tagName: string } + element.tagName = 'p' } if (el.hasAttribute('class')) { diff --git a/backend/src/activitypub/peers.ts b/backend/src/activitypub/peers.ts index 3f05220..bd2a6b0 100644 --- a/backend/src/activitypub/peers.ts +++ b/backend/src/activitypub/peers.ts @@ -9,10 +9,9 @@ export async function getPeers(db: Database): Promise> { } export async function addPeer(db: Database, domain: string): Promise { - const query = ` - INSERT OR IGNORE INTO peers (domain) - VALUES (?) - ` + const query = db.qb.insertOrIgnore(` + INTO peers (domain) VALUES (?) + `) const out = await db.prepare(query).bind(domain).run() if (!out.success) { diff --git a/backend/src/config/rules.ts b/backend/src/config/rules.ts new file mode 100644 index 0000000..68a1342 --- /dev/null +++ b/backend/src/config/rules.ts @@ -0,0 +1,29 @@ +import { type Database } from 'wildebeest/backend/src/database' + +export async function getRules(db: Database): Promise> { + const query = `SELECT * from server_rules;` + const result = await db.prepare(query).all<{ id: string; text: string }>() + + if (!result.success) { + throw new Error('SQL error: ' + result.error) + } + + return result.results ?? [] +} + +export async function upsertRule(db: Database, rule: { id?: number; text: string } | string) { + const id = typeof rule === 'string' ? null : rule.id ?? null + const text = typeof rule === 'string' ? rule : rule.text + return await db + .prepare( + `INSERT INTO server_rules (id, text) + VALUES (?, ?) + ON CONFLICT(id) DO UPDATE SET text=excluded.text;` + ) + .bind(id, text) + .run() +} + +export async function deleteRule(db: Database, ruleId: number) { + return await db.prepare('DELETE FROM server_rules WHERE id=?').bind(ruleId).run() +} diff --git a/backend/src/config/server.ts b/backend/src/config/server.ts new file mode 100644 index 0000000..132c78c --- /dev/null +++ b/backend/src/config/server.ts @@ -0,0 +1,49 @@ +import { type Database } from 'wildebeest/backend/src/database' +import { type ServerSettingsData } from 'wildebeest/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout' + +export async function getSettings(db: Database): Promise { + const query = `SELECT * from server_settings` + const result = await db.prepare(query).all<{ setting_name: string; setting_value: string }>() + + const data = (result.results ?? []).reduce( + (settings, { setting_name, setting_value }) => ({ + ...settings, + [setting_name]: setting_value, + }), + {} as Object + ) + + if (!result.success) { + throw new Error('SQL Error: ' + result.error) + } + + return data as ServerSettingsData +} + +export async function updateSettings(db: Database, data: Partial) { + const result = await upsertServerSettings(db, data) + if (result && !result.success) { + throw new Error('SQL Error: ' + result.error) + } + + return new Response('', { status: 200 }) +} + +export async function upsertServerSettings(db: Database, settings: Partial) { + const settingsEntries = Object.entries(settings) + + if (!settingsEntries.length) { + return null + } + + const query = ` + INSERT INTO server_settings (setting_name, setting_value) + VALUES ${settingsEntries.map(() => `(?, ?)`).join(', ')} + ON CONFLICT(setting_name) DO UPDATE SET setting_value=excluded.setting_value + ` + + return await db + .prepare(query) + .bind(...settingsEntries.flat()) + .run() +} diff --git a/backend/src/database/d1.ts b/backend/src/database/d1.ts index 99359e8..f13b433 100644 --- a/backend/src/database/d1.ts +++ b/backend/src/database/d1.ts @@ -1,6 +1,40 @@ -import { type Database } from 'wildebeest/backend/src/database' +import { type Database, QueryBuilder } from 'wildebeest/backend/src/database' import type { Env } from 'wildebeest/backend/src/types/env' -export default function make({ DATABASE }: Pick): Database { - return DATABASE +const qb: QueryBuilder = { + jsonExtract(obj: string, prop: string): string { + return `json_extract(${obj}, '$.${prop}')` + }, + + jsonExtractIsNull(obj: string, prop: string): string { + return `${qb.jsonExtract(obj, prop)} IS NULL` + }, + + set(array: string): string { + return `(SELECT value FROM json_each(${array}))` + }, + + epoch(): string { + return '00-00-00 00:00:00' + }, + + insertOrIgnore(q: string): string { + return `INSERT OR IGNORE ${q}` + }, + + psqlOnly(): string { + return '' + }, + + jsonSet(obj: string, field: string, value: string): string { + return `json_set(${obj}, '$.${field}', ${value})` + }, +} + +export default function make({ DATABASE }: Pick): Database { + const db = DATABASE as any + db.qb = qb + db.client = 'd1' + + return db as Database } diff --git a/backend/src/database/index.ts b/backend/src/database/index.ts index 260409e..9e17829 100644 --- a/backend/src/database/index.ts +++ b/backend/src/database/index.ts @@ -1,5 +1,6 @@ import type { Env } from 'wildebeest/backend/src/types/env' import d1 from './d1' +import neon from './neon' export interface Result { results?: T[] @@ -13,6 +14,8 @@ export interface Database { dump(): Promise batch(statements: PreparedStatement[]): Promise[]> exec(query: string): Promise> + qb: QueryBuilder + client: string } export interface PreparedStatement { @@ -23,6 +26,20 @@ export interface PreparedStatement { raw(): Promise } -export function getDatabase(env: Pick): Database { +export interface QueryBuilder { + jsonExtract(obj: string, prop: string): string + jsonExtractIsNull(obj: string, prop: string): string + set(array: string): string + epoch(): string + insertOrIgnore(q: string): string + psqlOnly(raw: string): string + jsonSet(obj: string, field: string, value: string): string +} + +export async function getDatabase(env: Pick): Promise { + if (env.NEON_DATABASE_URL !== undefined) { + return neon(env) + } + return d1(env) } diff --git a/backend/src/database/neon.sql b/backend/src/database/neon.sql new file mode 100644 index 0000000..a7d6ec0 --- /dev/null +++ b/backend/src/database/neon.sql @@ -0,0 +1,195 @@ +-- Migration number: 0000 2022-12-05T20:27:34.391Z + +CREATE TABLE IF NOT EXISTS actors ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + email TEXT, + privkey bytea, + privkey_salt bytea, + pubkey TEXT, + cdate timestamp NOT NULL DEFAULT (now()), + properties jsonb NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX IF NOT EXISTS actors_email ON actors(email); + +CREATE TABLE IF NOT EXISTS actor_following ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + target_actor_id TEXT NOT NULL, + target_actor_acct TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending', + cdate timestamp NOT NULL DEFAULT (now()) +); + +CREATE INDEX IF NOT EXISTS actor_following_actor_id ON actor_following(actor_id); +CREATE INDEX IF NOT EXISTS actor_following_target_actor_id ON actor_following(target_actor_id); + +CREATE TABLE IF NOT EXISTS objects ( + id TEXT PRIMARY KEY, + mastodon_id TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()), + original_actor_id TEXT, + original_object_id TEXT UNIQUE, + reply_to_object_id TEXT, + properties jsonb NOT NULL DEFAULT '{}'::jsonb, + local INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS inbox_objects ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + object_id TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()) +); + +CREATE TABLE IF NOT EXISTS outbox_objects ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + object_id TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()), + published_date timestamp NOT NULL DEFAULT (now()) + +); + +CREATE TABLE IF NOT EXISTS actor_notifications ( + id SERIAL PRIMARY KEY, + type TEXT NOT NULL, + actor_id TEXT NOT NULL, + from_actor_id TEXT NOT NULL, + object_id TEXT, + cdate timestamp NOT NULL DEFAULT (now()) + +); + +CREATE INDEX IF NOT EXISTS actor_notifications_actor_id ON actor_notifications(actor_id); + +CREATE TABLE IF NOT EXISTS actor_favourites ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + object_id TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()) + +); + +CREATE INDEX IF NOT EXISTS actor_favourites_actor_id ON actor_favourites(actor_id); +CREATE INDEX IF NOT EXISTS actor_favourites_object_id ON actor_favourites(object_id); + +CREATE TABLE IF NOT EXISTS actor_reblogs ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + object_id TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()) + +); + +CREATE INDEX IF NOT EXISTS actor_reblogs_actor_id ON actor_reblogs(actor_id); +CREATE INDEX IF NOT EXISTS actor_reblogs_object_id ON actor_reblogs(object_id); + +CREATE TABLE IF NOT EXISTS clients ( + id TEXT PRIMARY KEY, + secret TEXT NOT NULL, + name TEXT NOT NULL, + redirect_uris TEXT NOT NULL, + website TEXT, + scopes TEXT, + cdate timestamp NOT NULL DEFAULT (now()) +); + +CREATE TABLE IF NOT EXISTS actor_replies ( + id TEXT PRIMARY KEY, + actor_id TEXT NOT NULL, + object_id TEXT NOT NULL, + in_reply_to_object_id TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()) + +); + +CREATE INDEX IF NOT EXISTS actor_replies_in_reply_to_object_id ON actor_replies(in_reply_to_object_id); +-- Migration number: 0001 2023-01-16T13:09:04.033Z + +CREATE UNIQUE INDEX unique_actor_following ON actor_following (actor_id, target_actor_id); +-- Migration number: 0002 2023-01-16T13:46:54.975Z + +ALTER TABLE outbox_objects + ADD target TEXT NOT NULL DEFAULT 'https://www.w3.org/ns/activitystreams#Public'; +-- Migration number: 0003 2023-02-02T15:03:27.478Z + +CREATE TABLE IF NOT EXISTS peers ( + domain TEXT UNIQUE NOT NULL +); +-- Migration number: 0004 2023-02-03T17:17:19.099Z + +CREATE INDEX IF NOT EXISTS outbox_objects_actor_id ON outbox_objects(actor_id); +CREATE INDEX IF NOT EXISTS outbox_objects_target ON outbox_objects(target); +-- Migration number: 0005 2023-02-07T10:57:21.848Z + +CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT PRIMARY KEY, + object_id TEXT NOT NULL, + expires_at timestamp NOT NULL + +); +-- Migration number: 0006 2023-02-13T11:18:03.485Z + +CREATE TABLE IF NOT EXISTS note_hashtags ( + value TEXT NOT NULL, + object_id TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()) + +); +-- Migration number: 0007 2023-02-15T11:01:46.585Z + +CREATE TABLE subscriptions ( + id SERIAL PRIMARY KEY, + actor_id TEXT NOT NULL, + client_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + key_p256dh TEXT NOT NULL, + key_auth TEXT NOT NULL, + alert_mention INTEGER NOT NULL, + alert_status INTEGER NOT NULL, + alert_reblog INTEGER NOT NULL, + alert_follow INTEGER NOT NULL, + alert_follow_request INTEGER NOT NULL, + alert_favourite INTEGER NOT NULL, + alert_poll INTEGER NOT NULL, + alert_update INTEGER NOT NULL, + alert_admin_sign_up INTEGER NOT NULL, + alert_admin_report INTEGER NOT NULL, + policy TEXT NOT NULL, + cdate timestamp NOT NULL DEFAULT (now()), + + UNIQUE(actor_id, client_id) +); + +-- Migration number: 0003 2023-02-24T15:03:27.478Z + +CREATE TABLE IF NOT EXISTS server_settings ( + setting_name TEXT UNIQUE NOT NULL, + setting_value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS server_rules ( + id INTEGER PRIMARY KEY, + text TEXT NOT NULL +); + +-- Migration number: 0009 2023-02-28T13:58:08.319Z + +ALTER TABLE actors + ADD is_admin INTEGER; + +UPDATE actors SET is_admin = 1 +WHERE id = + (SELECT id + FROM actors + ORDER BY cdate ASC LIMIT 1 ); + +-- Migration number: 0010 2023-03-08T09:40:30.734Z + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +INSERT INTO clients (id, secret, name, redirect_uris, scopes) +VALUES ('924801be-d211-495d-8cac-e73503413af8', encode(gen_random_bytes(42), 'hex'), 'Wildebeest User Interface', '/', 'all'); diff --git a/backend/src/database/neon.ts b/backend/src/database/neon.ts new file mode 100644 index 0000000..0dde858 --- /dev/null +++ b/backend/src/database/neon.ts @@ -0,0 +1,132 @@ +import * as neon from '@neondatabase/serverless' +import type { Database, Result, QueryBuilder } from 'wildebeest/backend/src/database' +import type { Env } from 'wildebeest/backend/src/types/env' + +function sqliteToPsql(query: string): string { + let c = 0 + return query.replace(/\?([0-9])?/g, (match: string, p1: string) => { + c += 1 + return `$${p1 || c}` + }) +} + +const qb: QueryBuilder = { + jsonExtract(obj: string, prop: string): string { + return `jsonb_extract_path(${obj}, '${prop}')::text` + }, + + jsonExtractIsNull(obj: string, prop: string): string { + return `${qb.jsonExtract(obj, prop)} = 'null'` + }, + + set(array: string): string { + return `(SELECT value::text FROM json_array_elements_text(${array}))` + }, + + epoch(): string { + return 'epoch' + }, + + insertOrIgnore(q: string): string { + return `INSERT ${q} ON CONFLICT DO NOTHING` + }, + + psqlOnly(q: string): string { + return q + }, + + jsonSet(obj: string, field: string, value: string): string { + return `jsonb_set(${obj}, '{${field}}', ${value})` + }, +} + +export default async function make(env: Pick): Promise { + const client = new neon.Client(env.NEON_DATABASE_URL) + await client.connect() + + return { + client: 'neon', + qb, + + prepare(query: string) { + return new PreparedStatement(env, query, [], client) + }, + + dump() { + throw new Error('not implemented') + }, + + async batch(statements: PreparedStatement[]): Promise[]> { + const results = [] + + for (let i = 0, len = statements.length; i < len; i++) { + const query = sqliteToPsql(statements[i].query) + const result = await client.query(query, statements[i].values) + + results.push({ + results: result.rows as T[], + success: true, + meta: {}, + }) + } + + return results + }, + + async exec(query: string): Promise> { + throw new Error('not implemented') + console.log(query) + }, + } +} + +export class PreparedStatement { + private env: Pick + private client: neon.Client + public query: string + public values: any[] + + constructor(env: Pick, query: string, values: any[], client: neon.Client) { + this.env = env + this.query = query + this.values = values + this.client = client + } + + bind(...values: any[]): PreparedStatement { + return new PreparedStatement(this.env, this.query, [...this.values, ...values], this.client) + } + + async first(colName?: string): Promise { + if (colName) { + throw new Error('not implemented') + } + const query = sqliteToPsql(this.query) + + const results = await this.client.query(query, this.values) + if (results.rows.length !== 1) { + throw new Error(`expected a single row, returned ${results.rows.length} row(s)`) + } + + return results.rows[0] as T + } + + async run(): Promise> { + return this.all() + } + + async all(): Promise> { + const query = sqliteToPsql(this.query) + const results = await this.client.query(query, this.values) + + return { + results: results.rows as T[], + success: true, + meta: {}, + } + } + + async raw(): Promise { + throw new Error('not implemented') + } +} diff --git a/backend/src/mastodon/account.ts b/backend/src/mastodon/account.ts index 7c81612..170b675 100644 --- a/backend/src/mastodon/account.ts +++ b/backend/src/mastodon/account.ts @@ -89,5 +89,12 @@ SELECT export async function getSigningKey(instanceKey: string, db: Database, actor: Actor): Promise { 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)) + + if (privkey.buffer && privkey_salt.buffer) { + // neon.tech + return unwrapPrivateKey(instanceKey, new Uint8Array(privkey.buffer), new Uint8Array(privkey_salt.buffer)) + } else { + // D1 + return unwrapPrivateKey(instanceKey, new Uint8Array(privkey), new Uint8Array(privkey_salt)) + } } diff --git a/backend/src/mastodon/client.ts b/backend/src/mastodon/client.ts index 2c2df3d..1f96606 100644 --- a/backend/src/mastodon/client.ts +++ b/backend/src/mastodon/client.ts @@ -6,16 +6,16 @@ export interface Client { secret: string name: string redirect_uris: string - website: string scopes: string + website?: string } export async function createClient( db: Database, name: string, redirect_uris: string, - website: string, - scopes: string + scopes: string, + website?: string ): Promise { const id = crypto.randomUUID() @@ -28,7 +28,10 @@ export async function createClient( 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() + const { success, error } = await db + .prepare(query) + .bind(id, secret, name, redirect_uris, website === undefined ? null : website, scopes) + .run() if (!success) { throw new Error('SQL error: ' + error) } diff --git a/backend/src/mastodon/follow.ts b/backend/src/mastodon/follow.ts index 49f0552..db563a3 100644 --- a/backend/src/mastodon/follow.ts +++ b/backend/src/mastodon/follow.ts @@ -10,11 +10,12 @@ const STATE_ACCEPTED = 'accepted' // During a migration we move the followers from the old Actor to the new export async function moveFollowers(db: Database, actor: Actor, followers: Array): Promise { const batch = [] - const stmt = db.prepare(` - INSERT OR IGNORE + const stmt = db.prepare( + db.qb.insertOrIgnore(` INTO actor_following (id, actor_id, target_actor_id, target_actor_acct, state) - VALUES (?1, ?2, ?3, ?4, 'accepted'); + VALUES (?1, ?2, ?3, ?4, 'accepted') `) + ) const actorId = actor.id.toString() const actorAcc = urlToHandle(actor.id) @@ -32,11 +33,12 @@ export async function moveFollowers(db: Database, actor: Actor, followers: Array export async function moveFollowing(db: Database, actor: Actor, followingActors: Array): Promise { const batch = [] - const stmt = db.prepare(` - INSERT OR IGNORE + const stmt = db.prepare( + db.qb.insertOrIgnore(` INTO actor_following (id, actor_id, target_actor_id, target_actor_acct, state) - VALUES (?1, ?2, ?3, ?4, 'accepted'); + VALUES (?1, ?2, ?3, ?4, 'accepted') `) + ) const actorId = actor.id.toString() @@ -56,10 +58,10 @@ export async function moveFollowing(db: Database, actor: Actor, followingActors: export async function addFollowing(db: Database, actor: Actor, target: Actor, targetAcct: string): Promise { const id = crypto.randomUUID() - const query = ` - INSERT OR IGNORE INTO actor_following (id, actor_id, target_actor_id, state, target_actor_acct) + const query = db.qb.insertOrIgnore(` + INTO actor_following (id, actor_id, target_actor_id, state, target_actor_acct) VALUES (?, ?, ?, ?, ?) - ` + `) const out = await db .prepare(query) diff --git a/backend/src/mastodon/idempotency.ts b/backend/src/mastodon/idempotency.ts index f5c1bee..c9f5b98 100644 --- a/backend/src/mastodon/idempotency.ts +++ b/backend/src/mastodon/idempotency.ts @@ -36,7 +36,15 @@ export async function hasKey(db: Database, key: string): Promise = {}): return `<${name}${htmlAttrs}>${content}` } -const linkRegex = /(^|\s|\b)(https?:\/\/[-\w@:%._+~#=]{2,256}\.[a-z]{2,6}\b(?:[-\w@:%_+.~#?&/=]*))(\b|\s|$)/g +const linkRegex = /(^|\s|\b)(https?:\/\/[-\w@:%._+~#=]{1,256}\.[a-z]{2,6}\b(?:[-\w@:%_+.~#?&/=]*))(\b|\s|$)/g const mentionedEmailRegex = /(^|\s|\b|\W)@(\w+(?:[.-]?\w+)+@\w+(?:[.-]?\w+)+(?:\.\w{2,63})+)(\b|\s|$)/g +const tagRegex = /(^|\s|\b|\W)#(\w{2,63})(\b|\s|$)/g -/// Transform a text status into a HTML status; enriching it with links / mentions. +// Transform a text status into a HTML status; enriching it with links / mentions. export function enrichStatus(status: string, mentions: Array): string { - const enrichedStatus = status + const anchorsPlaceholdersMap = new Map() + + const getLinkAnchorPlaceholder = (link: string) => { + const anchor = getLinkAnchor(link) + const placeholder = `%%%___-LINK-PLACEHOLDER-${crypto.randomUUID()}-__%%%` + anchorsPlaceholdersMap.set(placeholder, anchor) + return placeholder + } + + let enrichedStatus = status .replace( linkRegex, (_, matchPrefix: string, link: string, matchSuffix: string) => - `${matchPrefix}${getLinkAnchor(link)}${matchSuffix}` + `${matchPrefix}${getLinkAnchorPlaceholder(link)}${matchSuffix}` ) .replace(mentionedEmailRegex, (_, matchPrefix: string, email: string, matchSuffix: string) => { // ensure that the match is part of the mentions array @@ -33,6 +43,15 @@ export function enrichStatus(status: string, mentions: Array): string { // otherwise the match isn't valid and we don't add HTML return `${matchPrefix}${email}${matchSuffix}` }) + .replace( + tagRegex, + (_, matchPrefix: string, tag: string, matchSuffix: string) => + `${matchPrefix}${/^\d+$/.test(tag) ? `#${tag}` : getTagAnchor(tag)}${matchSuffix}` + ) + + for (const [placeholder, anchor] of anchorsPlaceholdersMap.entries()) { + enrichedStatus = enrichedStatus.replace(placeholder, anchor) + } return tag('p', enrichedStatus) } @@ -60,3 +79,12 @@ function getLinkAnchor(link: string) { return link } } + +function getTagAnchor(hashTag: string) { + try { + return tag('a', `#${hashTag}`, { href: `/tags/${hashTag.replace(/^#/, '')}`, class: 'status-link hashtag' }) + } catch (err: unknown) { + console.warn('failed to parse link', err) + return tag + } +} diff --git a/backend/src/mastodon/notification.ts b/backend/src/mastodon/notification.ts index 8d9690a..2272305 100644 --- a/backend/src/mastodon/notification.ts +++ b/backend/src/mastodon/notification.ts @@ -225,7 +225,15 @@ export async function getNotifications(db: Database, actor: Actor, domain: strin for (let i = 0, len = results.length; i < len; i++) { const result = results[i] - const properties = JSON.parse(result.properties) + let properties + if (typeof result.properties === 'object') { + // neon uses JSONB for properties which is returned as a deserialized + // object. + properties = result.properties + } else { + // D1 uses a string for JSON properties + properties = JSON.parse(result.properties) + } const notifFromActorId = new URL(result.notif_from_actor_id) const notifFromActor = await getActorById(db, notifFromActorId) diff --git a/backend/src/mastodon/status.ts b/backend/src/mastodon/status.ts index ccbc532..c9ca683 100644 --- a/backend/src/mastodon/status.ts +++ b/backend/src/mastodon/status.ts @@ -105,7 +105,15 @@ export async function toMastodonStatusFromRow(domain: string, db: Database, row: console.warn('missing `row.publisher_actor_id`') return null } - const properties = JSON.parse(row.properties) + let properties + if (typeof row.properties === 'object') { + // neon uses JSONB for properties which is returned as a deserialized + // object. + properties = row.properties + } else { + // D1 uses a string for JSON properties + properties = JSON.parse(row.properties) + } const actorId = new URL(row.publisher_actor_id) const author = actors.personFromRow({ diff --git a/backend/src/mastodon/timeline.ts b/backend/src/mastodon/timeline.ts index 57c945b..7627897 100644 --- a/backend/src/mastodon/timeline.ts +++ b/backend/src/mastodon/timeline.ts @@ -16,7 +16,7 @@ export async function getHomeTimeline(domain: string, db: Database, actor: Actor ` SELECT actor_following.target_actor_id as id, - json_extract(actors.properties, '$.followers') as actorFollowersURL + ${db.qb.jsonExtract('actors.properties', 'followers')} as actorFollowersURL FROM actor_following INNER JOIN actors ON actors.id = actor_following.target_actor_id WHERE actor_id=? AND state='accepted' @@ -60,10 +60,10 @@ 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(?2)) - AND json_extract(objects.properties, '$.inReplyTo') IS NULL - AND (outbox_objects.target = '${PUBLIC_GROUP}' OR outbox_objects.target IN (SELECT value FROM json_each(?3))) -GROUP BY objects.id + AND outbox_objects.actor_id IN ${db.qb.set('?2')} + AND ${db.qb.jsonExtractIsNull('objects.properties', 'inReplyTo')} + AND (outbox_objects.target = '${PUBLIC_GROUP}' OR outbox_objects.target IN ${db.qb.set('?3')}) +GROUP BY objects.id ${db.qb.psqlOnly(', actors.id, outbox_objects.actor_id, outbox_objects.published_date')} ORDER by outbox_objects.published_date DESC LIMIT ?4 ` @@ -101,7 +101,7 @@ export enum LocalPreference { function localPreferenceQuery(preference: LocalPreference): string { switch (preference) { case LocalPreference.NotSet: - return '1' + return 'true' case LocalPreference.OnlyLocal: return 'objects.local = 1' case LocalPreference.OnlyRemote: @@ -136,10 +136,12 @@ INNER JOIN actors ON actors.id=outbox_objects.actor_id LEFT JOIN note_hashtags ON objects.id=note_hashtags.object_id WHERE objects.type='Note' AND ${localPreferenceQuery(localPreference)} - AND json_extract(objects.properties, '$.inReplyTo') IS NULL + AND ${db.qb.jsonExtractIsNull('objects.properties', 'inReplyTo')} AND outbox_objects.target = '${PUBLIC_GROUP}' ${hashtagFilter} -GROUP BY objects.id +GROUP BY objects.id ${db.qb.psqlOnly( + ', actors.id, actors.cdate, actors.properties, outbox_objects.actor_id, outbox_objects.published_date' + )} ORDER by outbox_objects.published_date DESC LIMIT ?1 OFFSET ?2 ` diff --git a/backend/src/middleware/main.ts b/backend/src/middleware/main.ts index dd10e80..f226e8b 100644 --- a/backend/src/middleware/main.ts +++ b/backend/src/middleware/main.ts @@ -97,7 +97,7 @@ export async function main(context: EventContext) { // configuration, which are used to verify the JWT. // TODO: since we don't load the instance configuration anymore, we // don't need to load the user before anymore. - if (!(await loadContextData(getDatabase(context.env), clientId, payload.email, context))) { + if (!(await loadContextData(await getDatabase(context.env), clientId, payload.email, context))) { return errors.notAuthorized('failed to load context data') } diff --git a/backend/src/types/env.ts b/backend/src/types/env.ts index d849c5e..1862a70 100644 --- a/backend/src/types/env.ts +++ b/backend/src/types/env.ts @@ -25,4 +25,6 @@ export interface Env { SENTRY_DSN: string SENTRY_ACCESS_CLIENT_ID: string SENTRY_ACCESS_CLIENT_SECRET: string + + NEON_DATABASE_URL?: string } diff --git a/backend/src/utils/auth/getAdmins.ts b/backend/src/utils/auth/getAdmins.ts new file mode 100644 index 0000000..0232cac --- /dev/null +++ b/backend/src/utils/auth/getAdmins.ts @@ -0,0 +1,15 @@ +import { type Database } from 'wildebeest/backend/src/database' +import { Person, personFromRow } from 'wildebeest/backend/src/activitypub/actors' + +export async function getAdmins(db: Database): Promise { + let rows: unknown[] = [] + try { + const stmt = db.prepare('SELECT * FROM actors WHERE is_admin=1') + const result = await stmt.all() + rows = result.success ? (result.results as unknown[]) : [] + } catch { + /* empty */ + } + + return rows.map(personFromRow) +} diff --git a/backend/src/utils/auth/getJwtEmail.ts b/backend/src/utils/auth/getJwtEmail.ts new file mode 100644 index 0000000..cdf2feb --- /dev/null +++ b/backend/src/utils/auth/getJwtEmail.ts @@ -0,0 +1,23 @@ +import * as access from 'wildebeest/backend/src/access' + +export function getJwtEmail(jwtCookie: string) { + let payload: access.JWTPayload + if (!jwtCookie) { + throw new Error('Missing Authorization') + } + try { + // TODO: eventually, verify the JWT with Access, however this + // is not critical. + payload = access.getPayload(jwtCookie) + } catch (e: unknown) { + const error = e as { stack: string; cause: string } + console.warn(error.stack, error.cause) + throw new Error('Failed to validate Access JWT') + } + + if (!payload.email) { + throw new Error("The Access JWT doesn't contain an email") + } + + return payload.email +} diff --git a/backend/src/utils/auth/isUserAuthenticated.ts b/backend/src/utils/auth/isUserAuthenticated.ts new file mode 100644 index 0000000..190580d --- /dev/null +++ b/backend/src/utils/auth/isUserAuthenticated.ts @@ -0,0 +1,22 @@ +import * as access from 'wildebeest/backend/src/access' + +export async function isUserAuthenticated(request: Request, jwt: string, accessAuthDomain: string, accessAud: string) { + if (!jwt) return false + + try { + const validate = access.generateValidator({ + jwt, + domain: accessAuthDomain, + aud: accessAud, + }) + await validate(new Request(request.url)) + } catch { + return false + } + + const identity = await access.getIdentity({ jwt, domain: accessAuthDomain }) + if (identity) { + return true + } + return false +} diff --git a/backend/src/utils/httpsigjs/parser.ts b/backend/src/utils/httpsigjs/parser.ts index fa39d2b..cc0a8cd 100644 --- a/backend/src/utils/httpsigjs/parser.ts +++ b/backend/src/utils/httpsigjs/parser.ts @@ -281,11 +281,12 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu if (h === 'request-line') { if (!options.strict) { + const cf = (request as { cf?: IncomingRequestCfProperties }).cf /* * 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 + parsed.signingString += request.method + ' ' + request.url + ' ' + 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.') diff --git a/backend/src/utils/sentry.ts b/backend/src/utils/sentry.ts index ff69e4b..019eabd 100644 --- a/backend/src/utils/sentry.ts +++ b/backend/src/utils/sentry.ts @@ -19,7 +19,8 @@ export function initSentry(request: Request, env: Env, context: any) { request, transportOptions: { headers }, }) - const colo = request.cf && request.cf.colo ? request.cf.colo : 'UNKNOWN' + const cf = (request as { cf?: IncomingRequestCfProperties }).cf + const colo = cf?.colo ? cf.colo : 'UNKNOWN' sentry.setTag('colo', colo) // cf-connecting-ip should always be present, but if not we can fallback to XFF. diff --git a/backend/src/webfinger/index.ts b/backend/src/webfinger/index.ts index e0f8502..5fd011b 100644 --- a/backend/src/webfinger/index.ts +++ b/backend/src/webfinger/index.ts @@ -1,4 +1,5 @@ import * as actors from '../activitypub/actors' +import { type Database } from 'wildebeest/backend/src/database' import type { Actor } from '../activitypub/actors' export type WebFingerResponse = { @@ -11,7 +12,7 @@ const headers = { accept: 'application/jrd+json', } -export async function queryAcct(domain: string, db: D1Database, acct: string): Promise { +export async function queryAcct(domain: string, db: Database, acct: string): Promise { const url = await queryAcctLink(domain, acct) if (url === null) { return null diff --git a/backend/src/webpush/util.ts b/backend/src/webpush/util.ts index 25378af..cc38599 100644 --- a/backend/src/webpush/util.ts +++ b/backend/src/webpush/util.ts @@ -26,12 +26,12 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string { } export function b64ToUrlEncoded(str: string): string { - return str.replaceAll(/\+/g, '-').replaceAll(/\//g, '_').replace(/=+/g, '') + return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+/g, '') } export function urlEncodedToB64(str: string): string { const padding = '='.repeat((4 - (str.length % 4)) % 4) - return str.replaceAll(/-/g, '+').replaceAll(/_/g, '/') + padding + return str.replace(/-/g, '+').replace(/_/g, '/') + padding } export function stringToU8Array(str: string): Uint8Array { diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index 3773a4b..07c46d9 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -187,6 +187,9 @@ describe('Mastodon APIs', () => { 'http://www.cloudflare.co.uk?test=test@123', 'http://www.cloudflare.com/.com/?test=test@~123&a=b', 'https://developers.cloudflare.com/workers/runtime-apis/request/#background', + 'https://a.test', + 'https://a.test/test', + 'https://a.test/test?test=test', ] linksToTest.forEach((link) => { const url = new URL(link) @@ -198,6 +201,51 @@ describe('Mastodon APIs', () => { assert.equal(enrichStatus(`@!@£${link}!!!`, []), `

@!@£${urlDisplayText}!!!

`) }) }) + + test('convert tags to HTML', async () => { + const tagsToTest = [ + { + tag: '#test', + expectedTagAnchor: '#test', + }, + { + tag: '#123_joke_123', + expectedTagAnchor: '#123_joke_123', + }, + { + tag: '#_123', + expectedTagAnchor: '#_123', + }, + { + tag: '#example:', + expectedTagAnchor: '#example:', + }, + { + tag: '#tagA#tagB', + expectedTagAnchor: + '#tagA#tagB', + }, + ] + + for (let i = 0, len = tagsToTest.length; i < len; i++) { + const { tag, expectedTagAnchor } = tagsToTest[i] + + assert.equal(enrichStatus(`hey ${tag} hi`, []), `

hey ${expectedTagAnchor} hi

`) + assert.equal(enrichStatus(`${tag} hi`, []), `

${expectedTagAnchor} hi

`) + assert.equal(enrichStatus(`${tag}\n\thein`, []), `

${expectedTagAnchor}\n\thein

`) + assert.equal(enrichStatus(`hey ${tag}`, []), `

hey ${expectedTagAnchor}

`) + assert.equal(enrichStatus(`${tag}`, []), `

${expectedTagAnchor}

`) + assert.equal(enrichStatus(`@!@£${tag}!!!`, []), `

@!@£${expectedTagAnchor}!!!

`) + } + }) + + test('ignore invalid tags', () => { + assert.equal(enrichStatus('tags cannot be empty like: #', []), `

tags cannot be empty like: #

`) + assert.equal( + enrichStatus('tags cannot contain only numbers like: #123', []), + `

tags cannot contain only numbers like: #123

` + ) + }) }) describe('Follow', () => { diff --git a/backend/test/mastodon/apps.spec.ts b/backend/test/mastodon/apps.spec.ts index b80edc7..17896f5 100644 --- a/backend/test/mastodon/apps.spec.ts +++ b/backend/test/mastodon/apps.spec.ts @@ -36,6 +36,33 @@ describe('Mastodon APIs', () => { assert.deepEqual(rest, {}) }) + test('POST /apps registers client without website', async () => { + const db = await makeDB() + const vapidKeys = await generateVAPIDKeys() + const request = new Request('https://example.com', { + method: 'POST', + body: '{"redirect_uris":"mastodon://example.com/oauth","client_name":"Example mastodon client","scopes":"read write follow push"}', + headers: { + 'content-type': 'application/json', + }, + }) + + const res = await apps.handleRequest(db, request, vapidKeys) + assert.equal(res.status, 200) + assertCORS(res) + assertJSON(res) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { name, redirect_uri, client_id, client_secret, vapid_key, id, ...rest } = await res.json< + Record + >() + + assert.equal(name, 'Example mastodon client') + assert.equal(redirect_uri, 'mastodon://example.com/oauth') + assert.equal(id, '20') + assert.deepEqual(rest, {}) + }) + test('POST /apps returns 422 for malformed requests', async () => { // client_name and redirect_uris are required according to https://docs.joinmastodon.org/methods/apps/#form-data-parameters const db = await makeDB() @@ -76,12 +103,14 @@ describe('Mastodon APIs', () => { }) test('GET /apps is bad request', async () => { + const db = await makeDB() const vapidKeys = await generateVAPIDKeys() const request = new Request('https://example.com') const ctx: any = { next: () => new Response(), data: null, env: { + DATABASE: db, VAPID_JWK: JSON.stringify(vapidKeys), }, request, diff --git a/backend/test/mastodon/oauth.spec.ts b/backend/test/mastodon/oauth.spec.ts index eb55137..765e47f 100644 --- a/backend/test/mastodon/oauth.spec.ts +++ b/backend/test/mastodon/oauth.spec.ts @@ -85,7 +85,7 @@ describe('Mastodon APIs', () => { headers, }) const res = await oauth_authorize.handleRequestPost(req, db, userKEK, accessDomain, accessAud) - assert.equal(res.status, 403) + assert.equal(res.status, 422) }) test('authorize redirects with code on success and show first login', async () => { diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts index d81c05f..3e834dd 100644 --- a/backend/test/mastodon/statuses.spec.ts +++ b/backend/test/mastodon/statuses.spec.ts @@ -81,7 +81,7 @@ describe('Mastodon APIs', () => { .prepare( ` SELECT - json_extract(properties, '$.content') as content, + ${db.qb.jsonExtract('properties', 'content')} as content, original_actor_id, original_object_id FROM objects @@ -758,7 +758,7 @@ describe('Mastodon APIs', () => { const row = await db .prepare( ` - SELECT json_extract(properties, '$.inReplyTo') as inReplyTo + SELECT ${db.qb.jsonExtract('properties', 'inReplyTo')} as inReplyTo FROM objects WHERE mastodon_id=? ` diff --git a/backend/test/utils.ts b/backend/test/utils.ts index f0b1471..418607e 100644 --- a/backend/test/utils.ts +++ b/backend/test/utils.ts @@ -9,6 +9,7 @@ import * as path from 'path' import { BetaDatabase } from '@miniflare/d1' import * as SQLiteDatabase from 'better-sqlite3' import { type Database } from 'wildebeest/backend/src/database' +import d1 from 'wildebeest/backend/src/database/d1' export function isUrlValid(s: string) { let url @@ -32,7 +33,8 @@ export async function makeDB(): Promise { db.exec(content) } - return db2 as unknown as Database + const env = { DATABASE: db2 } as any + return d1(env) } export function assertCORS(response: Response) { @@ -71,7 +73,7 @@ export async function createTestClient( redirectUri: string = 'https://localhost', scopes: string = 'read follow' ): Promise { - return createClient(db, 'test client', redirectUri, 'https://cloudflare.com', scopes) + return createClient(db, 'test client', redirectUri, scopes, 'https://cloudflare.com') } type TestQueue = Queue & { messages: Array } diff --git a/backend/test/wildebeest/settings.spec.ts b/backend/test/wildebeest/settings.spec.ts index fb14004..b7608ae 100644 --- a/backend/test/wildebeest/settings.spec.ts +++ b/backend/test/wildebeest/settings.spec.ts @@ -1,7 +1,7 @@ import { makeDB } from '../utils' import { strict as assert } from 'node:assert/strict' import { createPerson, getActorById } from 'wildebeest/backend/src/activitypub/actors' -import * as account_alias from 'wildebeest/functions/api/wb/settings/account/alias' +import * as alias from 'wildebeest/backend/src/accounts/alias' const domain = 'cloudflare.com' const userKEK = 'test_kek22' @@ -39,14 +39,7 @@ describe('Wildebeest', () => { throw new Error('unexpected request to ' + input) } - const alias = 'test@example.com' - - const req = new Request('https://example.com', { - method: 'POST', - body: JSON.stringify({ alias }), - }) - const res = await account_alias.handleRequestPost(db, req, actor) - assert.equal(res.status, 201) + await alias.addAlias(db, 'test@example.com', actor) // Ensure the actor has the alias set const newActor = await getActorById(db, actor.id) diff --git a/consumer/src/deliver.ts b/consumer/src/deliver.ts index c033825..ca0ed22 100644 --- a/consumer/src/deliver.ts +++ b/consumer/src/deliver.ts @@ -8,12 +8,12 @@ import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver' export async function handleDeliverMessage(env: Env, actor: Actor, message: DeliverMessageBody) { const toActorId = new URL(message.toActorId) - const targetActor = await actors.getAndCache(toActorId, getDatabase(env)) + const targetActor = await actors.getAndCache(toActorId, await getDatabase(env)) if (targetActor === null) { console.warn(`actor ${toActorId} not found`) return } - const signingKey = await getSigningKey(message.userKEK, getDatabase(env), actor) + const signingKey = await getSigningKey(message.userKEK, await getDatabase(env), actor) await deliverToActor(signingKey, actor, targetActor, message.activity, env.DOMAIN) } diff --git a/consumer/src/inbox.ts b/consumer/src/inbox.ts index 35687c4..5ef3227 100644 --- a/consumer/src/inbox.ts +++ b/consumer/src/inbox.ts @@ -9,7 +9,7 @@ import type { Env } from './' export async function handleInboxMessage(env: Env, actor: Actor, message: InboxMessageBody) { const domain = env.DOMAIN - const db = getDatabase(env) + const db = await getDatabase(env) const adminEmail = env.ADMIN_EMAIL const cache = cacheFromEnv(env) const activity = message.activity diff --git a/consumer/src/index.ts b/consumer/src/index.ts index bf070b1..ada414d 100644 --- a/consumer/src/index.ts +++ b/consumer/src/index.ts @@ -15,12 +15,14 @@ export type Env = { SENTRY_DSN: string SENTRY_ACCESS_CLIENT_ID: string SENTRY_ACCESS_CLIENT_SECRET: string + + NEON_DATABASE_URL?: string } export default { async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) { const sentry = initSentryQueue(env, ctx) - const db = getDatabase(env) + const db = await getDatabase(env) try { for (const message of batch.messages) { diff --git a/consumer/wrangler.toml b/consumer/wrangler.toml index 7a861f3..8d2045f 100644 --- a/consumer/wrangler.toml +++ b/consumer/wrangler.toml @@ -1,3 +1,4 @@ compatibility_date = "2023-01-09" main = "./src/index.ts" usage_model = "unbound" +node_compat = true diff --git a/frontend/adaptors/cloudflare-pages/vite.config.ts b/frontend/adaptors/cloudflare-pages/vite.config.ts index 34baa4b..af2dd5a 100644 --- a/frontend/adaptors/cloudflare-pages/vite.config.ts +++ b/frontend/adaptors/cloudflare-pages/vite.config.ts @@ -1,4 +1,4 @@ -import { cloudflarePagesAdaptor } from '@builder.io/qwik-city/adaptors/cloudflare-pages/vite' +import { cloudflarePagesAdapter } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite' import { extendConfig } from '@builder.io/qwik-city/vite' import baseConfig from '../../vite.config' @@ -11,7 +11,7 @@ export default extendConfig(baseConfig, () => { }, }, plugins: [ - cloudflarePagesAdaptor({ + cloudflarePagesAdapter({ // Do not SSG as the D1 database is not available at build time, I think. // staticGenerate: true, }), diff --git a/frontend/mock-db/init.ts b/frontend/mock-db/init.ts index dc97a27..87dd4f7 100644 --- a/frontend/mock-db/init.ts +++ b/frontend/mock-db/init.ts @@ -7,6 +7,8 @@ import { createReply as createReplyInBackend } from 'wildebeest/backend/test/sha import { createStatus } from 'wildebeest/backend/src/mastodon/status' import type { APObject } from 'wildebeest/backend/src/activitypub/objects' import { type Database } from 'wildebeest/backend/src/database' +import { upsertRule } from 'wildebeest/backend/src/config/rules' +import { upsertServerSettings } from 'wildebeest/backend/src/config/server' /** * Run helper commands to initialize the database with actors, statuses, etc. @@ -41,6 +43,17 @@ export async function init(domain: string, db: Database) { for (const reply of replies) { await createReply(domain, db, reply, loadedStatuses) } + + await createServerData(db) +} + +async function createServerData(db: Database) { + await upsertServerSettings(db, { + 'extended description': 'this is a test wildebeest instance!', + }) + await upsertRule(db, "don't be mean") + await upsertRule(db, "don't insult people") + await upsertRule(db, 'respect the rules') } /** @@ -74,12 +87,21 @@ async function getOrCreatePerson( db: Database, { username, avatar, display_name }: Account ): Promise { - const person = await getPersonByEmail(db, username) + const isAdmin = username === 'george' + const email = `${username}@test.email` + const person = await getPersonByEmail(db, email) if (person) return person - const newPerson = await createPerson(domain, db, 'test-kek', username, { - icon: { url: avatar }, - name: display_name, - }) + const newPerson = await createPerson( + domain, + db, + 'test-kek', + email, + { + icon: { url: avatar }, + name: display_name, + }, + isAdmin + ) if (!newPerson) { throw new Error('Could not create Actor ' + username) } diff --git a/frontend/package.json b/frontend/package.json index dd75947..cfc6f6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,14 +7,16 @@ }, "private": true, "scripts": { + "pretypes-check": "yarn build", + "types-check": "tsc", "lint": "eslint src mock-db adaptors", "build": "vite build && vite build -c adaptors/cloudflare-pages/vite.config.ts", "dev": "vite --mode ssr", - "watch": "concurrently \"vite build -w\" \"vite build -w -c adaptors/cloudflare-pages/vite.config.ts\"" + "watch": "nodemon -w ./src --ext tsx,ts --exec npm run build" }, "devDependencies": { - "@builder.io/qwik": "0.18.1", - "@builder.io/qwik-city": "0.2.1", + "@builder.io/qwik": "0.21.0", + "@builder.io/qwik-city": "0.4.0", "@types/eslint": "8.4.10", "@types/jest": "^29.2.4", "@types/node": "^18.11.16", @@ -22,12 +24,12 @@ "@typescript-eslint/eslint-plugin": "5.46.1", "@typescript-eslint/parser": "5.46.1", "autoprefixer": "10.4.11", - "concurrently": "^7.6.0", "eslint": "8.30.0", "eslint-plugin-qwik": "0.16.1", "jest": "^29.3.1", "lorem-ipsum": "^2.0.8", "node-fetch": "3.3.0", + "nodemon": "^2.0.20", "postcss": "^8.4.16", "prettier": "2.8.1", "sass": "^1.57.0", diff --git a/frontend/src/components/ResultMessage/index.tsx b/frontend/src/components/ResultMessage/index.tsx new file mode 100644 index 0000000..0e8f620 --- /dev/null +++ b/frontend/src/components/ResultMessage/index.tsx @@ -0,0 +1,22 @@ +import { component$ } from '@builder.io/qwik' + +type Props = { + type: 'success' | 'failure' + message: string +} + +export default component$(({ type, message }: Props) => { + const colorClasses = getColorClasses(type) + return

{message}

+}) + +export function getColorClasses(type: Props['type']): string { + switch (type) { + case 'success': + return 'bg-green-800 border-green-700 text-green-100' + case 'failure': + return 'bg-red-800 border-red-700 text-red-100 text-green-100' + default: + return 'bg-green-800 border-green-700 text-green-100' + } +} diff --git a/frontend/src/components/Settings/SubmitButton.tsx b/frontend/src/components/Settings/SubmitButton.tsx new file mode 100644 index 0000000..84452ce --- /dev/null +++ b/frontend/src/components/Settings/SubmitButton.tsx @@ -0,0 +1,23 @@ +import { component$ } from '@builder.io/qwik' +import Spinner from '../Spinner' + +type Props = { + loading: boolean + text: string +} + +export const SubmitButton = component$(({ text, loading }) => { + return ( + + ) +}) diff --git a/frontend/src/components/Settings/TextArea.tsx b/frontend/src/components/Settings/TextArea.tsx new file mode 100644 index 0000000..f83f302 --- /dev/null +++ b/frontend/src/components/Settings/TextArea.tsx @@ -0,0 +1,34 @@ +import { component$, useSignal } from '@builder.io/qwik' + +type Props = { + label: string + name?: string + description?: string + class?: string + invalid?: boolean + value?: string + required?: boolean +} + +export const TextArea = component$( + ({ class: className, label, name, description, invalid, value, required }) => { + const inputId = useSignal(`${label.replace(/\s+/g, '_')}___${crypto.randomUUID()}`).value + return ( +
+ + {!!description &&
{description}
} +