2024-11-01 20:42:32 +01:00
import { sentry } from "@/sentry" ;
import { type Logger , getLogger } from "@logtape/logtape" ;
import {
EntityValidator ,
RequestParserHandler ,
SignatureValidator ,
} from "@versia/federation" ;
import type {
Entity ,
Delete as VersiaDelete ,
Follow as VersiaFollow ,
FollowAccept as VersiaFollowAccept ,
FollowReject as VersiaFollowReject ,
LikeExtension as VersiaLikeExtension ,
Note as VersiaNote ,
User as VersiaUser ,
} from "@versia/federation/types" ;
2024-12-09 13:50:46 +01:00
import { Instance , Like , Note , Relationship , User } from "@versia/kit/db" ;
2024-11-04 10:43:30 +01:00
import { Likes , Notes } from "@versia/kit/tables" ;
2024-11-01 20:42:32 +01:00
import type { SocketAddress } from "bun" ;
2024-11-24 22:17:45 +01:00
import chalk from "chalk" ;
2024-11-01 20:42:32 +01:00
import { eq } from "drizzle-orm" ;
import { matches } from "ip-matching" ;
2025-03-30 21:13:47 +02:00
import { isValidationError } from "zod-validation-error" ;
2025-02-15 02:47:29 +01:00
import { config } from "~/config.ts" ;
2025-03-30 21:13:47 +02:00
import { ApiError } from "../errors/api-error.ts" ;
2024-11-01 20:42:32 +01:00
/ * *
* Checks if the hostname is defederated using glob matching .
* @param { string } hostname - The hostname to check . Can contain glob patterns .
* @returns { boolean } - True if defederated , false otherwise .
* /
function isDefederated ( hostname : string ) : boolean {
const pattern = new Bun . Glob ( hostname ) ;
return (
config . federation . blocked . find (
2025-02-01 16:32:18 +01:00
( blocked ) = > pattern . match ( blocked . toString ( ) ) !== null ,
2024-11-01 20:42:32 +01:00
) !== undefined
) ;
}
/ * *
* Processes incoming federation inbox messages .
*
* @example
* ` ` ` typescript
* const processor = new InboxProcessor ( context , body , sender , headers ) ;
*
2025-03-30 21:13:47 +02:00
* await processor . process ( ) ;
2024-11-01 20:42:32 +01:00
* ` ` `
* /
export class InboxProcessor {
/ * *
* Creates a new InboxProcessor instance .
*
2024-11-24 21:35:59 +01:00
* @param request Request object .
2024-11-01 20:42:32 +01:00
* @param body Entity JSON body .
2025-02-17 13:07:43 +01:00
* @param sender Sender of the request ' s instance and key ( from Versia - Signed - By header ) . Null if request is from a bridge .
2024-11-01 20:42:32 +01:00
* @param headers Various request headers .
* @param logger LogTape logger instance .
* @param requestIp Request IP address . Grabs it from the Hono context if not provided .
* /
2024-11-01 21:20:12 +01:00
public constructor (
2024-11-24 21:35:59 +01:00
private request : {
2025-02-01 16:32:18 +01:00
url : URL ;
2024-11-24 21:35:59 +01:00
method : string ;
body : string ;
} ,
2024-11-01 20:42:32 +01:00
private body : Entity ,
2024-11-24 23:01:47 +01:00
private sender : {
instance : Instance ;
key : string ;
} | null ,
2024-11-01 20:42:32 +01:00
private headers : {
2024-11-23 23:02:18 +01:00
signature? : string ;
2025-02-17 13:07:43 +01:00
signedAt? : Date ;
2024-11-01 20:42:32 +01:00
authorization? : string ;
} ,
private logger : Logger = getLogger ( [ "federation" , "inbox" ] ) ,
2024-11-24 21:35:59 +01:00
private requestIp : SocketAddress | null = null ,
2024-11-01 20:42:32 +01:00
) { }
/ * *
* Verifies the request signature .
*
* @returns { Promise < boolean > } - Whether the signature is valid .
* /
private async isSignatureValid ( ) : Promise < boolean > {
2024-11-24 23:01:47 +01:00
if ( ! this . sender ) {
throw new Error ( "Sender is not defined" ) ;
2024-11-23 23:02:18 +01:00
}
2025-02-15 02:47:29 +01:00
if ( config . debug ? . federation ) {
2024-11-24 22:17:45 +01:00
this . logger . debug ` Sender public key: ${ chalk . gray (
2024-11-24 23:01:47 +01:00
this . sender . key ,
2024-11-24 22:17:45 +01:00
) } ` ;
2024-11-01 20:42:32 +01:00
}
const validator = await SignatureValidator . fromStringKey (
2024-11-24 23:01:47 +01:00
this . sender . key ,
2024-11-01 20:42:32 +01:00
) ;
2025-02-17 13:07:43 +01:00
if ( ! ( this . headers . signature && this . headers . signedAt ) ) {
throw new Error ( "Missing signature or signature timestamp" ) ;
2024-11-23 23:02:18 +01:00
}
2024-11-01 20:42:32 +01:00
// HACK: Making a fake Request object instead of passing the values directly is necessary because otherwise the validation breaks for some unknown reason
const isValid = await validator . validate (
2024-11-24 21:35:59 +01:00
new Request ( this . request . url , {
method : this.request.method ,
2024-11-01 20:42:32 +01:00
headers : {
2025-02-17 13:07:43 +01:00
"Versia-Signature" : this . headers . signature ,
"Versia-Signed-At" : (
this . headers . signedAt . getTime ( ) / 1000
) . toString ( ) ,
2024-11-01 20:42:32 +01:00
} ,
2024-11-24 21:35:59 +01:00
body : this.request.body ,
2024-11-01 20:42:32 +01:00
} ) ,
) ;
return isValid ;
}
/ * *
* Determines if signature checks can be skipped .
* Useful for requests from federation bridges .
*
2025-03-30 21:13:47 +02:00
* @returns { boolean } - Whether to skip signature checks .
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private shouldCheckSignature ( ) : boolean {
2025-02-15 02:47:29 +01:00
if ( config . federation . bridge ) {
2024-11-01 20:42:32 +01:00
const token = this . headers . authorization ? . split ( "Bearer " ) [ 1 ] ;
if ( token ) {
2025-03-30 21:13:47 +02:00
return this . isRequestFromBridge ( token ) ;
2024-11-01 20:42:32 +01:00
}
}
return true ;
}
/ * *
* Checks if a request is from a federation bridge .
*
* @param token - Authorization token to check .
2025-03-30 21:13:47 +02:00
* @returns { boolean } - Whether the request is from a federation bridge .
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private isRequestFromBridge ( token : string ) : boolean {
2025-02-15 02:47:29 +01:00
if ( ! config . federation . bridge ) {
2025-03-30 21:13:47 +02:00
throw new ApiError (
500 ,
"Bridge is not configured." ,
"Please remove the Authorization header." ,
) ;
2025-02-15 02:47:29 +01:00
}
2024-11-01 20:42:32 +01:00
if ( token !== config . federation . bridge . token ) {
2025-03-30 21:13:47 +02:00
throw new ApiError (
401 ,
"Invalid token." ,
"Please use the correct token, or remove the Authorization header." ,
) ;
2024-11-01 20:42:32 +01:00
}
2024-11-24 16:54:24 +01:00
if ( config . federation . bridge . allowed_ips . length === 0 ) {
return true ;
}
2024-11-01 20:42:32 +01:00
if ( ! this . requestIp ) {
2025-03-30 21:13:47 +02:00
throw new ApiError (
500 ,
"The request IP address could not be determined." ,
"This may be due to an incorrectly configured reverse proxy." ,
) ;
2024-11-01 20:42:32 +01:00
}
2024-11-24 16:54:24 +01:00
for ( const ip of config . federation . bridge . allowed_ips ) {
if ( matches ( ip , this . requestIp . address ) ) {
return true ;
2024-11-01 20:42:32 +01:00
}
}
2025-03-30 21:13:47 +02:00
throw new ApiError (
403 ,
"The request is not from a trusted bridge IP address." ,
"Remove the Authorization header if you are not trying to access this API as a bridge." ,
) ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Performs request processing .
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
* @throws { ApiError } - If there is an error processing the request .
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
public async process ( ) : Promise < void > {
2024-11-24 23:01:47 +01:00
! this . sender &&
2024-11-24 22:17:45 +01:00
this . logger . debug ` Processing request from potential bridge ` ;
2024-11-24 23:01:47 +01:00
if ( this . sender && isDefederated ( this . sender . instance . data . baseUrl ) ) {
2024-11-01 20:42:32 +01:00
// Return 201 to avoid
// 1. Leaking defederated instance information
// 2. Preventing the sender from thinking the message was not delivered and retrying
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
2024-11-24 22:17:45 +01:00
this . logger . debug ` Instance ${ chalk . gray (
2024-11-24 23:01:47 +01:00
this . sender ? . instance . data . baseUrl ,
2024-11-24 22:17:45 +01:00
) } is not defederated ` ;
2024-11-01 20:42:32 +01:00
const shouldCheckSignature = this . shouldCheckSignature ( ) ;
2024-11-24 22:17:45 +01:00
shouldCheckSignature
? this . logger . debug ` Checking signature `
: this . logger . debug ` Skipping signature check ` ;
2024-11-01 20:42:32 +01:00
if ( shouldCheckSignature ) {
const isValid = await this . isSignatureValid ( ) ;
if ( ! isValid ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 401 , "Signature is not valid" ) ;
2024-11-01 20:42:32 +01:00
}
}
2024-11-24 22:17:45 +01:00
shouldCheckSignature && this . logger . debug ` Signature is valid ` ;
2024-11-01 20:42:32 +01:00
const validator = new EntityValidator ( ) ;
const handler = new RequestParserHandler ( this . body , validator ) ;
try {
2025-03-30 21:13:47 +02:00
return await handler . parseBody < void > ( {
note : ( ) : Promise < void > = > this . processNote ( ) ,
follow : ( ) : Promise < void > = > this . processFollowRequest ( ) ,
followAccept : ( ) : Promise < void > = > this . processFollowAccept ( ) ,
followReject : ( ) : Promise < void > = > this . processFollowReject ( ) ,
"pub.versia:likes/Like" : ( ) : Promise < void > = >
2024-11-02 00:43:33 +01:00
this . processLikeRequest ( ) ,
2025-03-30 21:13:47 +02:00
delete : ( ) : Promise < void > = > this . processDelete ( ) ,
user : ( ) : Promise < void > = > this . processUserRequest ( ) ,
unknown : ( ) : void = > {
throw new ApiError ( 400 , "Unknown entity type" ) ;
} ,
2024-11-01 20:42:32 +01:00
} ) ;
} catch ( e ) {
return this . handleError ( e as Error ) ;
}
}
/ * *
* Handles Note entity processing .
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private async processNote ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
const note = this . body as VersiaNote ;
2025-02-01 16:32:18 +01:00
const author = await User . resolve ( new URL ( note . author ) ) ;
const instance = await Instance . resolve ( new URL ( note . uri ) ) ;
2024-12-09 13:11:23 +01:00
if ( ! instance ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Instance not found" ) ;
2024-12-09 13:11:23 +01:00
}
2024-11-01 20:42:32 +01:00
if ( ! author ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Author not found" ) ;
2024-11-01 20:42:32 +01:00
}
2024-12-09 13:11:23 +01:00
await Note . fromVersia ( note , author , instance ) ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Handles Follow entity processing .
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private async processFollowRequest ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
const follow = this . body as unknown as VersiaFollow ;
2025-02-01 16:32:18 +01:00
const author = await User . resolve ( new URL ( follow . author ) ) ;
const followee = await User . resolve ( new URL ( follow . followee ) ) ;
2024-11-01 20:42:32 +01:00
if ( ! author ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Author not found" ) ;
2024-11-01 20:42:32 +01:00
}
if ( ! followee ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Followee not found" ) ;
2024-11-01 20:42:32 +01:00
}
const foundRelationship = await Relationship . fromOwnerAndSubject (
author ,
followee ,
) ;
if ( foundRelationship . data . following ) {
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
await foundRelationship . update ( {
// If followee is not "locked" (doesn't manually approves follow requests), set following to true
following : ! followee . data . isLocked ,
requested : followee.data.isLocked ,
showingReblogs : true ,
notifying : true ,
languages : [ ] ,
} ) ;
2024-12-09 15:01:19 +01:00
await followee . notify (
2024-12-09 13:50:46 +01:00
followee . data . isLocked ? "follow_request" : "follow" ,
author ,
) ;
2024-11-01 20:42:32 +01:00
if ( ! followee . data . isLocked ) {
await followee . sendFollowAccept ( author ) ;
}
}
/ * *
* Handles FollowAccept entity processing
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private async processFollowAccept ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
const followAccept = this . body as unknown as VersiaFollowAccept ;
2025-02-01 16:32:18 +01:00
const author = await User . resolve ( new URL ( followAccept . author ) ) ;
const follower = await User . resolve ( new URL ( followAccept . follower ) ) ;
2024-11-01 20:42:32 +01:00
if ( ! author ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Author not found" ) ;
2024-11-01 20:42:32 +01:00
}
if ( ! follower ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Follower not found" ) ;
2024-11-01 20:42:32 +01:00
}
const foundRelationship = await Relationship . fromOwnerAndSubject (
follower ,
author ,
) ;
if ( ! foundRelationship . data . requested ) {
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
await foundRelationship . update ( {
requested : false ,
following : true ,
} ) ;
}
/ * *
* Handles FollowReject entity processing
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private async processFollowReject ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
const followReject = this . body as unknown as VersiaFollowReject ;
2025-02-01 16:32:18 +01:00
const author = await User . resolve ( new URL ( followReject . author ) ) ;
const follower = await User . resolve ( new URL ( followReject . follower ) ) ;
2024-11-01 20:42:32 +01:00
if ( ! author ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Author not found" ) ;
2024-11-01 20:42:32 +01:00
}
if ( ! follower ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Follower not found" ) ;
2024-11-01 20:42:32 +01:00
}
const foundRelationship = await Relationship . fromOwnerAndSubject (
follower ,
author ,
) ;
if ( ! foundRelationship . data . requested ) {
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
await foundRelationship . update ( {
requested : false ,
following : false ,
} ) ;
}
/ * *
* Handles Delete entity processing .
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
public async processDelete ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
// JS doesn't allow the use of `delete` as a variable name
const delete_ = this . body as unknown as VersiaDelete ;
const toDelete = delete_ . deleted ;
2024-11-23 23:02:18 +01:00
const author = delete_ . author
2025-02-01 16:32:18 +01:00
? await User . resolve ( new URL ( delete_ . author ) )
2024-11-23 23:02:18 +01:00
: null ;
2024-11-01 20:42:32 +01:00
switch ( delete_ . deleted_type ) {
case "Note" : {
const note = await Note . fromSql (
eq ( Notes . uri , toDelete ) ,
2024-11-23 23:02:18 +01:00
author ? eq ( Notes . authorId , author . id ) : undefined ,
2024-11-01 20:42:32 +01:00
) ;
if ( ! note ) {
2025-03-30 21:13:47 +02:00
throw new ApiError (
404 ,
"Note to delete not found or not owned by sender" ,
2024-11-01 20:42:32 +01:00
) ;
}
await note . delete ( ) ;
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
case "User" : {
2025-02-01 16:32:18 +01:00
const userToDelete = await User . resolve ( new URL ( toDelete ) ) ;
2024-11-01 20:42:32 +01:00
if ( ! userToDelete ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "User to delete not found" ) ;
2024-11-01 20:42:32 +01:00
}
2024-11-23 23:02:18 +01:00
if ( ! author || userToDelete . id === author . id ) {
await userToDelete . delete ( ) ;
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
2025-03-30 21:13:47 +02:00
throw new ApiError ( 400 , "Cannot delete other users than self" ) ;
2024-11-01 20:42:32 +01:00
}
case "pub.versia:likes/Like" : {
const like = await Like . fromSql (
eq ( Likes . uri , toDelete ) ,
2024-11-23 23:02:18 +01:00
author ? eq ( Likes . likerId , author . id ) : undefined ,
2024-11-01 20:42:32 +01:00
) ;
if ( ! like ) {
2025-03-30 21:13:47 +02:00
throw new ApiError (
404 ,
"Like not found or not owned by sender" ,
2024-11-01 20:42:32 +01:00
) ;
}
await like . delete ( ) ;
2025-03-30 21:13:47 +02:00
return ;
2024-11-01 20:42:32 +01:00
}
default : {
2025-03-30 21:13:47 +02:00
throw new ApiError (
400 ,
` Deletion of object ${ toDelete } not implemented ` ,
2024-11-01 20:42:32 +01:00
) ;
}
}
}
/ * *
* Handles Like entity processing .
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private async processLikeRequest ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
const like = this . body as unknown as VersiaLikeExtension ;
2025-02-01 16:32:18 +01:00
const author = await User . resolve ( new URL ( like . author ) ) ;
const likedNote = await Note . resolve ( new URL ( like . liked ) ) ;
2024-11-01 20:42:32 +01:00
if ( ! author ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Author not found" ) ;
2024-11-01 20:42:32 +01:00
}
if ( ! likedNote ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Liked Note not found" ) ;
2024-11-01 20:42:32 +01:00
}
await author . like ( likedNote , like . uri ) ;
}
/ * *
* Handles User entity processing ( profile edits ) .
*
2025-03-30 21:13:47 +02:00
* @returns { Promise < void > }
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private async processUserRequest ( ) : Promise < void > {
2024-11-01 20:42:32 +01:00
const user = this . body as unknown as VersiaUser ;
2025-03-30 21:02:36 +02:00
const instance = await Instance . resolve ( new URL ( user . uri ) ) ;
2024-11-01 20:42:32 +01:00
2025-03-30 21:02:36 +02:00
if ( ! instance ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 404 , "Instance not found" ) ;
2024-11-01 20:42:32 +01:00
}
2025-03-30 21:02:36 +02:00
await User . fromVersia ( user , instance ) ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Processes Errors into the appropriate HTTP response .
*
* @param { Error } e - The error object .
2025-03-30 21:13:47 +02:00
* @returns { void }
* @throws { ApiError } - The error response .
2024-11-01 20:42:32 +01:00
* /
2025-03-30 21:13:47 +02:00
private handleError ( e : Error ) : void {
2024-11-01 20:42:32 +01:00
if ( isValidationError ( e ) ) {
2025-03-30 21:13:47 +02:00
throw new ApiError ( 400 , "Failed to process request" , e . message ) ;
2024-11-01 20:42:32 +01:00
}
this . logger . error ` ${ e } ` ;
sentry ? . captureException ( e ) ;
2025-03-30 21:13:47 +02:00
throw new ApiError ( 500 , "Failed to process request" , e . message ) ;
2024-11-01 20:42:32 +01:00
}
}