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-11-04 10:43:30 +01:00
import {
2024-11-23 23:02:18 +01:00
type Instance ,
2024-11-04 10:43:30 +01:00
Like ,
Note ,
Notification ,
Relationship ,
User ,
} from "@versia/kit/db" ;
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 type { StatusCode } from "hono/utils/http-status" ;
import { matches } from "ip-matching" ;
import { type ValidationError , isValidationError } from "zod-validation-error" ;
import { config } from "~/packages/config-manager/index.ts" ;
type ResponseBody = {
message? : string ;
code : StatusCode ;
} ;
/ * *
* 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 (
( blocked ) = > pattern . match ( blocked ) !== null ,
) !== undefined
) ;
}
/ * *
* Processes incoming federation inbox messages .
*
* @example
* ` ` ` typescript
* const processor = new InboxProcessor ( context , body , sender , headers ) ;
*
* const response = await processor . process ( ) ;
*
* return response ;
* ` ` `
* /
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 .
2024-11-24 23:01:47 +01:00
* @param sender Sender of the request ' s instance and key ( from X - 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 : {
url : string ;
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 ;
nonce? : string ;
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
}
2024-11-01 20:42:32 +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
) ;
2024-11-23 23:02:18 +01:00
if ( ! ( this . headers . signature && this . headers . nonce ) ) {
throw new Error ( "Missing signature or nonce" ) ;
}
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 : {
"X-Signature" : this . headers . signature ,
"X-Nonce" : this . headers . nonce ,
} ,
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 .
*
* @returns { boolean | ResponseBody } - Whether to skip signature checks . May include a response body if there are errors .
* /
private shouldCheckSignature ( ) : boolean | ResponseBody {
if ( config . federation . bridge . enabled ) {
const token = this . headers . authorization ? . split ( "Bearer " ) [ 1 ] ;
if ( token ) {
const isBridge = this . isRequestFromBridge ( token ) ;
if ( isBridge === true ) {
return false ;
}
return isBridge ;
}
}
return true ;
}
/ * *
* Checks if a request is from a federation bridge .
*
* @param token - Authorization token to check .
* @returns
* /
private isRequestFromBridge ( token : string ) : boolean | ResponseBody {
if ( token !== config . federation . bridge . token ) {
return {
message :
"An invalid token was passed in the Authorization header. Please use the correct token, or remove the Authorization header." ,
code : 401 ,
} ;
}
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 ) {
return {
message : "The request IP address could not be determined." ,
code : 500 ,
} ;
}
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
}
}
return {
message : "The request is not from a trusted bridge IP address." ,
code : 403 ,
} ;
}
/ * *
* Performs request processing .
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - HTTP response to send back . Null if no response is needed ( no errors ) .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
public async process ( ) : Promise < Response | null > {
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
2024-11-25 23:11:17 +01:00
return null ;
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 ( ) ;
if ( shouldCheckSignature !== true && shouldCheckSignature !== false ) {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{ error : shouldCheckSignature.message } ,
2024-11-24 21:35:59 +01:00
{ status : shouldCheckSignature.code } ,
2024-11-01 20:42:32 +01:00
) ;
}
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 ) {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{ error : "Signature is not valid" } ,
2024-11-24 21:35:59 +01:00
{ status : 401 } ,
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 {
2024-11-25 23:11:17 +01:00
return await handler . parseBody < Response | null > ( {
note : ( ) : Promise < Response | null > = > this . processNote ( ) ,
follow : ( ) : Promise < Response | null > = >
this . processFollowRequest ( ) ,
followAccept : ( ) : Promise < Response | null > = >
2024-11-02 00:43:33 +01:00
this . processFollowAccept ( ) ,
2024-11-25 23:11:17 +01:00
followReject : ( ) : Promise < Response | null > = >
2024-11-02 00:43:33 +01:00
this . processFollowReject ( ) ,
2024-11-25 23:11:17 +01:00
"pub.versia:likes/Like" : ( ) : Promise < Response | null > = >
2024-11-02 00:43:33 +01:00
this . processLikeRequest ( ) ,
2024-11-25 23:11:17 +01:00
delete : ( ) : Promise < Response | null > = > this . processDelete ( ) ,
user : ( ) : Promise < Response | null > = > this . processUserRequest ( ) ,
2024-11-24 21:35:59 +01:00
unknown : ( ) : Response = >
Response . json (
{ error : "Unknown entity type" } ,
{ status : 400 } ,
) ,
2024-11-01 20:42:32 +01:00
} ) ;
} catch ( e ) {
return this . handleError ( e as Error ) ;
}
}
/ * *
* Handles Note entity processing .
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
private async processNote ( ) : Promise < Response | null > {
2024-11-01 20:42:32 +01:00
const note = this . body as VersiaNote ;
const author = await User . resolve ( note . author ) ;
if ( ! author ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Author not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
await Note . fromVersia ( note , author ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Handles Follow entity processing .
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
private async processFollowRequest ( ) : Promise < Response | null > {
2024-11-01 20:42:32 +01:00
const follow = this . body as unknown as VersiaFollow ;
const author = await User . resolve ( follow . author ) ;
const followee = await User . resolve ( follow . followee ) ;
if ( ! author ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Author not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
if ( ! followee ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Followee not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
const foundRelationship = await Relationship . fromOwnerAndSubject (
author ,
followee ,
) ;
if ( foundRelationship . data . following ) {
2024-11-25 23:11:17 +01:00
return null ;
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-11-04 10:43:30 +01:00
await Notification . insert ( {
2024-11-01 20:42:32 +01:00
accountId : author.id ,
type : followee . data . isLocked ? "follow_request" : "follow" ,
notifiedId : followee.id ,
} ) ;
if ( ! followee . data . isLocked ) {
await followee . sendFollowAccept ( author ) ;
}
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Handles FollowAccept entity processing
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
private async processFollowAccept ( ) : Promise < Response | null > {
2024-11-01 20:42:32 +01:00
const followAccept = this . body as unknown as VersiaFollowAccept ;
const author = await User . resolve ( followAccept . author ) ;
const follower = await User . resolve ( followAccept . follower ) ;
if ( ! author ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Author not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
if ( ! follower ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Follower not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
const foundRelationship = await Relationship . fromOwnerAndSubject (
follower ,
author ,
) ;
if ( ! foundRelationship . data . requested ) {
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
await foundRelationship . update ( {
requested : false ,
following : true ,
} ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Handles FollowReject entity processing
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
private async processFollowReject ( ) : Promise < Response | null > {
2024-11-01 20:42:32 +01:00
const followReject = this . body as unknown as VersiaFollowReject ;
const author = await User . resolve ( followReject . author ) ;
const follower = await User . resolve ( followReject . follower ) ;
if ( ! author ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Author not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
if ( ! follower ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Follower not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
const foundRelationship = await Relationship . fromOwnerAndSubject (
follower ,
author ,
) ;
if ( ! foundRelationship . data . requested ) {
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
await foundRelationship . update ( {
requested : false ,
following : false ,
} ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Handles Delete entity processing .
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
public async processDelete ( ) : Promise < Response | null > {
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
? await User . resolve ( delete_ . author )
: 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 ) {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{
error : "Note to delete not found or not owned by sender" ,
} ,
2024-11-24 21:35:59 +01:00
{ status : 404 } ,
2024-11-01 20:42:32 +01:00
) ;
}
await note . delete ( ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
case "User" : {
const userToDelete = await User . resolve ( toDelete ) ;
if ( ! userToDelete ) {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{ error : "User to delete not found" } ,
2024-11-24 21:35:59 +01:00
{ status : 404 } ,
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 ( ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{
error : "Cannot delete other users than self" ,
} ,
2024-11-24 21:35:59 +01:00
{ status : 400 } ,
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 ) {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{ error : "Like not found or not owned by sender" } ,
2024-11-24 21:35:59 +01:00
{ status : 404 } ,
2024-11-01 20:42:32 +01:00
) ;
}
await like . delete ( ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
default : {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{
error : ` Deletion of object ${ toDelete } not implemented ` ,
} ,
2024-11-24 21:35:59 +01:00
{ status : 400 } ,
2024-11-01 20:42:32 +01:00
) ;
}
}
}
/ * *
* Handles Like entity processing .
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
private async processLikeRequest ( ) : Promise < Response | null > {
2024-11-01 20:42:32 +01:00
const like = this . body as unknown as VersiaLikeExtension ;
const author = await User . resolve ( like . author ) ;
const likedNote = await Note . resolve ( like . liked ) ;
if ( ! author ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Author not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
if ( ! likedNote ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Liked Note not found" } ,
{ status : 404 } ,
) ;
2024-11-01 20:42:32 +01:00
}
await author . like ( likedNote , like . uri ) ;
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Handles User entity processing ( profile edits ) .
*
2024-11-25 23:11:17 +01:00
* @returns { Promise < Response | null > } - The response .
2024-11-01 20:42:32 +01:00
* /
2024-11-25 23:11:17 +01:00
private async processUserRequest ( ) : Promise < Response | null > {
2024-11-01 20:42:32 +01:00
const user = this . body as unknown as VersiaUser ;
// FIXME: Instead of refetching the remote user, we should read the incoming json and update from that
const updatedAccount = await User . saveFromRemote ( user . uri ) ;
if ( ! updatedAccount ) {
2024-11-24 21:35:59 +01:00
return Response . json (
{ error : "Failed to update user" } ,
{ status : 500 } ,
) ;
2024-11-01 20:42:32 +01:00
}
2024-11-25 23:11:17 +01:00
return null ;
2024-11-01 20:42:32 +01:00
}
/ * *
* Processes Errors into the appropriate HTTP response .
*
* @param { Error } e - The error object .
* @returns { Response } - The error response .
* /
2024-11-24 21:35:59 +01:00
private handleError ( e : Error ) : Response {
2024-11-01 20:42:32 +01:00
if ( isValidationError ( e ) ) {
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{
error : "Failed to process request" ,
error_description : ( e as ValidationError ) . message ,
} ,
2024-11-24 21:35:59 +01:00
{ status : 400 } ,
2024-11-01 20:42:32 +01:00
) ;
}
this . logger . error ` ${ e } ` ;
sentry ? . captureException ( e ) ;
2024-11-24 21:35:59 +01:00
return Response . json (
2024-11-01 20:42:32 +01:00
{
error : "Failed to process request" ,
message : ( e as Error ) . message ,
} ,
2024-11-24 21:35:59 +01:00
{ status : 500 } ,
2024-11-01 20:42:32 +01:00
) ;
}
}