diff --git a/.github/workflows/PRs.yml b/.github/workflows/PRs.yml index 807b7b461..2ebf30564 100644 --- a/.github/workflows/PRs.yml +++ b/.github/workflows/PRs.yml @@ -53,9 +53,6 @@ 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/backend/src/activitypub/activities/handle.ts b/backend/src/activitypub/activities/handle.ts index c2841fdfd..07e5cc07e 100644 --- a/backend/src/activitypub/activities/handle.ts +++ b/backend/src/activitypub/activities/handle.ts @@ -15,7 +15,8 @@ import { sendReblogNotification, } from 'wildebeest/backend/src/mastodon/notification' import { type APObject, updateObject } from 'wildebeest/backend/src/activitypub/objects' -import { parseHandle } from 'wildebeest/backend/src/utils/parse' +import { parseHandle, Handle } from 'wildebeest/backend/src/utils/parse' +import { urlToHandle } from 'wildebeest/backend/src/utils/handle' import type { Note } from 'wildebeest/backend/src/activitypub/objects/note' import { addFollowing, acceptFollowing, moveFollowers, moveFollowing } from 'wildebeest/backend/src/mastodon/follow' import { deliverToActor } from 'wildebeest/backend/src/activitypub/deliver' @@ -33,54 +34,45 @@ function extractID(domain: string, s: string | URL): string { return s.toString().replace(`https://${domain}/ap/users/`, '') } -export function makeGetObjectAsId(activity: Activity) { +export function makeGetObjectAsId(activity: Activity): () => URL | null { return () => { - 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 as URL - } - 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 + if (activity?.object?.id !== undefined) { + return new URL(activity?.object?.id) + } else if (typeof activity.object === 'string') { + return new URL(activity.object) + } else if (activity.object instanceof URL) { + // This is used for testing only. + return activity.object as URL + } else { + console.error(`makeGetObjectAsId | Unable to process Activity:\n${JSON.stringify(activity, null, 2)}`) + return null + } + } catch { + console.error(`Unable to extract APObject URL from Activity:\n${JSON.stringify(activity, null, 2)}`) + return null } } } -export function makeGetActorAsId(activity: Activity) { +export function makeGetActorAsId(activity: Activity): () => URL | null { return () => { - 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 as URL - } - 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 + if (activity?.actor?.id !== undefined) { + return new URL(activity.actor.id) + } else if (typeof activity.actor === 'string') { + return new URL(activity.actor) + } else if (activity.actor instanceof URL) { + // This is used for testing only. + // console.warn(`TESTING PURPOSES ONLY`) + return activity.actor as URL + } else { + console.error(`makeGetActorAsId | Unable to process Activity:\n${JSON.stringify(activity, null, 2)}`) + return null + } + } catch { + console.error(`Unable to extract APObject URL from Activity:\n${JSON.stringify(activity, null, 2)}`) + return null } } } @@ -101,41 +93,18 @@ export async function handle( } } - const getObjectAsId = makeGetObjectAsId(activity) - const getActorAsId = makeGetActorAsId(activity) - switch (activity.type) { - case 'Update': { - requireComplexObject() - const actorId = getActorAsId() - const objectId = getObjectAsId() - - if (!['Note', 'Person', 'Service'].includes(activity.object.type)) { - console.warn('unsupported Update for Object type: ' + activity.object.type) - return - } - - // check current object - const object = await objects.getObjectBy(db, objects.ObjectByKey.originalObjectId, objectId.toString()) - if (object === null) { - throw new Error(`object ${objectId} does not exist`) - } - - if (actorId.toString() !== object[originalActorIdSymbol]) { - 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() + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + const objectId: URL | null = makeGetObjectAsId(activity)() + if (objectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } // FIXME: download any attachment Objects @@ -143,6 +112,9 @@ export async function handle( let target = PUBLIC_GROUP if (Array.isArray(activity.to) && activity.to.length > 0) { + // TODO: Double-check that this is working as intended + // because this syntax will silently fail if `recipients` or `activity.to` are multi-dimensional arrays + // ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#sect1 recipients = [...recipients, ...activity.to] if (activity.to.length !== 1) { @@ -151,10 +123,12 @@ export async function handle( target = activity.to[0] } if (Array.isArray(activity.cc) && activity.cc.length > 0) { + // TODO: Double-check that this is working as intended + // because this syntax will silently fail if `recipients` or `activity.cc` are multi-dimensional arrays + // ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#sect1 recipients = [...recipients, ...activity.cc] } - const objectId = getObjectAsId() const res = await cacheObject(domain, activity.object, db, actorId, objectId) if (res === null) { break @@ -172,7 +146,7 @@ export async function handle( // 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) + const inReplyToObjectId: URL = new URL(obj.inReplyTo) let inReplyToObject = await objects.getObjectByOriginalId(db, inReplyToObjectId) if (inReplyToObject === null) { @@ -184,7 +158,7 @@ export async function handle( await insertReply(db, actor, obj, inReplyToObject) } - const fromActor = await actors.getAndCache(getActorAsId(), db) + const fromActor = await actors.getAndCache(actorId, 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, target) @@ -222,7 +196,10 @@ export async function handle( // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-accept case 'Accept': { requireComplexObject() - const actorId = getActorAsId() + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } const actor = await actors.getActorById(db, activity.object.actor) if (actor !== null) { @@ -235,90 +212,206 @@ export async function handle( break } - // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow - case 'Follow': { - const objectId = getObjectAsId() - const actorId = getActorAsId() - - const receiver = await actors.getActorById(db, objectId) - if (receiver !== null) { - const originalActor = await actors.getAndCache(new URL(actorId), db) - const receiverAcct = `${receiver.preferredUsername}@${domain}` - - await addFollowing(db, originalActor, receiver, receiverAcct) - - // Automatically send the Accept reply - await acceptFollowing(db, originalActor, receiver) - const reply = accept.create(receiver, activity) - const signingKey = await getSigningKey(userKEK, db, receiver) - await deliverToActor(signingKey, receiver, originalActor, reply, domain) - - // Notify the user - const notifId = await insertFollowNotification(db, receiver, originalActor) - await sendFollowNotification(db, originalActor, receiver, notifId, adminEmail, vapidKeys) - } else { - console.warn(`actor ${objectId} not found`) + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce + case 'Announce': { + const announcingActorId: URL | null = makeGetActorAsId(activity)() + if (announcingActorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + const announcedAPObjectId: URL | null = makeGetObjectAsId(activity)() + if (announcedAPObjectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) } - break - } + const announcingActor = await actors.getAndCache(announcingActorId, db) + if (announcingActor === null) { + const message: string = `Actor for 'Announce' does not exist or is inaccessible: '${announcingActorId}'` + console.error(message) + throw new Error(message) + } + const announcingActorHandle: Handle = parseHandle(urlToHandle(announcingActorId)) + if (announcingActorHandle.domain !== domain) { + console.warn( + `Actor for 'Announce' activity is not hosted locally: '${announcingActorHandle.localPart}@${announcingActorHandle.domain}'` + ) + break + } - // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-announce - case 'Announce': { - const actorId = getActorAsId() - const objectId = getObjectAsId() + const announcedAPObject = activity.object + if (announcedAPObject === null || announcedAPObject?.content === undefined) { + // prettier-ignore + const message: string = `'Announce' from '${announcingActorHandle.localPart}@${announcingActorHandle.domain}' contains an invalid APObject: '${JSON.stringify(activity.object, null, 2)}'` + console.error(message) + throw new Error(message) + } - let obj: any = null + let actorIdToNotify: URL + let actorToNotify: any = null + let objectToAnnounce: any = null - const localObject = await objects.getObjectById(db, objectId) + const localObject = await objects.getObjectById(db, announcedAPObjectId) if (localObject === null) { + console.debug(`Announced APObject is not cached locally, fetching '${announcedAPObjectId}' now ...`) try { - // Object doesn't exists locally, we'll need to download it. - const remoteObject = await objects.get(objectId) + // If original Actor doesn't exist locally, try to fetch it + const originalActorId = new URL(announcedAPObject.attributedTo!) + const originalActor = await actors.getAndCache(announcedAPObject.attributedTo, db) + if (originalActor === null) { + const message: string = `APObject in 'Announce' is attributed to an Actor that does not exist or is inaccessible: '${originalActorId}'` + console.info(message) + break + } - const res = await cacheObject(domain, remoteObject, db, actorId, objectId) + // Object doesn't exist locally, try to fetch it + const remoteObject = await objects.get(announcedAPObjectId) + + const originalObjectId = remoteObject?.id as URL + const res = await cacheObject(domain, remoteObject, db, originalActorId, originalObjectId) if (res === null) { break } - obj = res.object + actorIdToNotify = originalActorId + actorToNotify = originalActor + objectToAnnounce = res.object } catch (err: any) { - console.warn(`failed to retrieve object ${objectId}: ${err.message}`) + console.warn(`failed to retrieve announced object (id: ${announcedAPObjectId}): ${err.message}`) break } } else { // Object already exists locally, we can just use it. - obj = localObject + actorIdToNotify = new URL(localObject[originalActorIdSymbol]!) + actorToNotify = await actors.getAndCache(actorIdToNotify, db) + if (actorToNotify === null) { + const message: string = `APObject in 'Announce' is attributed to an Actor that does not exist or is inaccessible: '${actorIdToNotify}'` + console.info(message) + break + } + objectToAnnounce = localObject } - const fromActor = await actors.getAndCache(actorId, db) + if (await hasReblog(db, announcingActorId, objectToAnnounce?.id)) { + // A reblog already exists. Ignore to avoid duplicated + // prettier-ignore + console.warn(`Ignoring reblog request from '${announcingActorId}' regarding ${objectToAnnounce?.type} authored by '${actorIdToNotify}' (id: ${objectToAnnounce?.id})'\nProbably duplicated Announce message`) + break + } - if (await hasReblog(db, fromActor, obj)) { - // A reblog already exists. To avoid dulicated reblog we ignore. - console.warn('probably duplicated Announce message') + try { + await createReblog(db, announcingActor, objectToAnnounce) + + // prettier-ignore + console.debug(`'${announcingActorId}' reblogged ${objectToAnnounce?.type} authored by '${actorIdToNotify}': ${objectToAnnounce?.id}'`) + } catch (e: any) { + // prettier-ignore + console.error(`Unexpected error prevented Announce of ${objectToAnnounce?.type} (id: ${objectToAnnounce?.id}) by Actor '${announcingActorId}': ${JSON.stringify(e, null, 2)}\n'`) break } - // notify the user - const targetActor = await actors.getActorById(db, new URL(obj[originalActorIdSymbol])) - if (targetActor === null) { - console.warn('object actor not found') + if (announcingActorId.toString() === actorIdToNotify.toString()) { + // prettier-ignore + console.trace(`Notification not sent because the sender (${announcingActorId}) and recipient (${actorIdToNotify}) are the same.`) break } - const notifId = await createNotification(db, 'reblog', targetActor, fromActor, obj) + try { + const notifId = await createNotification(db, 'reblog', actorToNotify, announcingActor, objectToAnnounce) + await sendReblogNotification(db, announcingActor, actorToNotify, notifId, adminEmail, vapidKeys) - await Promise.all([ - createReblog(db, fromActor, obj), - sendReblogNotification(db, fromActor, targetActor, notifId, adminEmail, vapidKeys), - ]) + // prettier-ignore + console.debug(`Notified Actor '${actorIdToNotify}' of ${objectToAnnounce?.type} '${announcedAPObjectId}' Announce (reblog) by Actor '${announcingActorId}'`) + break + } catch (e: any) { + // prettier-ignore + console.error(`Unexpected error prevented sending notification regarding Announce to attributed author: ${JSON.stringify(e, null, 2)}\n'`) + break + } + + throw new Error(`Unexpected error: this message should never be visible`) + break + } + + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete + case 'Delete': { + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + const objectId: URL | null = makeGetObjectAsId(activity)() + if (objectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } + + const obj = await objects.getObjectByOriginalId(db, objectId) + if (obj === null || !obj[originalActorIdSymbol]) { + console.warn('unknown object or missing originalActorId') + break + } + + if (actorId.toString() !== obj[originalActorIdSymbol]) { + console.warn(`authorized Delete (${actorId} vs ${obj[originalActorIdSymbol]})`) + return + } + + if (!['Note'].includes(obj.type)) { + console.warn('unsupported Update for Object type: ' + activity.object.type) + return + } + const deleteOperationResult: string = await deleteObject(db, obj) + + if (deleteOperationResult !== 'success') { + console.error(deleteOperationResult) + throw new Error(deleteOperationResult) + } + + break + } + + // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-follow + case 'Follow': { + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + const objectId: URL | null = makeGetObjectAsId(activity)() + if (objectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } + + const receiver = await actors.getActorById(db, objectId) + if (receiver !== null) { + const originalActor = await actors.getAndCache(new URL(actorId), db) + const receiverAcct = `${receiver.preferredUsername}@${domain}` + + await addFollowing(db, originalActor, receiver, receiverAcct) + + // Automatically send the Accept reply + await acceptFollowing(db, originalActor, receiver) + const reply = accept.create(receiver, activity) + const signingKey = await getSigningKey(userKEK, db, receiver) + await deliverToActor(signingKey, receiver, originalActor, reply, domain) + + // Notify the user + const notifId = await insertFollowNotification(db, receiver, originalActor) + await sendFollowNotification(db, originalActor, receiver, notifId, adminEmail, vapidKeys) + } else { + const m: string = `'${activity.type}' request failed because actor '${objectId}' was not found` + console.error(m) + throw new Error(m) + } break } // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like case 'Like': { - const actorId = getActorAsId() - const objectId = getObjectAsId() + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + const objectId: URL | null = makeGetObjectAsId(activity)() + if (objectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } const obj = await objects.getObjectById(db, objectId) if (obj === null || !obj[originalActorIdSymbol]) { @@ -344,34 +437,12 @@ export async function handle( break } - // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-delete - case 'Delete': { - const objectId = getObjectAsId() - const actorId = getActorAsId() - - const obj = await objects.getObjectByOriginalId(db, objectId) - if (obj === null || !obj[originalActorIdSymbol]) { - console.warn('unknown object or missing originalActorId') - break - } - - if (actorId.toString() !== obj[originalActorIdSymbol]) { - console.warn(`authorized Delete (${actorId} vs ${obj[originalActorIdSymbol]})`) - return - } - - if (!['Note'].includes(obj.type)) { - console.warn('unsupported Update for Object type: ' + activity.object.type) - return - } - - await deleteObject(db, obj) - break - } - // https://www.w3.org/TR/activitystreams-vocabulary/#dfn-move case 'Move': { - const fromActorId = getActorAsId() + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } const target = new URL(activity.target) if (target.hostname !== domain) { @@ -379,7 +450,7 @@ export async function handle( break } - const fromActor = await actors.getAndCache(fromActorId, db) + const fromActor = await actors.getAndCache(actorId, db) const localActor = await actors.getActorById(db, target) if (localActor === null) { @@ -416,6 +487,40 @@ export async function handle( break } + case 'Update': { + requireComplexObject() + const actorId: URL | null = makeGetActorAsId(activity)() + if (actorId === null) { + console.error(`Activity type '${activity.type}' requires an actor with a valid ID`) + throw new Error() + } + const objectId: URL | null = makeGetObjectAsId(activity)() + if (objectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } + + if (!['Note', 'Person', 'Service'].includes(activity.object.type)) { + console.warn('unsupported Update for Object type: ' + activity.object.type) + return + } + + // check current object + const object = await objects.getObjectBy(db, objects.ObjectByKey.originalObjectId, objectId.toString()) + if (object === null) { + throw new Error(`object ${objectId} does not exist`) + } + + if (actorId.toString() !== object[originalActorIdSymbol]) { + 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 + } + default: console.warn(`Unsupported activity: ${activity.type}`) } diff --git a/backend/src/activitypub/actors/index.ts b/backend/src/activitypub/actors/index.ts index 6a6b32434..f5618f7df 100644 --- a/backend/src/activitypub/actors/index.ts +++ b/backend/src/activitypub/actors/index.ts @@ -197,7 +197,7 @@ export async function createPerson( properties.followers = id + '/followers' } - const row = await db + await db .prepare( ` INSERT INTO actors(id, type, email, pubkey, privkey, privkey_salt, properties, is_admin) @@ -206,7 +206,9 @@ export async function createPerson( ` ) .bind(id, PERSON, email, userKeyPair.pubKey, privkey, salt, JSON.stringify(properties), admin ? 1 : null) - .first() + .run() + + const row = await db.prepare(`SELECT * FROM actors WHERE id=?`).bind(id).first() return personFromRow(row) } @@ -295,21 +297,28 @@ export function personFromRow(row: any): Person { } } - return { + // prettier-ignore + const personObject = { // Hidden values [emailSymbol]: row.email, - - ...properties, - name, + icon, image, - preferredUsername, - discoverable: true, publicKey, + name: name, + preferredUsername: preferredUsername, + discoverable: true, + type: PERSON, - id, + id: id, published: new Date(row.cdate).toISOString(), url: new URL('@' + preferredUsername, 'https://' + domain), - } as unknown as Person + ...properties + } + + // console.info(`\npersonObject.id = ${personObject.id}`) + // console.info(`personObject:\n ${JSON.stringify(personObject, null, 2)}`) + + return personObject as unknown as Person } diff --git a/backend/src/activitypub/actors/outbox.ts b/backend/src/activitypub/actors/outbox.ts index 0c50aaecd..685a66955 100644 --- a/backend/src/activitypub/actors/outbox.ts +++ b/backend/src/activitypub/actors/outbox.ts @@ -13,22 +13,27 @@ export async function addObjectInOutbox( published_date?: string, target: string = PUBLIC_GROUP ) { - const id = crypto.randomUUID() - let out: any = null + // console.log(`actor.id: ${actor.id}\npublished_date: ${published_date}\nobj: ${JSON.stringify(obj, null, 2)}\n`) + try { + const id = crypto.randomUUID() - if (published_date !== undefined) { - out = await db - .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date, target) VALUES(?, ?, ?, ?, ?)') - .bind(id, actor.id.toString(), obj.id.toString(), published_date, target) - .run() - } else { - out = await db - .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, target) VALUES(?, ?, ?, ?)') - .bind(id, actor.id.toString(), obj.id.toString(), target) - .run() - } - if (!out.success) { - throw new Error('SQL error: ' + out.error) + if (published_date !== undefined) { + await db + .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, published_date, target) VALUES(?, ?, ?, ?, ?)') + .bind(id, actor.id.toString(), obj.id.toString(), published_date, target) + .run() + } else { + await db + .prepare('INSERT INTO outbox_objects(id, actor_id, object_id, target) VALUES(?, ?, ?, ?)') + .bind(id, actor.id.toString(), obj.id.toString(), target) + .run() + } + } catch (e: any) { + const message: string = `Unable to add object to outbox due to SQL error: ${e.message}\n${ + e.cause?.message ?? e.cause + }` + console.error(message) + throw Error(message) } } diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts index 7d1d0cd70..99f38c46d 100644 --- a/backend/src/activitypub/objects/index.ts +++ b/backend/src/activitypub/objects/index.ts @@ -49,23 +49,43 @@ export async function createObject( const uuid = crypto.randomUUID() const apId = uri(domain, uuid).toString() const sanitizedProperties = await sanitizeObjectProperties(properties) + const insertQuery = ` + INSERT INTO objects(id, type, properties, original_actor_id, local, mastodon_id) + VALUES(?, ?, ?, ?, ?, ?) + RETURNING * + ;` + try { + await db + .prepare(insertQuery) + .bind(apId, type, JSON.stringify(sanitizedProperties), originalActorId.toString(), local ? 1 : 0, uuid) + .run() + } catch (e: any) { + const message: string = `Unable to create '${type}' object due to SQL error: ${e.message}\n${ + e.cause?.message ?? e.cause + }\nid, type, properties, original_actor_id, local, uuid = ${apId}, ${type}, ${JSON.stringify( + sanitizedProperties + )}, ${originalActorId.toString()}, ${local ? 1 : 0}, ${uuid}` + console.error(message) + throw Error(message) + } - 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(sanitizedProperties), originalActorId.toString(), local ? 1 : 0, uuid) - .first() - - return { - ...sanitizedProperties, - type, - id: new URL(row.id), - published: new Date(row.cdate).toISOString(), - - [mastodonIdSymbol]: row.mastodon_id, - [originalActorIdSymbol]: row.original_actor_id, - } as Type + const searchQueryResults = await db + .prepare('SELECT cdate FROM objects WHERE id=?;') + .bind(apId) + .first<{ cdate: string }>() + // prettier-ignore + const santitizedObject = { + published: new Date(searchQueryResults.cdate).toISOString(), + [mastodonIdSymbol]: uuid, + [originalActorIdSymbol]: originalActorId.toString(), + ...sanitizedProperties + } + santitizedObject.type = type + santitizedObject.id = new URL(apId) + // console.info(`\ntype = ${type}\nnew URL(apId) = ${new URL(apId).toString()}`) + // console.info(`santitizedObject = ${JSON.stringify(santitizedObject, null, 2)}`) + + return santitizedObject as Type } export async function get(url: URL): Promise { @@ -106,7 +126,7 @@ export async function cacheObject( const uuid = crypto.randomUUID() const apId = uri(domain, uuid).toString() - const row: any = await db + await db .prepare( 'INSERT INTO objects(id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES(?, ?, ?, ?, ?, ?, ?) RETURNING *' ) @@ -119,7 +139,12 @@ export async function cacheObject( local ? 1 : 0, uuid ) - .first() + .run() + + const searchQueryResults = await db + .prepare('SELECT cdate FROM objects WHERE id=?;') + .bind(apId) + .first<{ cdate: string }>() // Add peer { @@ -128,20 +153,20 @@ export async function cacheObject( } { - const properties = JSON.parse(row.properties) - const object = { - published: new Date(row.cdate).toISOString(), - ...properties, + // prettier-ignore + const retrievedFederatedObject = { + published: new Date(searchQueryResults.cdate).toISOString(), - type: row.type, - id: new URL(row.id), + [mastodonIdSymbol]: uuid, + [originalActorIdSymbol]: originalActorId.toString(), + [originalObjectIdSymbol]: originalObjectId.toString(), - [mastodonIdSymbol]: row.mastodon_id, - [originalActorIdSymbol]: row.original_actor_id, - [originalObjectIdSymbol]: row.original_object_id, + ...sanitizedProperties } as APObject + retrievedFederatedObject.id = new URL(apId) + retrievedFederatedObject.type = sanitizedProperties.type - return { object, created: true } + return { object: retrievedFederatedObject, created: true } } } @@ -167,16 +192,30 @@ export async function updateObjectProperty(db: Database, obj: APObject, key: str } } +async function _getObjectByIdType(db: Database, id: string | URL, idType: ObjectByKey): Promise { + if (typeof id === 'object') { + const apObject: APObject | null = await getObjectBy(db, idType, id.toString()) + // prettier-ignore + console.debug(`_getObjectByIdType | idType = ${idType} | id = ${id.toString()} | apObject = ${JSON.stringify((apObject ?? { result: 'NOT FOUND' }), null, 2)}`) + return apObject + } else { + const apObject: APObject | null = await getObjectBy(db, idType, id) + // prettier-ignore + console.debug(`_getObjectByIdType | idType = ${idType} | id = ${id} | apObject = ${JSON.stringify((apObject ?? { result: 'NOT FOUND' }), null, 2)}`) + return apObject + } +} + export async function getObjectById(db: Database, id: string | URL): Promise { - return getObjectBy(db, ObjectByKey.id, id.toString()) + return await _getObjectByIdType(db, id, ObjectByKey.id) } export async function getObjectByOriginalId(db: Database, id: string | URL): Promise { - return getObjectBy(db, ObjectByKey.originalObjectId, id.toString()) + return await _getObjectByIdType(db, id, ObjectByKey.originalObjectId) } export async function getObjectByMastodonId(db: Database, id: UUID): Promise { - return getObjectBy(db, ObjectByKey.mastodonId, id) + return await _getObjectByIdType(db, id, ObjectByKey.mastodonId) } export enum ObjectByKey { @@ -194,7 +233,10 @@ export async function getObjectBy(db: Database, key: ObjectByKey, value: string) const query = ` SELECT * FROM objects - WHERE objects.${key}=? + WHERE + objects.${key}=? + ORDER BY cdate DESC + LIMIT 1 ` const { results, success, error } = await db.prepare(query).bind(value).all() if (!success) { @@ -208,9 +250,9 @@ export async function getObjectBy(db: Database, key: ObjectByKey, value: string) const result: any = results[0] const properties = JSON.parse(result.properties) - return { + // prettier-ignore + const localObject = { published: new Date(result.cdate).toISOString(), - ...properties, type: result.type, id: new URL(result.id), @@ -218,7 +260,17 @@ export async function getObjectBy(db: Database, key: ObjectByKey, value: string) [mastodonIdSymbol]: result.mastodon_id, [originalActorIdSymbol]: result.original_actor_id, [originalObjectIdSymbol]: result.original_object_id, + ...properties } as APObject + + // console.trace(`\nresult.type = ${result.type}\nresult.id = ${result.id}\nlocalObject.type = ${localObject.type}\nlocalObject.id = ${localObject.id}\nproperties = ${JSON.stringify(properties, null, 2)}`) + + ;(localObject.type = result.type), (localObject.id = new URL(result.id)) + + // console.trace(`\nresult.type = ${result.type}\nresult.id = ${result.id}\nlocalObject.type = ${localObject.type}\nlocalObject.id = ${localObject.id}\nproperties = ${JSON.stringify(properties, null, 2)}`) + + // console.info(`localObject = ${JSON.stringify(localObject, null, 2)}`) + return localObject as APObject } /** Is the given `value` an ActivityPub Object? */ @@ -231,9 +283,12 @@ export async function sanitizeObjectProperties(properties: unknown): Promise(db: Database, note: T) { +export async function deleteObject(db: Database, note: T): Promise { const nodeId = note.id.toString() const batch = [ db.prepare('DELETE FROM outbox_objects WHERE object_id=?').bind(nodeId), @@ -320,7 +374,10 @@ export async function deleteObject(db: Database, note: T) { for (let i = 0, len = res.length; i < len; i++) { if (!res[i].success) { - throw new Error('SQL error: ' + res[i].error) + const message: string = `SQL error: ${res[i].error}` + console.error(message) + return message } } + return 'success' } diff --git a/backend/src/activitypub/objects/note.ts b/backend/src/activitypub/objects/note.ts index 02ebdf6eb..ee4cdb036 100644 --- a/backend/src/activitypub/objects/note.ts +++ b/backend/src/activitypub/objects/note.ts @@ -32,9 +32,11 @@ export async function createPublicNote( ): Promise { const actorId = new URL(actor.id) + // prettier-ignore const properties = { + type: NOTE, attributedTo: actorId, - content, + content: content, to: [PUBLIC_GROUP], cc: [actor.followers.toString()], @@ -46,10 +48,12 @@ export async function createPublicNote( attachment: attachments, inReplyTo: null, - ...extraProperties, + ...extraProperties } - return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note + const createdNote: Note = (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note + + return createdNote } export async function createDirectNote( @@ -63,6 +67,7 @@ export async function createDirectNote( ): Promise { const actorId = new URL(actor.id) + // prettier-ignore const properties = { attributedTo: actorId, content, @@ -77,7 +82,7 @@ export async function createDirectNote( tag: [], attachment, - ...extraProperties, + ...extraProperties } return (await objects.createObject(domain, db, NOTE, properties, actorId, true)) as Note diff --git a/backend/src/config/rules.ts b/backend/src/config/rules.ts deleted file mode 100644 index 68a1342c1..000000000 --- a/backend/src/config/rules.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 deleted file mode 100644 index 5d4710cd7..000000000 --- a/backend/src/config/server.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 -} - -export async function updateSettings(db: Database, data: ServerSettingsData) { - 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/neon.ts b/backend/src/database/neon.ts index 6e7e715b5..960c5d428 100644 --- a/backend/src/database/neon.ts +++ b/backend/src/database/neon.ts @@ -4,7 +4,7 @@ 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) => { + return query.replaceAll(/\?([0-9])?/g, (match: string, p1: string) => { c += 1 return `$${p1 || c}` }) @@ -90,6 +90,9 @@ export class PreparedStatement { } bind(...values: any[]): PreparedStatement { + // TODO: Double-check that this is working as intended + // because this syntax will silently fail if `this.values` or `values` are multi-dimensional arrays + // ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#sect1 return new PreparedStatement(this.env, this.query, [...this.values, ...values], this.client) } diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts index 07fe9af27..76c54b429 100644 --- a/backend/src/errors/index.ts +++ b/backend/src/errors/index.ts @@ -5,16 +5,19 @@ type ErrorResponse = { error_description?: string } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } as const function generateErrorResponse(error: string, status: number, errorDescription?: string): Response { + // prettier-ignore const res: ErrorResponse = { error: `${error}. If the problem persists please contact your instance administrator.`, - ...(errorDescription ? { error_description: errorDescription } : {}), + ...(errorDescription ? { error_description: errorDescription } : {}) } + return new Response(JSON.stringify(res), { headers, status }) } diff --git a/backend/src/mastodon/idempotency.ts b/backend/src/mastodon/idempotency.ts index f5c1beec1..854ed2de4 100644 --- a/backend/src/mastodon/idempotency.ts +++ b/backend/src/mastodon/idempotency.ts @@ -38,15 +38,14 @@ export async function hasKey(db: Database, key: string): Promise { - const query = ` + // ON CONFLICT DO NOTHING + const insertQuery = ` INSERT INTO actor_notifications (type, actor_id, from_actor_id, object_id) VALUES (?, ?, ?, ?) RETURNING id -` - const row = await db - .prepare(query) - .bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString()) - .first<{ id: string }>() - return row.id + ;` + try { + await db.prepare(insertQuery).bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString()).run() + } catch (e: any) { + const message: string = `Unable to create '${type}' notification due to SQL error: ${e.message}\n${ + e.cause?.message ?? e.cause + }\ntype, actor.id.toString(), fromActor.id.toString(), obj.id.toString() = ${type}, ${actor.id.toString()}, ${fromActor.id.toString()}, ${obj.id.toString()}` + console.error(message) + throw new Error(message) + } + + const selectQuery = ` + SELECT + id + FROM actor_notifications + WHERE + type=? AND + actor_id=? AND + from_actor_id=? AND + object_id=? + ORDER BY cdate DESC + LIMIT 1 + ;` + try { + const selectQueryResults = await db + .prepare(selectQuery) + .bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString()) + .first<{ id: string }>() + return selectQueryResults.id + } catch (e: any) { + const message: string = `Unable to retrieve 'id' of newly-created '${type}' notification due to SQL error: ${ + e.message + }\n${e.cause?.message ?? e.cause}` + console.error(message) + throw new Error(message) + } } export async function insertFollowNotification(db: Database, actor: Actor, fromActor: Actor): Promise { const type: NotificationType = 'follow' - - const query = ` - INSERT INTO actor_notifications (type, actor_id, from_actor_id) + const insertQuery = ` + INSERT INTO actor_notifications (type, actor_id, from_actor_id) VALUES (?, ?, ?) RETURNING id -` - const row = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first<{ id: string }>() - return row.id + ;` + try { + await db.prepare(insertQuery).bind(type, actor.id.toString(), fromActor.id.toString()).run() + } catch (e: any) { + const message: string = `Unable to create '${type}' notification due to SQL error: ${e.message}\n${ + e.cause?.message ?? e.cause + }` + console.error(message) + throw new Error(message) + } + + const selectQuery = ` + SELECT + id + FROM actor_notifications + WHERE + type=? AND + actor_id=? AND + from_actor_id=? + ORDER BY cdate DESC + LIMIT 1 + ;` + try { + const selectQueryResults = await db + .prepare(selectQuery) + .bind(type, actor.id.toString(), fromActor.id.toString()) + .first<{ id: string }>() + return selectQueryResults.id + } catch (e: any) { + const message: string = `Unable to retrieve 'id' of newly-created '${type}' notification due to SQL error: ${ + e.message + }\n${e.cause?.message ?? e.cause}` + console.error(message) + throw new Error(message) + } } export async function sendFollowNotification( diff --git a/backend/src/mastodon/reblog.ts b/backend/src/mastodon/reblog.ts index 9e4d5cb52..0c374f074 100644 --- a/backend/src/mastodon/reblog.ts +++ b/backend/src/mastodon/reblog.ts @@ -1,7 +1,7 @@ // Also known as boost. import type { APObject } from 'wildebeest/backend/src/activitypub/objects' -import { type Database } from 'wildebeest/backend/src/database' +import type { Database, Result } from 'wildebeest/backend/src/database' import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import { getResultsField } from './utils' import { addObjectInOutbox } from '../activitypub/actors/outbox' @@ -14,20 +14,35 @@ import { addObjectInOutbox } from '../activitypub/actors/outbox' * @param obj ActivityPub object to reblog */ export async function createReblog(db: Database, actor: Actor, obj: APObject) { - await Promise.all([addObjectInOutbox(db, actor, obj), insertReblog(db, actor, obj)]) + await insertReblog(db, actor, obj).then(async (result: string) => { + if (result === 'success') { + await addObjectInOutbox(db, actor, obj) + } else { + throw new Error(result) + } + }) } export async function insertReblog(db: Database, actor: Actor, obj: APObject) { const id = crypto.randomUUID() - const query = ` + const insertQuery = ` INSERT INTO actor_reblogs (id, actor_id, object_id) VALUES (?, ?, ?) - ` + RETURNING * + ;` - 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) + try { + const insertQueryResults: Result = await db + .prepare(insertQuery) + .bind(id, actor.id.toString(), obj.id.toString()) + .run() + return insertQueryResults.success === true ? 'success' : 'Unexpected error occurred' + } catch (e: any) { + // prettier-ignore + const message: string = `Mastodon reblog of '${obj.id.toString()}' by user '${actor.id.toString()}' failed due to SQL error: ${e.message}\n${e.cause?.message ?? e.cause}\nobj.type, actor.id.toString(), obj.id.toString() = ${obj.type}, ${actor.id.toString()}, ${obj.id.toString()}` + console.error(message) + return message } } @@ -41,11 +56,17 @@ export function getReblogs(db: Database, obj: APObject): Promise> return getResultsField(statement, 'actor_id') } -export async function hasReblog(db: Database, actor: Actor, obj: APObject): Promise { +export async function hasReblog(db: Database, actorId: URL, objectId: URL): Promise { const query = ` - SELECT count(*) as count FROM actor_reblogs WHERE object_id=?1 AND actor_id=?2 + SELECT + count(1) as count + FROM actor_reblogs + WHERE + actor_id=?1 AND + object_id=?2 + LIMIT 1 ` - const { count } = await db.prepare(query).bind(obj.id.toString(), actor.id.toString()).first<{ count: number }>() + const { count } = await db.prepare(query).bind(actorId.toString(), objectId.toString()).first<{ count: number }>() return count > 0 } diff --git a/backend/src/mastodon/status.ts b/backend/src/mastodon/status.ts index ccbc53212..96c5367f7 100644 --- a/backend/src/mastodon/status.ts +++ b/backend/src/mastodon/status.ts @@ -158,7 +158,13 @@ export async function toMastodonStatusFromRow(domain: string, db: Database, row: } // FIXME: add unit tests for reblog + console.debug( + `properties.attributedTo ??? row.publisher_actor_id: ${properties.attributedTo} ??? ${row.publisher_actor_id}` + ) if (properties.attributedTo && properties.attributedTo !== row.publisher_actor_id) { + console.error( + `properties.attributedTo !== row.publisher_actor_id: ${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. @@ -168,10 +174,11 @@ export async function toMastodonStatusFromRow(domain: string, db: Database, row: const account = await loadExternalMastodonAccount(acct, author) // Restore reblogged status + // prettier-ignore status.reblog = { - ...status, - account, + ...status } + status.reblog.account = account } return status diff --git a/backend/src/mastodon/subscription.ts b/backend/src/mastodon/subscription.ts index b9ec8b963..1d62daeea 100644 --- a/backend/src/mastodon/subscription.ts +++ b/backend/src/mastodon/subscription.ts @@ -62,7 +62,7 @@ export async function createSubscription( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING * ` - const row = await db + await db .prepare(query) .bind( actor.id.toString(), @@ -82,6 +82,22 @@ export async function createSubscription( req.data.alerts.admin_report === false ? 0 : 1, req.data.policy ?? 'all' ) + .run() + + const row = await db + .prepare( + ` + SELECT * + FROM subscriptions + WHERE + actor_id=? AND + client_id=? AND + endpoint=? AND + key_auth=? + ORDER BY cdate DESC + LIMIT 1;` + ) + .bind(actor.id.toString(), client.id, req.subscription.endpoint, req.subscription.keys.auth) .first() return subscriptionFromRow(row) } diff --git a/backend/src/middleware/main.ts b/backend/src/middleware/main.ts index f226e8b79..20e59b7ca 100644 --- a/backend/src/middleware/main.ts +++ b/backend/src/middleware/main.ts @@ -39,9 +39,10 @@ async function loadContextData(db: Database, clientId: string, email: string, ct export async function main(context: EventContext) { if (context.request.method === 'OPTIONS') { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json', + ...cors() } return new Response('', { headers }) } diff --git a/backend/src/utils/http-signing-cavage.ts b/backend/src/utils/http-signing-cavage.ts index 827f6f0e0..b1b459250 100644 --- a/backend/src/utils/http-signing-cavage.ts +++ b/backend/src/utils/http-signing-cavage.ts @@ -157,11 +157,14 @@ export async function generateDigestHeader(body: string): Promise { export async function sign(request: Request, opts: SignOptions): Promise { const signingComponents: Component[] = opts.components ?? defaultSigningComponents + + // prettier-ignore const signingParams: Parameters = { - ...opts.parameters, keyid: opts.keyId, alg: opts.signer.alg, + ...opts.parameters } + const signatureInputString = buildSignatureInputString(signingComponents, signingParams) const dataToSign = buildSignedData(request, signingComponents, signingParams) const signature = await opts.signer(dataToSign) diff --git a/backend/src/utils/httpsigjs/parser.ts b/backend/src/utils/httpsigjs/parser.ts index cc0a8cd98..fa39d2b7d 100644 --- a/backend/src/utils/httpsigjs/parser.ts +++ b/backend/src/utils/httpsigjs/parser.ts @@ -281,12 +281,11 @@ 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 + ' ' + cf?.httpProtocol + 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.') diff --git a/backend/src/utils/sentry.ts b/backend/src/utils/sentry.ts index 019eabd73..ff69e4bd3 100644 --- a/backend/src/utils/sentry.ts +++ b/backend/src/utils/sentry.ts @@ -19,8 +19,7 @@ export function initSentry(request: Request, env: Env, context: any) { request, transportOptions: { headers }, }) - const cf = (request as { cf?: IncomingRequestCfProperties }).cf - const colo = cf?.colo ? cf.colo : 'UNKNOWN' + const colo = request.cf && request.cf.colo ? request.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/webpush/util.ts b/backend/src/webpush/util.ts index cc3859912..25378af3e 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.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+/g, '') + return str.replaceAll(/\+/g, '-').replaceAll(/\//g, '_').replace(/=+/g, '') } export function urlEncodedToB64(str: string): string { const padding = '='.repeat((4 - (str.length % 4)) % 4) - return str.replace(/-/g, '+').replace(/_/g, '/') + padding + return str.replaceAll(/-/g, '+').replaceAll(/_/g, '/') + padding } export function stringToU8Array(str: string): Uint8Array { diff --git a/backend/test/activitypub.spec.ts b/backend/test/activitypub.spec.ts index 435c6d4f7..d4deffeba 100644 --- a/backend/test/activitypub.spec.ts +++ b/backend/test/activitypub.spec.ts @@ -279,7 +279,7 @@ describe('ActivityPub', () => { assert.equal(res1.object.b, 2) assert(res1.created) - result = await db.prepare('SELECT count(*) as count from objects').first() + result = await db.prepare('SELECT count(1) as count from objects').first() assert.equal(result.count, 1) // Cache object second time updates the first one @@ -291,7 +291,7 @@ describe('ActivityPub', () => { assert.equal(res1.object.published, res2.object.published) assert(!res2.created) - result = await db.prepare('SELECT count(*) as count from objects').first() + result = await db.prepare('SELECT count(1) as count from objects').first() assert.equal(result.count, 1) }) diff --git a/backend/test/activitypub/handle.spec.ts b/backend/test/activitypub/handle.spec.ts index 4032f3753..b99734fe0 100644 --- a/backend/test/activitypub/handle.spec.ts +++ b/backend/test/activitypub/handle.spec.ts @@ -5,9 +5,12 @@ import { strict as assert } from 'node:assert/strict' import { cacheObject, getObjectById } from 'wildebeest/backend/src/activitypub/objects/' import { addFollowing } from 'wildebeest/backend/src/mastodon/follow' import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' -import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import * as objects from 'wildebeest/backend/src/activitypub/objects' +import { createPerson, Person } from 'wildebeest/backend/src/activitypub/actors' import { ObjectsRow } from 'wildebeest/backend/src/types/objects' import { originalObjectIdSymbol } from 'wildebeest/backend/src/activitypub/objects' +import { Note } from 'wildebeest/backend/src/activitypub/objects/note' +import { PUBLIC_GROUP } from 'wildebeest/backend/src/activitypub/activities' const adminEmail = 'admin@example.com' const domain = 'cloudflare.com' @@ -16,6 +19,61 @@ const vapidKeys = {} as JWK describe('ActivityPub', () => { describe('handle Activity', () => { + describe('Accept', () => { + beforeEach(() => { + globalThis.fetch = async (input: RequestInfo) => { + throw new Error('unexpected request to ' + input) + } + }) + + test('Accept follow request stores in db', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') + await addFollowing(db, actor, actor2, 'not needed') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Accept', + actor: { id: 'https://' + domain + '/ap/users/sven2' }, + object: { + type: 'Follow', + actor: actor.id, + object: 'https://' + domain + '/ap/users/sven2', + }, + } + + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const row = await db + .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) + .bind(actor.id.toString()) + .first<{ + target_actor_id: string + state: string + }>() + assert(row) + assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') + assert.equal(row.state, 'accepted') + }) + + test('Object must be an object', async () => { + const db = await makeDB() + await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Accept', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: '`activity.object` must be of type object', + }) + }) + }) + describe('Announce', () => { test('records reblog in db', async () => { const db = await makeDB() @@ -27,7 +85,7 @@ describe('ActivityPub', () => { const activity: any = { type: 'Announce', actor: actorB.id, - object: note.id, + object: note, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) @@ -39,21 +97,21 @@ describe('ActivityPub', () => { assert.equal(entry.object_id.toString(), note.id.toString()) }) - test('creates notification', async () => { + test('Local Announce (reblog) of local APObject creates local notification', async () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') - const note = await createPublicNote(domain, db, 'my first status', actorA) const activity: any = { type: 'Announce', actor: actorB.id, - object: note.id, + to: [PUBLIC_GROUP], + object: note, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + const entry = await db.prepare('SELECT type, actor_id, from_actor_id FROM actor_notifications').first<{ type: string actor_id: URL from_actor_id: URL @@ -63,127 +121,151 @@ describe('ActivityPub', () => { assert.equal(entry.actor_id.toString(), actorA.id.toString()) assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) }) - }) - - describe('Like', () => { - test('records like in db', async () => { - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') - const note = await createPublicNote(domain, db, 'my first status', actorA) + test('Remote announce objects are stored locally and added to the actors outbox', async () => { + const remoteActorId = new URL('https://example.com/actor') + const remoteObjectId = new URL('https://example.com/some-object') + const remoteObject = { + type: 'Note', + id: remoteObjectId, + url: remoteObjectId, + published: new Date().toISOString(), + content: 'foo', + attributedTo: remoteActorId.toString(), + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: [], + replies: undefined, + summary: undefined, + tag: [], + attachment: [], + inReplyTo: undefined, + [objects.originalActorIdSymbol]: remoteObjectId.toString(), + } as Note - const activity: any = { - type: 'Like', - actor: actorB.id, - object: note.id, + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === remoteActorId.toString()) { + return new Response( + JSON.stringify({ + id: remoteActorId, + icon: { url: 'img.com' }, + type: 'Person', + }) + ) + } + if (input.toString() === remoteObjectId.toString()) { + return new Response(JSON.stringify(remoteObject)) + } + console.error(`input ??? remoteActorId: ${input} vs. ${remoteActorId}`) + throw new Error('unexpected request to ' + input) } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ actor_id: URL; object_id: URL }>() - assert.equal(entry.actor_id.toString(), actorB.id.toString()) - assert.equal(entry.object_id.toString(), note.id.toString()) - }) - test('creates notification', async () => { const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') - - const note = await createPublicNote(domain, db, 'my first status', actorA) + const localActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') const activity: any = { - type: 'Like', - actor: actorB.id, - object: note.id, + type: 'Announce', + actor: localActor.id, + to: [PUBLIC_GROUP], + cc: [`${remoteActorId}/followers`, `${localActor.id}/inbox`], + object: remoteObject, } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + const object = await db.prepare('SELECT * FROM objects').first<{ type: string - actor_id: URL - from_actor_id: URL + original_actor_id: string }>() - assert.equal(entry.type, 'favourite') - assert.equal(entry.actor_id.toString(), actorA.id.toString()) - assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) - }) - - test('records like in db', async () => { - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') - - const note = await createPublicNote(domain, db, 'my first status', actorA) + assert(object) + assert.equal(object.type, 'Note') + assert.equal(object.original_actor_id, remoteActorId.toString()) - const activity: any = { - type: 'Like', - actor: actorB.id, - object: note.id, - } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + // // For testing purpose only: + // const result = await db.prepare('SELECT * FROM outbox_objects').all() + // if (result.success) { + // console.debug(`SUCCESS!!\n${JSON.stringify(result, null, 2)}`) + // } else { + // console.error('Something went wrong') + // } - const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ - actor_id: URL - object_id: URL - }>() - assert.equal(entry.actor_id.toString(), actorB.id.toString()) - assert.equal(entry.object_id.toString(), note.id.toString()) + const outbox_object = await db + .prepare('SELECT actor_id FROM outbox_objects WHERE actor_id=?') + .bind(localActor.id.toString()) + .first<{ actor_id: string }>() + assert(outbox_object) + assert.equal(outbox_object.actor_id, localActor.id.toString()) }) - }) - describe('Accept', () => { - beforeEach(() => { + test('duplicated announce', async () => { + const remoteActorId = new URL('https://example.com/actor') + const remoteObjectId = new URL('https://example.com/some-object') + const remoteObject = { + type: 'Note', + id: remoteObjectId, + url: remoteObjectId, + published: new Date().toISOString(), + content: 'foo', + attributedTo: remoteActorId.toString(), + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: [], + replies: undefined, + summary: undefined, + tag: [], + attachment: [], + inReplyTo: undefined, + [objects.originalActorIdSymbol]: remoteObjectId.toString(), + } as Note + globalThis.fetch = async (input: RequestInfo) => { + if (input.toString() === remoteActorId.toString()) { + return new Response( + JSON.stringify({ + id: remoteActorId, + icon: { url: 'img.com' }, + type: 'Person', + }) + ) + } + if (input.toString() === remoteObjectId.toString()) { + return new Response(JSON.stringify(remoteObject)) + } + console.error(`input ??? remoteActorId: ${input} vs. ${remoteActorId}`) throw new Error('unexpected request to ' + input) } - }) - test('Accept follow request stores in db', async () => { const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const actor2 = await createPerson(domain, db, userKEK, 'sven2@cloudflare.com') - await addFollowing(db, actor, actor2, 'not needed') + const localActor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - 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', - }, + const activity: any = { + type: 'Announce', + actor: localActor.id, + to: [PUBLIC_GROUP], + cc: [`${remoteActorId}/followers`, `${localActor.id}/inbox`], + object: remoteObject, } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const row = await db - .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) - .bind(actor.id.toString()) - .first<{ - target_actor_id: string - state: string - }>() - assert(row) - assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') - assert.equal(row.state, 'accepted') - }) + // Handle the same Activity + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - test('Object must be an object', async () => { - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const object = await db.prepare('SELECT * FROM objects').first<{ + type: string + original_actor_id: string + }>() + assert(object) + assert.equal(object.type, 'Note') + assert.equal(object.original_actor_id, remoteActorId.toString()) - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Accept', - actor: 'https://example.com/actor', - object: 'a', - } + // // For testing purpose only: + // const result = await db.prepare('SELECT * FROM outbox_objects').all() + // if (result.success) { + // console.debug(`SUCCESS!!\n${JSON.stringify(result, null, 2)}`) + // } else { + // console.error('Something went wrong') + // } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: '`activity.object` must be of type object', - }) + // Ensure only one reblog is kept + const { count } = await db.prepare('SELECT count(1) as count FROM outbox_objects').first<{ count: number }>() + assert.equal(count, 1) }) }) @@ -229,10 +311,10 @@ describe('ActivityPub', () => { }) test("Note adds in remote actor's outbox", async () => { - const remoteActorId = 'https://example.com/actor' + const remoteActorId = new URL('https://example.com/actor') globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === remoteActorId) { + if (input.toString() === remoteActorId.toString()) { return new Response( JSON.stringify({ id: remoteActorId, @@ -262,9 +344,9 @@ describe('ActivityPub', () => { const entry = await db .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') - .bind(remoteActorId) + .bind(remoteActorId.toString()) .first<{ actor_id: string }>() - assert.equal(entry.actor_id, remoteActorId) + assert.equal(entry.actor_id, remoteActorId.toString()) }) test('local actor sends Note with mention create notification', async () => { @@ -395,243 +477,60 @@ describe('ActivityPub', () => { }) }) - describe('Update', () => { - test('Object must be an object', async () => { + describe('Delete', () => { + test('delete Note', async () => { const db = await makeDB() - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: 'https://example.com/actor', - object: 'a', + const actorA: Person = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const originalObjectId = new URL('https://example.com/note123') + const cachedObject: Note = { + type: 'Note', + id: originalObjectId, + url: new URL('https://example.com/object1'), + published: new Date().toISOString(), + content: 'my first status', + attributedTo: (actorA.id as URL).toString(), + to: ['https://www.w3.org/ns/activitystreams#Public'], + cc: ['https://cloudflare.com/ap/users/a/followers'], + replies: undefined, + summary: undefined, + tag: [], + attachment: [], + inReplyTo: undefined, + [objects.originalActorIdSymbol]: (actorA.id as URL).toString(), } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: '`activity.object` must be of type object', - }) - }) - - test('Object must exist', async () => { - const db = await makeDB() + await db + .prepare( + 'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)' + ) + .bind( + originalObjectId.toString(), + cachedObject.type, + JSON.stringify(cachedObject), + (actorA.id as URL).toString(), + originalObjectId.toString(), + 'mastodonid1' + ) + .run() - 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', - }, + const activity: any = { + type: 'Delete', + actor: actorA.id as URL, + to: [], + cc: [], + object: originalObjectId, } - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: 'object https://example.com/note2 does not exist', - }) + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const { count } = await db.prepare('SELECT count(1) as count FROM objects').first<{ count: number }>() + assert.equal(count, 0) }) - test('Object must have the same origin', async () => { + test('delete Tombstone', async () => { const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const object = { - id: 'https://example.com/note2', - type: 'Note', - content: 'test note', - } - - const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) - assert.notEqual(obj, null, 'could not create object') - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: 'https://example.com/actor', - object: object, - } - - await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { - message: 'actorid mismatch when updating object', - }) - }) - - test('Object is updated', async () => { - const db = await makeDB() - const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const object = { - id: 'https://example.com/note2', - type: 'Note', - content: 'test note', - } - - const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) - assert.notEqual(obj, null, 'could not create object') - - const newObject = { - id: 'https://example.com/note2', - type: 'Note', - content: 'new test note', - } - - const activity = { - '@context': 'https://www.w3.org/ns/activitystreams', - type: 'Update', - actor: actor.id, - object: newObject, - } - - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const updatedObject = await db - .prepare('SELECT * FROM objects WHERE original_object_id=?') - .bind(object.id) - .first() - assert(updatedObject) - assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) - }) - }) - - describe('Announce', () => { - test('Announce objects are stored and added to the remote actors outbox', async () => { - const remoteActorId = 'https://example.com/actor' - const objectId = 'https://example.com/some-object' - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === remoteActorId) { - return new Response( - JSON.stringify({ - id: remoteActorId, - icon: { url: 'img.com' }, - type: 'Person', - }) - ) - } - - if (input.toString() === objectId) { - return new Response( - JSON.stringify({ - id: objectId, - type: 'Note', - content: 'foo', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const activity: any = { - type: 'Announce', - actor: remoteActorId, - to: [], - cc: [], - object: objectId, - } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const object = await db.prepare('SELECT * FROM objects').first<{ - type: string - original_actor_id: string - }>() - assert(object) - assert.equal(object.type, 'Note') - assert.equal(object.original_actor_id, remoteActorId) - - const outbox_object = await db - .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') - .bind(remoteActorId) - .first<{ actor_id: string }>() - assert(outbox_object) - assert.equal(outbox_object.actor_id, remoteActorId) - }) - - test('duplicated announce', async () => { - const remoteActorId = 'https://example.com/actor' - const objectId = 'https://example.com/some-object' - globalThis.fetch = async (input: RequestInfo) => { - if (input.toString() === remoteActorId) { - return new Response( - JSON.stringify({ - id: remoteActorId, - icon: { url: 'img.com' }, - type: 'Person', - }) - ) - } - - if (input.toString() === objectId) { - return new Response( - JSON.stringify({ - id: objectId, - type: 'Note', - content: 'foo', - }) - ) - } - - throw new Error('unexpected request to ' + input) - } - - const db = await makeDB() - await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - - const activity: any = { - type: 'Announce', - actor: remoteActorId, - to: [], - cc: [], - object: objectId, - } - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - // Handle the same Activity - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - // Ensure only one reblog is kept - const { count } = await db.prepare('SELECT count(*) as count FROM outbox_objects').first<{ count: number }>() - assert.equal(count, 1) - }) - }) - - describe('Delete', () => { - test('delete Note', async () => { - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - const originalObjectId = 'https://example.com/note123' - - await db - .prepare( - 'INSERT INTO objects (id, type, properties, original_actor_id, original_object_id, local, mastodon_id) VALUES (?, ?, ?, ?, ?, 1, ?)' - ) - .bind( - 'https://example.com/object1', - 'Note', - JSON.stringify({ content: 'my first status' }), - actorA.id.toString(), - originalObjectId, - 'mastodonid1' - ) - .run() - - const activity: any = { - type: 'Delete', - actor: actorA.id, - to: [], - cc: [], - object: originalObjectId, - } - - await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - - const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>() - assert.equal(count, 0) - }) - - test('delete Tombstone', async () => { - const db = await makeDB() - const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - const originalObjectId = 'https://example.com/note456' + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const originalObjectId = 'https://example.com/note456' await db .prepare( @@ -660,7 +559,7 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>() + const { count } = await db.prepare('SELECT count(1) as count FROM objects').first<{ count: number }>() assert.equal(count, 0) }) @@ -716,7 +615,7 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) // Ensure that we didn't actually delete the actor - const { count } = await db.prepare('SELECT count(*) as count FROM actors').first<{ count: number }>() + const { count } = await db.prepare('SELECT count(1) as count FROM actors').first<{ count: number }>() assert.equal(count, 1) }) @@ -739,9 +638,171 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const { count } = await db.prepare('SELECT count(*) as count FROM objects').first<{ count: number }>() + const { count } = await db.prepare('SELECT count(1) as count FROM objects').first<{ count: number }>() assert.equal(count, 1) }) }) + + describe('Like', () => { + test('records like in db', async () => { + const db = await makeDB() + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ actor_id: URL; object_id: URL }>() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + + test('creates notification', async () => { + const db = await makeDB() + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + type: string + actor_id: URL + from_actor_id: URL + }>() + assert.equal(entry.type, 'favourite') + assert.equal(entry.actor_id.toString(), actorA.id.toString()) + assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) + }) + + test('records like in db', async () => { + const db = await makeDB() + const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') + const actorB = await createPerson(domain, db, userKEK, 'b@cloudflare.com') + + const note = await createPublicNote(domain, db, 'my first status', actorA) + + const activity: any = { + type: 'Like', + actor: actorB.id, + object: note.id, + } + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ + actor_id: URL + object_id: URL + }>() + assert.equal(entry.actor_id.toString(), actorB.id.toString()) + assert.equal(entry.object_id.toString(), note.id.toString()) + }) + }) + + describe('Update', () => { + test('Object must be an object', async () => { + const db = await makeDB() + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: 'a', + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: '`activity.object` must be of type object', + }) + }) + + test('Object must exist', async () => { + const db = await makeDB() + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + }, + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: 'object https://example.com/note2 does not exist', + }) + }) + + test('Object must have the same origin', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const object = { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + } + + const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) + assert.notEqual(obj, null, 'could not create object') + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: 'https://example.com/actor', + object: object, + } + + await assert.rejects(activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys), { + message: 'actorid mismatch when updating object', + }) + }) + + test('Object is updated', async () => { + const db = await makeDB() + const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') + const object = { + id: 'https://example.com/note2', + type: 'Note', + content: 'test note', + } + + const obj = await cacheObject(domain, db, object, actor.id, new URL(object.id), false) + assert.notEqual(obj, null, 'could not create object') + + const newObject = { + id: 'https://example.com/note2', + type: 'Note', + content: 'new test note', + } + + const activity = { + '@context': 'https://www.w3.org/ns/activitystreams', + type: 'Update', + actor: actor.id, + object: newObject, + } + + await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) + + const updatedObject = await db + .prepare('SELECT * FROM objects WHERE original_object_id=?') + .bind(object.id) + .first() + assert(updatedObject) + assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) + }) + }) }) }) diff --git a/backend/test/mastodon/accounts.spec.ts b/backend/test/mastodon/accounts.spec.ts index 6ab164069..eb3b321fb 100644 --- a/backend/test/mastodon/accounts.spec.ts +++ b/backend/test/mastodon/accounts.spec.ts @@ -576,7 +576,7 @@ describe('Mastodon APIs', () => { // Statuses were imported locally and once was a reblog of an already // existing local object. - const row: { count: number } = await db.prepare(`SELECT count(*) as count FROM objects`).first() + const row: { count: number } = await db.prepare(`SELECT count(1) as count FROM objects`).first() assert.equal(row.count, 2) }) @@ -1051,7 +1051,7 @@ describe('Mastodon APIs', () => { assert.equal(receivedActivity.object.type, 'Follow') const row = await db - .prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`) + .prepare(`SELECT count(1) as count FROM actor_following WHERE actor_id=?`) .bind(actor.id.toString()) .first<{ count: number }>() assert(row) diff --git a/backend/test/mastodon/oauth.spec.ts b/backend/test/mastodon/oauth.spec.ts index eb5513703..f78bd90ab 100644 --- a/backend/test/mastodon/oauth.spec.ts +++ b/backend/test/mastodon/oauth.spec.ts @@ -114,7 +114,7 @@ describe('Mastodon APIs', () => { ) // actor isn't created yet - const { count } = await db.prepare('SELECT count(*) as count FROM actors').first<{ count: number }>() + const { count } = await db.prepare('SELECT count(1) as count FROM actors').first<{ count: number }>() assert.equal(count, 0) }) diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts index 3e834dd1d..b364313e7 100644 --- a/backend/test/mastodon/statuses.spec.ts +++ b/backend/test/mastodon/statuses.spec.ts @@ -140,7 +140,7 @@ describe('Mastodon APIs', () => { const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache) assert.equal(res.status, 200) - const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first<{ count: number }>() + const row = await db.prepare(`SELECT count(1) as count FROM outbox_objects`).first<{ count: number }>() assert.equal(row.count, 1) }) @@ -310,8 +310,7 @@ describe('Mastodon APIs', () => { assert.equal(res.status, 200) const data = await res.json() - - const note = (await getObjectByMastodonId(db, data.id)) as unknown as Note + const note = (await getObjectByMastodonId(db, data.id)) as Note assert.equal(note.tag.length, 1) assert.equal(note.tag[0].href, actor.id.toString()) assert.equal(note.tag[0].name, 'sven@' + domain) @@ -874,11 +873,11 @@ describe('Mastodon APIs', () => { assert.equal(res.status, 200) { - const { count } = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first() + const { count } = await db.prepare(`SELECT count(1) as count FROM outbox_objects`).first() assert.equal(count, 0) } { - const { count } = await db.prepare(`SELECT count(*) as count FROM objects`).first() + const { count } = await db.prepare(`SELECT count(1) as count FROM objects`).first() assert.equal(count, 0) } }) @@ -974,12 +973,12 @@ describe('Mastodon APIs', () => { assert.deepEqual(data1, data2) { - const row = await db.prepare(`SELECT count(*) as count FROM objects`).first<{ count: number }>() + const row = await db.prepare(`SELECT count(1) as count FROM objects`).first<{ count: number }>() assert.equal(row.count, 1) } { - const row = await db.prepare(`SELECT count(*) as count FROM idempotency_keys`).first<{ count: number }>() + const row = await db.prepare(`SELECT count(1) as count FROM idempotency_keys`).first<{ count: number }>() assert.equal(row.count, 1) } }) diff --git a/backend/test/mastodon/subscription.spec.ts b/backend/test/mastodon/subscription.spec.ts index be078efbe..5d9aed451 100644 --- a/backend/test/mastodon/subscription.spec.ts +++ b/backend/test/mastodon/subscription.spec.ts @@ -135,7 +135,7 @@ describe('Mastodon APIs', () => { const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) assert.equal(res.status, 200) - const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>() + const { count } = await db.prepare('SELECT count(1) as count FROM subscriptions').first<{ count: number }>() assert.equal(count, 1) }) diff --git a/frontend/adaptors/cloudflare-pages/vite.config.ts b/frontend/adaptors/cloudflare-pages/vite.config.ts index af2dd5a3d..34baa4bd6 100644 --- a/frontend/adaptors/cloudflare-pages/vite.config.ts +++ b/frontend/adaptors/cloudflare-pages/vite.config.ts @@ -1,4 +1,4 @@ -import { cloudflarePagesAdapter } from '@builder.io/qwik-city/adapters/cloudflare-pages/vite' +import { cloudflarePagesAdaptor } from '@builder.io/qwik-city/adaptors/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: [ - cloudflarePagesAdapter({ + cloudflarePagesAdaptor({ // 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 87dd4f77d..37f852db0 100644 --- a/frontend/mock-db/init.ts +++ b/frontend/mock-db/init.ts @@ -7,8 +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' +import { upsertRule } from 'wildebeest/functions/api/wb/settings/server/rules' +import { upsertServerSettings } from 'wildebeest/functions/api/wb/settings/server/server' /** * Run helper commands to initialize the database with actors, statuses, etc. diff --git a/frontend/mock-db/run.mjs b/frontend/mock-db/run.mjs index a5138b10f..9a1352c2a 100644 --- a/frontend/mock-db/run.mjs +++ b/frontend/mock-db/run.mjs @@ -22,7 +22,7 @@ async function main() { define: ['jest:{}'], } const workerPath = resolve(__dirname, './worker.ts') - const worker = await unstable_dev(workerPath, { ...options, experimental: { disableExperimentalWarning: true } }) + const worker = await unstable_dev(workerPath, { experimental: { disableExperimentalWarning: true }, ...options }) await worker.fetch() await worker.stop() } diff --git a/frontend/package.json b/frontend/package.json index 5e4c64679..48d8cebad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,6 @@ }, "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", diff --git a/frontend/src/components/MediaGallery.tsx/Image.tsx b/frontend/src/components/MediaGallery.tsx/Image.tsx index 96ec14b5a..05b7b7ade 100644 --- a/frontend/src/components/MediaGallery.tsx/Image.tsx +++ b/frontend/src/components/MediaGallery.tsx/Image.tsx @@ -32,7 +32,7 @@ export default component$(({ mediaAttachment, onOpenImagesModal$ }) => { onOpenImagesModal$(mediaAttachment.id)} diff --git a/frontend/src/components/StatusesPanel/StatusesPanel.tsx b/frontend/src/components/StatusesPanel/StatusesPanel.tsx index d59d97347..009efa586 100644 --- a/frontend/src/components/StatusesPanel/StatusesPanel.tsx +++ b/frontend/src/components/StatusesPanel/StatusesPanel.tsx @@ -21,6 +21,9 @@ export const StatusesPanel = component$(({ initialStatuses, fetchMoreStatuses: f const newStatuses = await fetchMoreStatusesFn(statuses.value.length) fetchingMoreStatuses.value = false noMoreStatusesAvailable.value = newStatuses.length === 0 + // TODO: Double-check that this is working as intended + // because this syntax will silently fail with multi-dimensional arrays + // ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Spread_syntax#sect1 statuses.value = [...statuses.value, ...newStatuses] }) diff --git a/frontend/src/components/layout/LeftColumn/LeftColumn.tsx b/frontend/src/components/layout/LeftColumn/LeftColumn.tsx index 45b816d5d..d82b22d78 100644 --- a/frontend/src/components/layout/LeftColumn/LeftColumn.tsx +++ b/frontend/src/components/layout/LeftColumn/LeftColumn.tsx @@ -1,5 +1,4 @@ import { component$, useContext } from '@builder.io/qwik' -import { Link } from '@builder.io/qwik-city' import { InstanceConfigContext } from '~/utils/instanceConfig' import { useDomain } from '~/utils/useDomain' @@ -17,12 +16,6 @@ export default component$(() => { Wildebeest instance thumbnail

{config.description}

- - Learn More - ) }) diff --git a/frontend/src/components/layout/RightColumn/RightColumn.tsx b/frontend/src/components/layout/RightColumn/RightColumn.tsx index 12c16d799..e7a12a88b 100644 --- a/frontend/src/components/layout/RightColumn/RightColumn.tsx +++ b/frontend/src/components/layout/RightColumn/RightColumn.tsx @@ -37,7 +37,7 @@ export default component$(() => { { iconName: 'fa-globe', linkText: 'Federated', linkTarget: '/public', linkActiveRegex: /^\/public\/?$/ }, ] - const aboutLink = { iconName: 'fa-ellipsis', linkText: 'About', linkTarget: '/about', linkActiveRegex: /^\/about/ } + // const aboutLink = { iconName: 'fa-ellipsis', linkText: 'About', linkTarget: '/about', linkActiveRegex: /^\/about/ } return (
@@ -49,15 +49,16 @@ export default component$(() => {
{links.map((link) => renderNavLink(link))} -
+ {/* *********** Hiding the about link until the backend support is available ***************** */} + {/*

{renderNavLink(aboutLink)} -
+
*/} {!isAuthorized && ( Sign in diff --git a/frontend/src/dummyData/accounts.ts b/frontend/src/dummyData/accounts.ts index cd929b7bf..f08c99e2f 100644 --- a/frontend/src/dummyData/accounts.ts +++ b/frontend/src/dummyData/accounts.ts @@ -41,11 +41,11 @@ export const rafael = generateDummyAccount({ avatar_static: getAvatarUrl(309), }) +// prettier-ignore function generateDummyAccount( details: Pick ): Account { return { - ...details, id: `${Math.round(Math.random() * 9999999)}`.padStart(7, '0'), locked: false, bot: false, @@ -88,6 +88,7 @@ function generateDummyAccount( verified_at: null, }, ], + ...details } } diff --git a/frontend/src/dummyData/statuses.ts b/frontend/src/dummyData/statuses.ts index a50530982..400970a43 100644 --- a/frontend/src/dummyData/statuses.ts +++ b/frontend/src/dummyData/statuses.ts @@ -54,12 +54,13 @@ const mastodonRawStatuses: MastodonStatus[] = [ }), ] +// prettier-ignore export const statuses: MastodonStatus[] = mastodonRawStatuses.map((rawStatus) => ({ - ...rawStatus, media_attachments: rawStatus.media_attachments.map((mediaAttachment) => ({ - ...mediaAttachment, type: getStandardMediaType(mediaAttachment.type), + ...mediaAttachment })), + ...rawStatus })) export const replies: MastodonStatus[] = [ diff --git a/frontend/src/entry.ssr.tsx b/frontend/src/entry.ssr.tsx index 5c72fc8d4..d252bed23 100644 --- a/frontend/src/entry.ssr.tsx +++ b/frontend/src/entry.ssr.tsx @@ -11,11 +11,11 @@ import Root from './root' export default function (opts: RenderToStreamOptions) { return renderToStream(, { manifest, - ...opts, // Use container attributes to set attributes on the html tag. containerAttributes: { lang: 'en-us', - ...opts.containerAttributes, + ...opts.containerAttributes }, + ...opts }) } diff --git a/frontend/src/routes/(admin)/settings/(admin)/server-settings/about/index.tsx b/frontend/src/routes/(admin)/settings/(admin)/server-settings/about/index.tsx index 0389cde89..c5e515dc2 100644 --- a/frontend/src/routes/(admin)/settings/(admin)/server-settings/about/index.tsx +++ b/frontend/src/routes/(admin)/settings/(admin)/server-settings/about/index.tsx @@ -1,7 +1,7 @@ import { component$ } from '@builder.io/qwik' import { action$, Form, Link, z, zod$ } from '@builder.io/qwik-city' import { getDatabase } from 'wildebeest/backend/src/database' -import { updateSettings } from 'wildebeest/backend/src/config/server' +import { handleRequestPost } from 'wildebeest/functions/api/wb/settings/server/server' import { TextArea } from '~/components/Settings/TextArea' import { serverSettingsLoader } from '../layout' @@ -12,12 +12,16 @@ const zodSchema = zod$({ export type ServerAboutData = Awaited['_type'] -export const action = action$(async (data, { platform }) => { - const db = await getDatabase(platform) +export const action = action$(async (data, { request, platform }) => { let success = false try { - await updateSettings(db, data) - success = true + const response = await handleRequestPost( + await getDatabase(platform), + new Request(request, { body: JSON.stringify(data) }), + platform.ACCESS_AUTH_DOMAIN, + platform.ACCESS_AUD + ) + success = response.ok } catch (e: unknown) { success = false } @@ -32,7 +36,7 @@ export default component$(() => { const saveAction = action() return ( -
+

Provide in-depth information about how the server is operated, moderated, funded.

diff --git a/frontend/src/routes/(admin)/settings/(admin)/server-settings/branding/index.tsx b/frontend/src/routes/(admin)/settings/(admin)/server-settings/branding/index.tsx index c59016b4b..a6e8961a5 100644 --- a/frontend/src/routes/(admin)/settings/(admin)/server-settings/branding/index.tsx +++ b/frontend/src/routes/(admin)/settings/(admin)/server-settings/branding/index.tsx @@ -1,7 +1,7 @@ import { component$ } from '@builder.io/qwik' import { action$, Form, zod$, z } from '@builder.io/qwik-city' import { getDatabase } from 'wildebeest/backend/src/database' -import { updateSettings } from 'wildebeest/backend/src/config/server' +import { handleRequestPost } from 'wildebeest/functions/api/wb/settings/server/server' import { TextArea } from '~/components/Settings/TextArea' import { TextInput } from '~/components/Settings/TextInput' import { serverSettingsLoader } from '../layout' @@ -13,12 +13,16 @@ const zodSchema = zod$({ export type ServerBrandingData = Awaited['_type'] -export const action = action$(async (data, { platform }) => { - const db = await getDatabase(platform) +export const action = action$(async (data, { request, platform }) => { let success = false try { - await updateSettings(db, data) - success = true + const response = await handleRequestPost( + await getDatabase(platform), + new Request(request, { body: JSON.stringify(data) }), + platform.ACCESS_AUTH_DOMAIN, + platform.ACCESS_AUD + ) + success = response.ok } catch (e: unknown) { success = false } @@ -33,7 +37,7 @@ export default component$(() => { const saveAction = action() return ( - +

Your server's branding differentiates it from other servers in the network. This information may be displayed across a variety of environments, such as Mastodon's web interface, native applications, in link previews on diff --git a/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout.tsx b/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout.tsx index cba57a4d5..89c1c7185 100644 --- a/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout.tsx +++ b/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout.tsx @@ -1,7 +1,7 @@ import { component$, Slot } from '@builder.io/qwik' import { Link, loader$, useLocation } from '@builder.io/qwik-city' import { getDatabase } from 'wildebeest/backend/src/database' -import { getSettings } from 'wildebeest/backend/src/config/server' +import { handleRequestGet } from 'wildebeest/functions/api/wb/settings/server/server' import { ServerAboutData } from './about' import { ServerBrandingData } from './branding' @@ -10,7 +10,7 @@ export type ServerSettingsData = ServerBrandingData & ServerAboutData export const serverSettingsLoader = loader$>>(async ({ platform }) => { const database = await getDatabase(platform) - const settingsResp = await getSettings(database) + const settingsResp = await handleRequestGet(database) let settingsData: Partial = {} try { settingsData = await settingsResp.json() diff --git a/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/edit/[id]/index.tsx b/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/edit/[id]/index.tsx index a8a05e54a..a6b3f6082 100644 --- a/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/edit/[id]/index.tsx +++ b/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/edit/[id]/index.tsx @@ -1,7 +1,8 @@ import { component$ } from '@builder.io/qwik' import { action$, Form, loader$, useNavigate, z, zod$ } from '@builder.io/qwik-city' import { getDatabase } from 'wildebeest/backend/src/database' -import { getRules, upsertRule } from 'wildebeest/backend/src/config/rules' +import { handleRequestGet } from 'wildebeest/functions/api/v1/instance/rules' +import { upsertRule } from 'wildebeest/functions/api/wb/settings/server/rules' import { TextArea } from '~/components/Settings/TextArea' import { getErrorHtml } from '~/utils/getErrorHtml/getErrorHtml' @@ -32,7 +33,14 @@ export const editAction = action$( export const ruleLoader = loader$>(async ({ params, platform, html }) => { const database = await getDatabase(platform) - const rules = await getRules(database) + + const settingsResp = await handleRequestGet(database) + let rules: { id: number; text: string }[] = [] + try { + rules = await settingsResp.json() + } catch { + rules = [] + } const rule: { id: number; text: string } | undefined = rules.find((r) => r.id === +params['id']) @@ -55,7 +63,7 @@ export default component$(() => { return ( <> - +

While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat diff --git a/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/index.tsx b/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/index.tsx index 08ec5a48a..eac772c63 100644 --- a/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/index.tsx +++ b/frontend/src/routes/(admin)/settings/(admin)/server-settings/rules/index.tsx @@ -1,7 +1,8 @@ import { component$ } from '@builder.io/qwik' import { action$, Form, Link, loader$, z, zod$ } from '@builder.io/qwik-city' import { getDatabase } from 'wildebeest/backend/src/database' -import { getRules, deleteRule, upsertRule } from 'wildebeest/backend/src/config/rules' +import { handleRequestGet } from 'wildebeest/functions/api/v1/instance/rules' +import { deleteRule, upsertRule } from 'wildebeest/functions/api/wb/settings/server/rules' import { TextArea } from '~/components/Settings/TextArea' export type ServerSettingsData = { rules: string[] } @@ -47,7 +48,15 @@ export const deleteAction = action$( export const rulesLoader = loader$>(async ({ platform }) => { const database = await getDatabase(platform) - const rules = await getRules(database) + + const settingsResp = await handleRequestGet(database) + let rules: { id: number; text: string }[] = [] + try { + rules = await settingsResp.json() + } catch { + rules = [] + } + return JSON.parse(JSON.stringify(rules)) }) @@ -85,11 +94,11 @@ export default component$(() => {

{rules.value.map(({ id, text }, idx) => { - const ruleNumber = idx + 1 - const ruleBtnText = `${ruleNumber}. ${text.slice(0, 27)}${text.length > 27 ? '...' : ''}` + const ruleId = idx + 1 + const ruleBtnText = `${ruleId}. ${text.slice(0, 27)}${text.length > 27 ? '...' : ''}` return (
- + {ruleBtnText}
diff --git a/frontend/src/routes/(frontend)/[accountId]/layout.tsx b/frontend/src/routes/(frontend)/[accountId]/layout.tsx index 7d964ea0b..7c27fc434 100644 --- a/frontend/src/routes/(frontend)/[accountId]/layout.tsx +++ b/frontend/src/routes/(frontend)/[accountId]/layout.tsx @@ -65,12 +65,13 @@ export default component$(() => { const location = useLocation() const currentPath = location.pathname.replace(/\/$/, '') + // prettier-ignore const fields = [ { name: 'Joined', value: formatDateTime(pageDetails.account.created_at, false), }, - ...pageDetails.account.fields, + ...pageDetails.account.fields ] const stats = [ diff --git a/frontend/src/routes/(frontend)/about/index.tsx b/frontend/src/routes/(frontend)/about/index.tsx index 6502fe3b2..ea43c82bb 100644 --- a/frontend/src/routes/(frontend)/about/index.tsx +++ b/frontend/src/routes/(frontend)/about/index.tsx @@ -2,10 +2,11 @@ import { component$ } from '@builder.io/qwik' import { DocumentHead, loader$ } from '@builder.io/qwik-city' import { getDatabase } from 'wildebeest/backend/src/database' import { getDomain } from 'wildebeest/backend/src/utils/getDomain' -import { getSettings } from 'wildebeest/backend/src/config/server' -import { getRules } from 'wildebeest/backend/src/config/rules' +import { handleRequestGet as settingsHandleRequestGet } from 'wildebeest/functions/api/wb/settings/server/server' +import { handleRequestGet as rulesHandleRequestGet } from 'wildebeest/functions/api/v1/instance/rules' import { Accordion } from '~/components/Accordion/Accordion' import { HtmlContent } from '~/components/HtmlContent/HtmlContent' +import { ServerSettingsData } from '~/routes/(admin)/settings/(admin)/server-settings/layout' import { Account } from '~/types' import { getDocumentHead } from '~/utils/getDocumentHead' import { instanceLoader } from '../layout' @@ -27,9 +28,25 @@ type AboutInfo = { export const aboutInfoLoader = loader$>(async ({ resolveValue, request, platform }) => { // TODO: fetching the instance for the thumbnail, but that should be part of the settings const instance = await resolveValue(instanceLoader) + const database = await getDatabase(platform) - const brandingData = await getSettings(database) - const rules = await getRules(database) + + const brandingDataResp = await settingsHandleRequestGet(database) + let brandingData: ServerSettingsData | null + try { + brandingData = await brandingDataResp.json() + } catch { + brandingData = null + } + + const rulesResp = await rulesHandleRequestGet(database) + let rules: { id: number; text: string }[] = [] + try { + rules = await rulesResp.json() + } catch { + rules = [] + } + const admins = await getAdmins(database) let adminAccount: Account | null = null @@ -105,10 +122,10 @@ export default component$(() => {
    - {aboutInfo.rules.map(({ id, text }, idx) => ( + {aboutInfo.rules.map(({ id, text }) => (
  1. - {idx + 1} + {id} {text}
  2. diff --git a/functions/.well-known/nodeinfo.ts b/functions/.well-known/nodeinfo.ts index d32c19194..d04c7caa5 100644 --- a/functions/.well-known/nodeinfo.ts +++ b/functions/.well-known/nodeinfo.ts @@ -1,10 +1,11 @@ import type { Env } from 'wildebeest/backend/src/types/env' import { cors } from 'wildebeest/backend/src/utils/cors' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json', 'cache-control': 'max-age=259200, public', + ...cors() } export const onRequest: PagesFunction = async ({ env }) => { diff --git a/functions/ap/o/[id].ts b/functions/ap/o/[id].ts index 8c401ffd5..310279f77 100644 --- a/functions/ap/o/[id].ts +++ b/functions/ap/o/[id].ts @@ -8,9 +8,10 @@ export const onRequest: PagesFunction = async ({ params, request, env return handleRequest(domain, await getDatabase(env), params.id as string) } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/activity+json; charset=utf-8', + ...cors() } export async function handleRequest(domain: string, db: Database, id: string): Promise { @@ -19,6 +20,7 @@ export async function handleRequest(domain: string, db: Database, id: string): P return new Response('', { status: 404 }) } + // prettier-ignore const res = { // TODO: should this be part of the object? '@context': [ @@ -33,8 +35,7 @@ export async function handleRequest(domain: string, db: Database, id: string): P votersCount: 'toot:votersCount', }, ], - - ...obj, + ...obj } return new Response(JSON.stringify(res), { status: 200, headers }) diff --git a/functions/ap/users/[id].ts b/functions/ap/users/[id].ts index ddfba06fe..42c996661 100644 --- a/functions/ap/users/[id].ts +++ b/functions/ap/users/[id].ts @@ -10,10 +10,11 @@ export const onRequest: PagesFunction = async ({ params, request, env return handleRequest(domain, await getDatabase(env), params.id as string) } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/activity+json; charset=utf-8', 'Cache-Control': 'max-age=180, public', + ...cors() } export async function handleRequest(domain: string, db: Database, id: string): Promise { @@ -28,6 +29,7 @@ export async function handleRequest(domain: string, db: Database, id: string): P return new Response('', { status: 404 }) } + // prettier-ignore const res = { // TODO: should this be part of the actor object? '@context': [ @@ -42,8 +44,7 @@ export async function handleRequest(domain: string, db: Database, id: string): P }, }, ], - - ...person, + ...person } return new Response(JSON.stringify(res), { status: 200, headers }) diff --git a/functions/ap/users/[id]/outbox/page.ts b/functions/ap/users/[id]/outbox/page.ts index b574a6389..19981a11a 100644 --- a/functions/ap/users/[id]/outbox/page.ts +++ b/functions/ap/users/[id]/outbox/page.ts @@ -15,9 +15,10 @@ export const onRequest: PagesFunction = async ({ request, return handleRequest(domain, await getDatabase(env), params.id as string) } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const DEFAULT_LIMIT = 20 @@ -59,6 +60,7 @@ LIMIT ?2 const result: any = results[i] const properties = JSON.parse(result.properties) + // prettier-ignore const note: Note = { id: new URL(result.id), atomUri: new URL(result.id), @@ -81,9 +83,9 @@ LIMIT ?2 id: 'https://example.com/users/a/statuses/109372762645660352/replies', type: 'Collection', }, - - ...properties, + ...properties } + const activity = activityCreate.create(domain, actor, note) delete activity['@context'] activity.id = note.id + '/activity' diff --git a/functions/api/v1/accounts/[id].ts b/functions/api/v1/accounts/[id].ts index 810138053..438913b85 100644 --- a/functions/api/v1/accounts/[id].ts +++ b/functions/api/v1/accounts/[id].ts @@ -6,9 +6,10 @@ import type { ContextData } from 'wildebeest/backend/src/types/context' import type { Env } from 'wildebeest/backend/src/types/env' import { getAccount } from 'wildebeest/backend/src/accounts/getAccount' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, env, params }) => { diff --git a/functions/api/v1/accounts/[id]/featured_tags.ts b/functions/api/v1/accounts/[id]/featured_tags.ts index 82dd5b5fd..74c49e852 100644 --- a/functions/api/v1/accounts/[id]/featured_tags.ts +++ b/functions/api/v1/accounts/[id]/featured_tags.ts @@ -1,9 +1,10 @@ import { cors } from 'wildebeest/backend/src/utils/cors' export const onRequest = async () => { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const res: any = [] return new Response(JSON.stringify(res), { headers }) diff --git a/functions/api/v1/accounts/[id]/follow.ts b/functions/api/v1/accounts/[id]/follow.ts index da9b916ef..ea352f848 100644 --- a/functions/api/v1/accounts/[id]/follow.ts +++ b/functions/api/v1/accounts/[id]/follow.ts @@ -50,9 +50,10 @@ export async function handleRequest( const res: Relationship = { id: await addFollowing(db, connectedActor, targetActor, acct), } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v1/accounts/[id]/followers.ts b/functions/api/v1/accounts/[id]/followers.ts index f093537e1..a0513f671 100644 --- a/functions/api/v1/accounts/[id]/followers.ts +++ b/functions/api/v1/accounts/[id]/followers.ts @@ -51,9 +51,10 @@ async function getRemoteFollowers(request: Request, handle: Handle, db: Database }) const out = await Promise.all(promises) + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(out), { headers }) } @@ -78,9 +79,10 @@ async function getLocalFollowers(request: Request, handle: Handle, db: Database) } } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(out), { headers }) } diff --git a/functions/api/v1/accounts/[id]/following.ts b/functions/api/v1/accounts/[id]/following.ts index cc2ecaccc..abd2f3c75 100644 --- a/functions/api/v1/accounts/[id]/following.ts +++ b/functions/api/v1/accounts/[id]/following.ts @@ -51,9 +51,10 @@ async function getRemoteFollowing(request: Request, handle: Handle, db: Database }) const out = await Promise.all(promises) + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(out), { headers }) } @@ -78,9 +79,10 @@ async function getLocalFollowing(request: Request, handle: Handle, db: Database) } } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(out), { headers }) } diff --git a/functions/api/v1/accounts/[id]/lists.ts b/functions/api/v1/accounts/[id]/lists.ts index 27403099e..0043e3dc2 100644 --- a/functions/api/v1/accounts/[id]/lists.ts +++ b/functions/api/v1/accounts/[id]/lists.ts @@ -1,8 +1,9 @@ import { cors } from 'wildebeest/backend/src/utils/cors' export const onRequest = async () => { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const res: any = [] return new Response(JSON.stringify(res), { headers }) diff --git a/functions/api/v1/accounts/[id]/statuses.ts b/functions/api/v1/accounts/[id]/statuses.ts index 04bc3f3da..c52f2af8e 100644 --- a/functions/api/v1/accounts/[id]/statuses.ts +++ b/functions/api/v1/accounts/[id]/statuses.ts @@ -20,9 +20,10 @@ import * as actors from 'wildebeest/backend/src/activitypub/actors' import { toMastodonStatusFromRow } from 'wildebeest/backend/src/mastodon/status' import { adjustLocalHostDomain } from 'wildebeest/backend/src/utils/adjustLocalHostDomain' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, env, params }) => { @@ -71,12 +72,15 @@ async function getRemoteStatuses(request: Request, handle: Handle, db: Database) const account = await loadExternalMastodonAccount(acct, actor) const promises = activities.items.map(async (activity: Activity) => { - const getObjectAsId = makeGetObjectAsId(activity) - const getActorAsId = makeGetActorAsId(activity) - if (activity.type === 'Create') { - const actorId = getActorAsId() - const originalObjectId = getObjectAsId() + const actorId: URL | null = makeGetActorAsId(activity)() + const originalObjectId: URL | null = makeGetObjectAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + if (originalObjectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } const res = await objects.cacheObject(domain, db, activity.object, actorId, originalObjectId, false) return toMastodonStatusFromObject(db, res.object as Note, domain) } @@ -84,8 +88,14 @@ async function getRemoteStatuses(request: Request, handle: Handle, db: Database) if (activity.type === 'Announce') { let obj: any - const actorId = getActorAsId() - const objectId = getObjectAsId() + const actorId: URL | null = makeGetActorAsId(activity)() + const objectId: URL | null = makeGetObjectAsId(activity)() + if (actorId === null) { + throw new Error(`Activity type '${activity.type}' requires an actor with a valid ID`) + } + if (objectId === null) { + throw new Error(`Activity type '${activity.type}' requires an object with a valid ID`) + } const localObject = await objects.getObjectById(db, objectId) if (localObject === null) { @@ -133,9 +143,9 @@ SELECT objects.*, 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(1) FROM actor_favourites WHERE actor_favourites.object_id=objects.id) as favourites_count, + (SELECT count(1) FROM actor_reblogs WHERE actor_reblogs.object_id=objects.id) as reblogs_count, + (SELECT count(1) 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 diff --git a/functions/api/v1/accounts/[id]/unfollow.ts b/functions/api/v1/accounts/[id]/unfollow.ts index 3b8ca1fd5..f2f91d8ea 100644 --- a/functions/api/v1/accounts/[id]/unfollow.ts +++ b/functions/api/v1/accounts/[id]/unfollow.ts @@ -49,9 +49,10 @@ export async function handleRequest( // FIXME: stub id: '0', } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v1/accounts/relationships.ts b/functions/api/v1/accounts/relationships.ts index 1bbf96cf8..21a914110 100644 --- a/functions/api/v1/accounts/relationships.ts +++ b/functions/api/v1/accounts/relationships.ts @@ -55,9 +55,10 @@ export async function handleRequest(req: Request, db: Database, connectedActor: }) } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v1/accounts/update_credentials.ts b/functions/api/v1/accounts/update_credentials.ts index da30c11f9..175d57f97 100644 --- a/functions/api/v1/accounts/update_credentials.ts +++ b/functions/api/v1/accounts/update_credentials.ts @@ -15,9 +15,10 @@ import type { CredentialAccount } from 'wildebeest/backend/src/types/account' import type { ContextData } from 'wildebeest/backend/src/types/context' import { loadLocalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, data, env }) => { @@ -59,12 +60,12 @@ export async function handleRequest( if (formData.has('display_name')) { const value = formData.get('display_name')! - await updateActorProperty(db, connectedActor.id, 'name', value as string) + await updateActorProperty(db, connectedActor.id, 'name', value) } if (formData.has('note')) { const value = formData.get('note')! - await updateActorProperty(db, connectedActor.id, 'summary', value as string) + await updateActorProperty(db, connectedActor.id, 'summary', value) } if (formData.has('avatar')) { @@ -92,8 +93,8 @@ export async function handleRequest( } const user = await loadLocalMastodonAccount(db, actor) + // prettier-ignore const res: CredentialAccount = { - ...user, source: { note: user.note, fields: user.fields, @@ -112,6 +113,7 @@ export async function handleRequest( created_at: '2022-09-08T22:48:07.983Z', updated_at: '2022-09-08T22:48:07.983Z', }, + ...user } // send updates diff --git a/functions/api/v1/accounts/verify_credentials.ts b/functions/api/v1/accounts/verify_credentials.ts index 84d06f784..36423fb19 100644 --- a/functions/api/v1/accounts/verify_credentials.ts +++ b/functions/api/v1/accounts/verify_credentials.ts @@ -14,8 +14,8 @@ export const onRequest: PagesFunction = async ({ data, en } const user = await loadLocalMastodonAccount(await getDatabase(env), data.connectedActor) + // prettier-ignore const res: CredentialAccount = { - ...user, source: { note: user.note, fields: user.fields, @@ -34,11 +34,14 @@ export const onRequest: PagesFunction = async ({ data, en created_at: '2022-09-08T22:48:07.983Z', updated_at: '2022-09-08T22:48:07.983Z', }, + ...user } + // prettier-ignore + const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v1/apps.ts b/functions/api/v1/apps.ts index ef19120d4..90d8b14cf 100644 --- a/functions/api/v1/apps.ts +++ b/functions/api/v1/apps.ts @@ -69,9 +69,10 @@ export async function handleRequest(db: Database, request: Request, vapidKeys: J // FIXME: stub value id: '20', } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v1/apps/verify_credentials.ts b/functions/api/v1/apps/verify_credentials.ts index e229d5678..bddfd04f7 100644 --- a/functions/api/v1/apps/verify_credentials.ts +++ b/functions/api/v1/apps/verify_credentials.ts @@ -16,9 +16,10 @@ export type CredentialApp = { vapid_key: string } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, env }) => { diff --git a/functions/api/v1/custom_emojis.ts b/functions/api/v1/custom_emojis.ts index 2ca9bc4f1..7c8629661 100644 --- a/functions/api/v1/custom_emojis.ts +++ b/functions/api/v1/custom_emojis.ts @@ -1,10 +1,11 @@ import { cors } from 'wildebeest/backend/src/utils/cors' export const onRequest = async () => { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', 'cache-control': 'max-age=300, public', + ...cors() } const res: any = [] return new Response(JSON.stringify(res), { headers }) diff --git a/functions/api/v1/instance.ts b/functions/api/v1/instance.ts index d1ab41b79..b04dd6c45 100644 --- a/functions/api/v1/instance.ts +++ b/functions/api/v1/instance.ts @@ -9,9 +9,10 @@ export const onRequest: PagesFunction = async ({ env, request }) => { } export async function handleRequest(domain: string, env: Env) { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const res: any = {} diff --git a/functions/api/v1/instance/peers.ts b/functions/api/v1/instance/peers.ts index d087d765d..0c6c0c185 100644 --- a/functions/api/v1/instance/peers.ts +++ b/functions/api/v1/instance/peers.ts @@ -8,9 +8,10 @@ export const onRequest: PagesFunction = async ({ env }) => { } export async function handleRequest(db: Database): Promise { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const peers = await getPeers(db) return new Response(JSON.stringify(peers), { headers }) diff --git a/functions/api/v1/notifications.ts b/functions/api/v1/notifications.ts index f4422df28..557f2291e 100644 --- a/functions/api/v1/notifications.ts +++ b/functions/api/v1/notifications.ts @@ -11,9 +11,10 @@ export const onRequest: PagesFunction = async ({ request, return handleRequest(request, cacheFromEnv(env), data.connectedActor) } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export async function handleRequest(request: Request, cache: Cache, connectedActor: Person): Promise { diff --git a/functions/api/v1/push/subscription.ts b/functions/api/v1/push/subscription.ts index c5ba3572b..0148d5c13 100644 --- a/functions/api/v1/push/subscription.ts +++ b/functions/api/v1/push/subscription.ts @@ -19,9 +19,10 @@ export const onRequestPost: PagesFunction = async ({ requ return handlePostRequest(await getDatabase(env), request, data.connectedActor, data.clientId, getVAPIDKeys(env)) } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export async function handleGetRequest( diff --git a/functions/api/v1/statuses.ts b/functions/api/v1/statuses.ts index 15c0207b0..c762ced5f 100644 --- a/functions/api/v1/statuses.ts +++ b/functions/api/v1/statuses.ts @@ -56,9 +56,10 @@ export async function handleRequest( } const domain = new URL(request.url).hostname + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const idempotencyKey = request.headers.get('Idempotency-Key') diff --git a/functions/api/v1/statuses/[id].ts b/functions/api/v1/statuses/[id].ts index 71949787b..8cdbb5ff5 100644 --- a/functions/api/v1/statuses/[id].ts +++ b/functions/api/v1/statuses/[id].ts @@ -55,9 +55,10 @@ export async function handleRequestGet( } */ + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(status), { headers }) } @@ -92,9 +93,10 @@ export async function handleRequestDelete( await timeline.pregenerateTimelines(domain, db, cache, connectedActor) + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(status), { headers }) } diff --git a/functions/api/v1/statuses/[id]/context.ts b/functions/api/v1/statuses/[id]/context.ts index e5af0dfd6..919333c61 100644 --- a/functions/api/v1/statuses/[id]/context.ts +++ b/functions/api/v1/statuses/[id]/context.ts @@ -13,9 +13,10 @@ export const onRequest: PagesFunction = async ({ request, return handleRequest(domain, await getDatabase(env), params.id as string) } +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export async function handleRequest(domain: string, db: Database, id: string): Promise { diff --git a/functions/api/v1/statuses/[id]/favourite.ts b/functions/api/v1/statuses/[id]/favourite.ts index 10c908047..9466cbb74 100644 --- a/functions/api/v1/statuses/[id]/favourite.ts +++ b/functions/api/v1/statuses/[id]/favourite.ts @@ -52,9 +52,10 @@ export async function handleRequest( await insertLike(db, connectedActor, obj) status.favourited = true + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(status), { headers }) } diff --git a/functions/api/v1/statuses/[id]/reblog.ts b/functions/api/v1/statuses/[id]/reblog.ts index 456437ba0..1fa9b2f28 100644 --- a/functions/api/v1/statuses/[id]/reblog.ts +++ b/functions/api/v1/statuses/[id]/reblog.ts @@ -60,9 +60,10 @@ export async function handleRequest( await createReblog(db, connectedActor, obj) status.reblogged = true + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(status), { headers }) } diff --git a/functions/api/v1/tags/[tag].ts b/functions/api/v1/tags/[tag].ts index 8b71c0470..7e989fe19 100644 --- a/functions/api/v1/tags/[tag].ts +++ b/functions/api/v1/tags/[tag].ts @@ -7,9 +7,10 @@ import * as errors from 'wildebeest/backend/src/errors' import { cors } from 'wildebeest/backend/src/utils/cors' import { type Database, getDatabase } from 'wildebeest/backend/src/database' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json', + ...cors() } as const export const onRequestGet: PagesFunction = async ({ params, env, request }) => { diff --git a/functions/api/v1/timelines/home.ts b/functions/api/v1/timelines/home.ts index e3b6b698c..5dc4ce344 100644 --- a/functions/api/v1/timelines/home.ts +++ b/functions/api/v1/timelines/home.ts @@ -5,9 +5,10 @@ import type { Actor } from 'wildebeest/backend/src/activitypub/actors/' import type { Cache } from 'wildebeest/backend/src/cache' import { cacheFromEnv } from 'wildebeest/backend/src/cache' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, env, data }) => { diff --git a/functions/api/v1/timelines/public.ts b/functions/api/v1/timelines/public.ts index 2b2dc9655..42717def3 100644 --- a/functions/api/v1/timelines/public.ts +++ b/functions/api/v1/timelines/public.ts @@ -4,9 +4,10 @@ import type { ContextData } from 'wildebeest/backend/src/types/context' import { getPublicTimeline, LocalPreference } from 'wildebeest/backend/src/mastodon/timeline' import { type Database, getDatabase } from 'wildebeest/backend/src/database' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, env }) => { diff --git a/functions/api/v1/timelines/tag/[tag].ts b/functions/api/v1/timelines/tag/[tag].ts index 7105dc46d..c50031b42 100644 --- a/functions/api/v1/timelines/tag/[tag].ts +++ b/functions/api/v1/timelines/tag/[tag].ts @@ -5,9 +5,10 @@ import * as timelines from 'wildebeest/backend/src/mastodon/timeline' import { type Database, getDatabase } from 'wildebeest/backend/src/database' import { getDomain } from 'wildebeest/backend/src/utils/getDomain' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } export const onRequest: PagesFunction = async ({ request, env, params }) => { diff --git a/functions/api/v2/instance.ts b/functions/api/v2/instance.ts index adc34b247..80439d876 100644 --- a/functions/api/v2/instance.ts +++ b/functions/api/v2/instance.ts @@ -11,9 +11,10 @@ export const onRequest: PagesFunction = async ({ env, request }) => { } export async function handleRequest(domain: string, db: Database, env: Env) { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } const res: InstanceConfigV2 = { diff --git a/functions/api/v2/media.ts b/functions/api/v2/media.ts index f3c5bd5df..18944c3aa 100644 --- a/functions/api/v2/media.ts +++ b/functions/api/v2/media.ts @@ -33,7 +33,7 @@ export async function handleRequestPost( } const domain = new URL(request.url).hostname const image = await createImage(domain, db, connectedActor, properties) - console.log({ image }) + console.debug({ image }) const res: MediaAttachment = { id: image[mastodonIdSymbol]!, @@ -62,9 +62,10 @@ export async function handleRequestPost( blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}', } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v2/media/[id].ts b/functions/api/v2/media/[id].ts index 8e40cdeab..7f4da74ff 100644 --- a/functions/api/v2/media/[id].ts +++ b/functions/api/v2/media/[id].ts @@ -66,9 +66,10 @@ export async function handleRequestPut(db: Database, id: UUID, request: Request) blurhash: 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}', } + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } return new Response(JSON.stringify(res), { headers }) } diff --git a/functions/api/v2/search.ts b/functions/api/v2/search.ts index 622de8c29..1f5f74841 100644 --- a/functions/api/v2/search.ts +++ b/functions/api/v2/search.ts @@ -10,9 +10,10 @@ import { personFromRow } from 'wildebeest/backend/src/activitypub/actors' import type { Handle } from 'wildebeest/backend/src/utils/parse' import { type Database, getDatabase } from 'wildebeest/backend/src/database' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } type SearchResult = { diff --git a/functions/api/wb/settings/server/admins.ts b/functions/api/wb/settings/server/admins.ts new file mode 100644 index 000000000..e69de29bb diff --git a/functions/api/wb/settings/server/rules.ts b/functions/api/wb/settings/server/rules.ts new file mode 100644 index 000000000..99fc67912 --- /dev/null +++ b/functions/api/wb/settings/server/rules.ts @@ -0,0 +1,80 @@ +import type { Env } from 'wildebeest/backend/src/types/env' +import type { ContextData } from 'wildebeest/backend/src/types/context' +import * as errors from 'wildebeest/backend/src/errors' +import { type Database, getDatabase } from 'wildebeest/backend/src/database' +import { parse } from 'cookie' +import { isUserAdmin } from 'wildebeest/backend/src/utils/auth/isUserAdmin' + +export const onRequestGet: PagesFunction = async ({ env, request }) => { + return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD) +} + +export async function handleRequestGet(db: Database) { + const query = `SELECT * from server_rules;` + const result = await db.prepare(query).all<{ id: string; text: string }>() + + if (!result.success) { + return new Response('SQL error: ' + result.error, { status: 500 }) + } + + return new Response(JSON.stringify(result.results ?? []), { status: 200 }) +} + +export const onRequestPost: PagesFunction = async ({ env, request }) => { + return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD) +} + +export async function handleRequestPost(db: Database, request: Request, accessAuthDomain: string, accessAud: string) { + const cookie = parse(request.headers.get('Cookie') || '') + const jwt = cookie['CF_Authorization'] + const isAdmin = await isUserAdmin(request, jwt, accessAuthDomain, accessAud, db) + + if (!isAdmin) { + return errors.notAuthorized('Lacking authorization rights to edit server rules') + } + + const rule = await request.json<{ id?: number; text: string }>() + const result = await upsertRule(db, rule) + + if (!result.success) { + return new Response('SQL error: ' + result.error, { status: 500 }) + } + + return new Response('', { status: 200 }) +} + +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 handleRequestDelete(db: Database, request: Request, accessAuthDomain: string, accessAud: string) { + const cookie = parse(request.headers.get('Cookie') || '') + const jwt = cookie['CF_Authorization'] + const isAdmin = await isUserAdmin(request, jwt, accessAuthDomain, accessAud, db) + + if (!isAdmin) { + return errors.notAuthorized('Lacking authorization rights to edit server rules') + } + + const rule = await request.json<{ id: number }>() + const result = await deleteRule(db, rule.id) + + if (!result.success) { + return new Response('SQL error: ' + result.error, { status: 500 }) + } + + return new Response('', { status: 200 }) +} + +export async function deleteRule(db: Database, ruleId: number) { + return await db.prepare('DELETE FROM server_rules WHERE id=?').bind(ruleId).run() +} diff --git a/functions/api/wb/settings/server/server.ts b/functions/api/wb/settings/server/server.ts new file mode 100644 index 000000000..3b672c5f7 --- /dev/null +++ b/functions/api/wb/settings/server/server.ts @@ -0,0 +1,74 @@ +import type { Env } from 'wildebeest/backend/src/types/env' +import type { ContextData } from 'wildebeest/backend/src/types/context' +import * as errors from 'wildebeest/backend/src/errors' +import { type Database, getDatabase } from 'wildebeest/backend/src/database' +import { parse } from 'cookie' +import { ServerSettingsData } from 'wildebeest/frontend/src/routes/(admin)/settings/(admin)/server-settings/layout' +import { isUserAdmin } from 'wildebeest/backend/src/utils/auth/isUserAdmin' + +export const onRequestGet: PagesFunction = async ({ env, request }) => { + return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD) +} + +export async function handleRequestGet(db: Database) { + const query = `SELECT * from server_settings` + const result = await db.prepare(query).all<{ setting_name: string; setting_value: string }>() + + // prettier-ignore + const data = (result.results ?? []).reduce( + (settings, { setting_name, setting_value }) => ({ + [setting_name]: setting_value, + ...settings + }), + {} as Object + ) + + if (!result.success) { + return new Response('SQL error: ' + result.error, { status: 500 }) + } + + return new Response(JSON.stringify(data), { status: 200 }) +} + +export const onRequestPost: PagesFunction = async ({ env, request }) => { + return handleRequestPost(await getDatabase(env), request, env.ACCESS_AUTH_DOMAIN, env.ACCESS_AUD) +} + +export async function handleRequestPost(db: Database, request: Request, accessAuthDomain: string, accessAud: string) { + const cookie = parse(request.headers.get('Cookie') || '') + const jwt = cookie['CF_Authorization'] + const isAdmin = await isUserAdmin(request, jwt, accessAuthDomain, accessAud, db) + + if (!isAdmin) { + return errors.notAuthorized('Lacking authorization rights to edit server settings') + } + + const data = await request.json() + + const result = await upsertServerSettings(db, data) + + if (result && !result.success) { + return new Response('SQL error: ' + result.error, { status: 500 }) + } + + 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/functions/first-login.ts b/functions/first-login.ts index 1a9c9e29a..abccf82de 100644 --- a/functions/first-login.ts +++ b/functions/first-login.ts @@ -42,11 +42,11 @@ export async function handlePostRequest( const properties: Record = {} if (formData.has('username')) { - properties.preferredUsername = (formData.get('username') as string) || '' + properties.preferredUsername = formData.get('username') || '' } if (formData.has('name')) { - properties.name = (formData.get('name') as string) || '' + properties.name = formData.get('name') || '' } await createPerson(domain, db, userKEK, email, properties) diff --git a/functions/nodeinfo/2.0.ts b/functions/nodeinfo/2.0.ts index afcb1efe9..01d6fe4ec 100644 --- a/functions/nodeinfo/2.0.ts +++ b/functions/nodeinfo/2.0.ts @@ -2,10 +2,11 @@ import type { Env } from 'wildebeest/backend/src/types/env' import { WILDEBEEST_VERSION } from 'wildebeest/config/versions' import { cors } from 'wildebeest/backend/src/utils/cors' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json', 'cache-control': 'max-age=259200, public', + ...cors() } export const onRequest: PagesFunction = async () => { diff --git a/functions/nodeinfo/2.1.ts b/functions/nodeinfo/2.1.ts index 16cc798b8..3cc8fbf3f 100644 --- a/functions/nodeinfo/2.1.ts +++ b/functions/nodeinfo/2.1.ts @@ -2,10 +2,11 @@ import type { Env } from 'wildebeest/backend/src/types/env' import { cors } from 'wildebeest/backend/src/utils/cors' import { WILDEBEEST_VERSION } from 'wildebeest/config/versions' +// prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json', 'cache-control': 'max-age=259200, public', + ...cors() } export const onRequest: PagesFunction = async () => { diff --git a/functions/oauth/authorize.ts b/functions/oauth/authorize.ts index 612d8560b..04eaed4c3 100644 --- a/functions/oauth/authorize.ts +++ b/functions/oauth/authorize.ts @@ -72,9 +72,10 @@ export async function handleRequestPost( accessAud: string ): Promise { if (request.method === 'OPTIONS') { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json', + ...cors() } return new Response('', { headers }) } diff --git a/functions/oauth/token.ts b/functions/oauth/token.ts index ffc7478fe..a46d27432 100644 --- a/functions/oauth/token.ts +++ b/functions/oauth/token.ts @@ -16,9 +16,10 @@ export const onRequest: PagesFunction = async ({ request, env }) => { } export async function handleRequest(db: Database, request: Request): Promise { + // prettier-ignore const headers = { - ...cors(), 'content-type': 'application/json; charset=utf-8', + ...cors() } if (request.method === 'OPTIONS') { diff --git a/package.json b/package.json index 2d2bab733..136d52778 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "http-message-signatures": "^0.1.2", "toucan-js": "^3.1.0" }, - "simple-git-hooks": { - "pre-commit": "yarn lint" - } -} + "simple-git-hooks": { + "pre-commit": "yarn lint" + } +} \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index 26b0d1249..20a8b7b0b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -19,14 +19,14 @@ const config: PlaywrightTestConfig = { * Maximum time expect() should wait for the condition to be met. * For example in `await expect(locator).toHaveText();` */ - timeout: (process.env.CI ? 30 : 5) * 1000, + timeout: process.env.CI ? 5000 : 500, }, /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ - retries: process.env.CI ? 1 : 0, + retries: process.env.CI ? 3 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ @@ -34,7 +34,7 @@ const config: PlaywrightTestConfig = { /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: (process.env.CI ? 30 : 10) * 1000, + actionTimeout: 0, /* Base URL to use in actions like `await page.goto('/')`. */ // baseURL: 'http://localhost:3000', @@ -43,25 +43,26 @@ const config: PlaywrightTestConfig = { }, /* Configure projects for major browsers */ + // prettier-ignore projects: [ { name: 'chromium', use: { - ...devices['Desktop Chrome'], + ...devices['Desktop Chrome'] }, }, { name: 'firefox', use: { - ...devices['Desktop Firefox'], + ...devices['Desktop Firefox'] }, }, { name: 'webkit', use: { - ...devices['Desktop Safari'], + ...devices['Desktop Safari'] }, }, @@ -69,13 +70,13 @@ const config: PlaywrightTestConfig = { { name: 'Mobile Chrome', use: { - ...devices['Pixel 5'], + ...devices['Pixel 5'] }, }, { name: 'Mobile Safari', use: { - ...devices['iPhone 12'], + ...devices['iPhone 12'] }, },