2024-05-29 02:59:49 +02:00
import { idValidator } from "@/api" ;
import { getBestContentType , urlToContentFormat } from "@/content_types" ;
2024-06-13 07:38:26 +02:00
import { randomString } from "@/math" ;
2024-05-29 02:59:49 +02:00
import { proxyUrl } from "@/response" ;
2024-07-26 18:51:39 +02:00
import { sentry } from "@/sentry" ;
import { getLogger } from "@logtape/logtape" ;
2024-06-29 08:36:15 +02:00
import type {
Account as ApiAccount ,
Mention as ApiMention ,
2024-08-26 19:40:15 +02:00
} from "@versia/client/types" ;
2024-07-26 18:51:39 +02:00
import {
EntityValidator ,
FederationRequester ,
type HttpVerb ,
SignatureConstructor ,
2024-08-26 19:06:49 +02:00
} from "@versia/federation" ;
2024-08-27 15:50:14 +02:00
import type {
Collection ,
Unfollow ,
User as VersiaUser ,
} from "@versia/federation/types" ;
2024-11-04 10:43:30 +01:00
import { Notification , db } from "@versia/kit/db" ;
2024-11-01 21:05:54 +01:00
import {
EmojiToUser ,
Likes ,
NoteToMentions ,
Notes ,
Notifications ,
type RolePermissions ,
UserToPinnedNotes ,
Users ,
} from "@versia/kit/tables" ;
2024-07-26 18:51:39 +02:00
import chalk from "chalk" ;
2024-05-06 09:16:33 +02:00
import {
2024-06-13 04:26:43 +02:00
type InferInsertModel ,
2024-05-06 09:16:33 +02:00
type SQL ,
and ,
countDistinct ,
desc ,
eq ,
gte ,
inArray ,
2024-06-06 07:12:23 +02:00
isNotNull ,
2024-05-06 09:16:33 +02:00
isNull ,
2024-06-06 06:58:28 +02:00
sql ,
2024-05-06 09:16:33 +02:00
} from "drizzle-orm" ;
2024-04-25 05:40:27 +02:00
import { htmlToText } from "html-to-text" ;
2024-08-27 17:40:58 +02:00
import { z } from "zod" ;
2024-04-25 05:40:27 +02:00
import {
type UserWithRelations ,
findManyUsers ,
2024-11-01 20:42:32 +01:00
followAcceptToVersia ,
followRejectToVersia ,
2024-08-19 15:16:01 +02:00
followRequestToVersia ,
2024-06-29 05:50:56 +02:00
} from "~/classes/functions/user" ;
2024-06-29 11:40:44 +02:00
import { searchManager } from "~/classes/search/search-manager" ;
2024-05-29 02:59:49 +02:00
import { type Config , config } from "~/packages/config-manager" ;
2024-08-26 19:06:49 +02:00
import type { KnownEntity } from "~/types/api.ts" ;
2024-10-04 15:22:48 +02:00
import { BaseInterface } from "./base.ts" ;
import { Emoji } from "./emoji.ts" ;
import { Instance } from "./instance.ts" ;
2024-10-24 17:20:00 +02:00
import { Like } from "./like.ts" ;
2024-10-04 15:22:48 +02:00
import type { Note } from "./note.ts" ;
import { Relationship } from "./relationship.ts" ;
import { Role } from "./role.ts" ;
2024-04-25 05:40:27 +02:00
/ * *
* Gives helpers to fetch users from database in a nice format
* /
2024-06-13 02:45:07 +02:00
export class User extends BaseInterface < typeof Users , UserWithRelations > {
2024-11-01 21:20:12 +01:00
public static schema : z.ZodType < ApiAccount > = z . object ( {
2024-08-27 17:40:58 +02:00
id : z.string ( ) ,
username : z.string ( ) ,
acct : z.string ( ) ,
display_name : z.string ( ) ,
locked : z.boolean ( ) ,
discoverable : z.boolean ( ) . optional ( ) ,
group : z.boolean ( ) . nullable ( ) ,
noindex : z.boolean ( ) . nullable ( ) ,
suspended : z.boolean ( ) . nullable ( ) ,
limited : z.boolean ( ) . nullable ( ) ,
created_at : z.string ( ) ,
followers_count : z.number ( ) ,
following_count : z.number ( ) ,
statuses_count : z.number ( ) ,
note : z.string ( ) ,
uri : z.string ( ) ,
url : z.string ( ) ,
avatar : z.string ( ) ,
avatar_static : z.string ( ) ,
header : z.string ( ) ,
header_static : z.string ( ) ,
emojis : z.array ( Emoji . schema ) ,
fields : z.array (
z . object ( {
name : z.string ( ) ,
value : z.string ( ) ,
2024-08-27 18:55:02 +02:00
verified : z.boolean ( ) . optional ( ) ,
2024-08-27 17:40:58 +02:00
verified_at : z.string ( ) . nullable ( ) . optional ( ) ,
} ) ,
) ,
// FIXME: Use a proper type
2024-08-27 18:55:02 +02:00
moved : z.lazy ( ( ) = > User . schema ) . nullable ( ) ,
2024-08-27 17:40:58 +02:00
bot : z.boolean ( ) . nullable ( ) ,
source : z
. object ( {
privacy : z.string ( ) . nullable ( ) ,
sensitive : z.boolean ( ) . nullable ( ) ,
language : z.string ( ) . nullable ( ) ,
note : z.string ( ) ,
2024-08-27 18:55:02 +02:00
fields : z.array (
z . object ( {
name : z.string ( ) ,
value : z.string ( ) ,
} ) ,
) ,
2024-08-27 17:40:58 +02:00
} )
. optional ( ) ,
role : z
. object ( {
name : z.string ( ) ,
} )
. optional ( ) ,
roles : z.array ( Role . schema ) ,
mute_expires_at : z.string ( ) . optional ( ) ,
} ) ;
2024-11-01 21:20:12 +01:00
public async reload ( ) : Promise < void > {
2024-06-13 02:45:07 +02:00
const reloaded = await User . fromId ( this . data . id ) ;
if ( ! reloaded ) {
throw new Error ( "Failed to reload user" ) ;
}
this . data = reloaded . data ;
}
2024-04-25 05:40:27 +02:00
2024-11-01 21:20:12 +01:00
public static async fromId ( id : string | null ) : Promise < User | null > {
2024-06-13 04:26:43 +02:00
if ( ! id ) {
return null ;
}
2024-04-25 05:40:27 +02:00
return await User . fromSql ( eq ( Users . id , id ) ) ;
}
2024-11-01 21:20:12 +01:00
public static async fromIds ( ids : string [ ] ) : Promise < User [ ] > {
2024-04-25 05:40:27 +02:00
return await User . manyFromSql ( inArray ( Users . id , ids ) ) ;
}
2024-11-01 21:20:12 +01:00
public static async fromSql (
2024-04-25 05:40:27 +02:00
sql : SQL < unknown > | undefined ,
orderBy : SQL < unknown > | undefined = desc ( Users . id ) ,
2024-11-02 00:43:33 +01:00
) : Promise < User | null > {
2024-06-13 08:34:17 +02:00
const found = await findManyUsers ( {
2024-04-25 05:40:27 +02:00
where : sql ,
orderBy ,
} ) ;
2024-06-13 08:34:17 +02:00
if ( ! found [ 0 ] ) {
2024-06-13 04:26:43 +02:00
return null ;
}
2024-06-13 08:34:17 +02:00
return new User ( found [ 0 ] ) ;
2024-04-25 05:40:27 +02:00
}
2024-11-01 21:20:12 +01:00
public static async manyFromSql (
2024-04-25 05:40:27 +02:00
sql : SQL < unknown > | undefined ,
orderBy : SQL < unknown > | undefined = desc ( Users . id ) ,
limit? : number ,
offset? : number ,
extra? : Parameters < typeof db.query.Users.findMany > [ 0 ] ,
2024-11-02 00:43:33 +01:00
) : Promise < User [ ] > {
2024-04-25 05:40:27 +02:00
const found = await findManyUsers ( {
where : sql ,
orderBy ,
limit ,
offset ,
with : extra ? . with ,
} ) ;
return found . map ( ( s ) = > new User ( s ) ) ;
}
2024-11-02 00:43:33 +01:00
public get id ( ) : string {
2024-06-13 02:45:07 +02:00
return this . data . id ;
2024-04-25 05:40:27 +02:00
}
2024-11-02 00:43:33 +01:00
public isLocal ( ) : boolean {
2024-06-13 02:45:07 +02:00
return this . data . instanceId === null ;
2024-04-25 05:40:27 +02:00
}
2024-11-02 00:43:33 +01:00
public isRemote ( ) : boolean {
2024-04-25 05:40:27 +02:00
return ! this . isLocal ( ) ;
}
2024-11-02 00:43:33 +01:00
public getUri ( ) : string {
2024-04-25 05:40:27 +02:00
return (
2024-06-13 02:45:07 +02:00
this . data . uri ||
new URL ( ` /users/ ${ this . data . id } ` , config . http . base_url ) . toString ( )
2024-04-25 05:40:27 +02:00
) ;
}
2024-11-02 00:43:33 +01:00
public static getUri (
id : string ,
uri : string | null ,
baseUrl : string ,
) : string {
2024-04-25 05:40:27 +02:00
return uri || new URL ( ` /users/ ${ id } ` , baseUrl ) . toString ( ) ;
}
2024-11-02 00:43:33 +01:00
public hasPermission ( permission : RolePermissions ) : boolean {
2024-06-08 06:57:29 +02:00
return this . getAllPermissions ( ) . includes ( permission ) ;
}
2024-11-02 00:43:33 +01:00
public getAllPermissions ( ) : RolePermissions [ ] {
2024-06-08 06:57:29 +02:00
return (
2024-06-13 02:45:07 +02:00
this . data . roles
2024-06-08 06:57:29 +02:00
. flatMap ( ( role ) = > role . permissions )
// Add default permissions
. concat ( config . permissions . default )
// If admin, add admin permissions
2024-06-13 02:45:07 +02:00
. concat ( this . data . isAdmin ? config . permissions . admin : [ ] )
2024-06-08 06:57:29 +02:00
. reduce ( ( acc , permission ) = > {
2024-06-13 04:26:43 +02:00
if ( ! acc . includes ( permission ) ) {
acc . push ( permission ) ;
}
2024-06-08 06:57:29 +02:00
return acc ;
} , [ ] as RolePermissions [ ] )
) ;
}
2024-07-27 20:46:19 +02:00
public async followRequest (
otherUser : User ,
options ? : {
reblogs? : boolean ;
notify? : boolean ;
languages? : string [ ] ;
} ,
) : Promise < Relationship > {
const foundRelationship = await Relationship . fromOwnerAndSubject (
this ,
otherUser ,
) ;
await foundRelationship . update ( {
following : otherUser.isRemote ( ) ? false : ! otherUser . data . isLocked ,
requested : otherUser.isRemote ( ) ? true : otherUser . data . isLocked ,
showingReblogs : options?.reblogs ,
notifying : options?.notify ,
languages : options?.languages ,
} ) ;
if ( otherUser . isRemote ( ) ) {
const { ok } = await this . federateToUser (
2024-08-19 15:16:01 +02:00
followRequestToVersia ( this , otherUser ) ,
2024-07-27 20:46:19 +02:00
otherUser ,
) ;
if ( ! ok ) {
await foundRelationship . update ( {
requested : false ,
following : false ,
} ) ;
return foundRelationship ;
}
} else {
2024-11-04 10:43:30 +01:00
await Notification . insert ( {
2024-07-27 20:46:19 +02:00
accountId : this.id ,
type : otherUser . data . isLocked ? "follow_request" : "follow" ,
notifiedId : otherUser.id ,
} ) ;
}
return foundRelationship ;
}
2024-11-02 00:43:33 +01:00
public async unfollow (
followee : User ,
relationship : Relationship ,
) : Promise < boolean > {
2024-08-02 17:28:50 +02:00
if ( followee . isRemote ( ) ) {
// TODO: This should reschedule for a later time and maybe notify the server admin if it fails too often
const { ok } = await this . federateToUser (
2024-08-27 15:50:14 +02:00
this . unfollowToVersia ( followee ) ,
2024-08-02 17:28:50 +02:00
followee ,
) ;
if ( ! ok ) {
return false ;
}
} else if ( ! this . data . isLocked ) {
if ( relationship . data . following ) {
2024-11-04 10:43:30 +01:00
await Notification . insert ( {
2024-08-02 17:28:50 +02:00
accountId : followee.id ,
type : "unfollow" ,
notifiedId : this.id ,
} ) ;
} else {
2024-11-04 10:43:30 +01:00
await Notification . insert ( {
2024-08-02 17:28:50 +02:00
accountId : followee.id ,
type : "cancel-follow" ,
notifiedId : this.id ,
} ) ;
}
}
await relationship . update ( {
following : false ,
} ) ;
return true ;
}
2024-08-27 15:50:14 +02:00
private unfollowToVersia ( followee : User ) : Unfollow {
const id = crypto . randomUUID ( ) ;
return {
type : "Unfollow" ,
id ,
author : this.getUri ( ) ,
created_at : new Date ( ) . toISOString ( ) ,
followee : followee.getUri ( ) ,
} ;
}
2024-11-01 20:42:32 +01:00
public async sendFollowAccept ( follower : User ) : Promise < void > {
await this . federateToUser (
followAcceptToVersia ( follower , this ) ,
follower ,
) ;
}
public async sendFollowReject ( follower : User ) : Promise < void > {
await this . federateToUser (
followRejectToVersia ( follower , this ) ,
follower ,
) ;
}
2024-11-01 21:20:12 +01:00
public static async webFinger (
2024-07-17 15:37:36 +02:00
manager : FederationRequester ,
username : string ,
2024-07-26 18:07:11 +02:00
hostname : string ,
2024-07-17 15:37:36 +02:00
) : Promise < string > {
return (
2024-07-26 18:07:11 +02:00
( await manager . webFinger ( username , hostname ) . catch ( ( ) = > null ) ) ? ?
2024-07-17 15:37:36 +02:00
( await manager . webFinger (
username ,
2024-07-26 18:07:11 +02:00
hostname ,
2024-07-17 15:37:36 +02:00
"application/activity+json" ,
) )
) ;
}
2024-11-01 21:20:12 +01:00
public static getCount ( ) : Promise < number > {
2024-10-11 15:46:05 +02:00
return db . $count ( Users , isNull ( Users . instanceId ) ) ;
2024-05-06 09:16:33 +02:00
}
2024-11-02 00:43:33 +01:00
public static async getActiveInPeriod (
milliseconds : number ,
) : Promise < number > {
2024-05-06 09:16:33 +02:00
return (
await db
. select ( {
count : countDistinct ( Users ) ,
} )
. from ( Users )
. leftJoin ( Notes , eq ( Users . id , Notes . authorId ) )
. where (
and (
isNull ( Users . instanceId ) ,
gte (
Notes . createdAt ,
new Date ( Date . now ( ) - milliseconds ) . toISOString ( ) ,
) ,
) ,
)
) [ 0 ] . count ;
}
2024-11-01 21:20:12 +01:00
public async delete ( ids? : string [ ] ) : Promise < void > {
2024-06-13 02:45:07 +02:00
if ( Array . isArray ( ids ) ) {
await db . delete ( Users ) . where ( inArray ( Users . id , ids ) ) ;
} else {
await db . delete ( Users ) . where ( eq ( Users . id , this . id ) ) ;
}
2024-05-07 09:41:02 +02:00
}
2024-11-02 00:43:33 +01:00
public async resetPassword ( ) : Promise < string > {
2024-06-13 07:38:26 +02:00
const resetToken = randomString ( 32 , "hex" ) ;
2024-05-17 10:27:41 +02:00
await this . update ( {
passwordResetToken : resetToken ,
} ) ;
return resetToken ;
}
2024-11-02 00:43:33 +01:00
public async pin ( note : Note ) : Promise < void > {
await db . insert ( UserToPinnedNotes ) . values ( {
noteId : note.id ,
userId : this.id ,
} ) ;
2024-04-25 05:40:27 +02:00
}
2024-11-02 00:43:33 +01:00
public async unpin ( note : Note ) : Promise < void > {
await db
. delete ( UserToPinnedNotes )
. where (
and (
eq ( NoteToMentions . noteId , note . id ) ,
eq ( NoteToMentions . userId , this . id ) ,
) ,
) ;
2024-04-25 05:40:27 +02:00
}
2024-11-01 21:20:12 +01:00
public save ( ) : Promise < UserWithRelations > {
2024-06-13 02:45:07 +02:00
return this . update ( this . data ) ;
2024-06-06 06:49:06 +02:00
}
2024-04-25 05:40:27 +02:00
2024-10-11 17:03:33 +02:00
public async getLinkedOidcAccounts (
providers : {
id : string ;
name : string ;
url : string ;
icon? : string ;
} [ ] ,
) : Promise <
2024-09-24 14:42:39 +02:00
{
id : string ;
name : string ;
url : string ;
icon? : string | undefined ;
server_id : string ;
} [ ]
> {
// Get all linked accounts
const accounts = await db . query . OpenIdAccounts . findMany ( {
2024-11-02 00:43:33 +01:00
where : ( User , { eq } ) : SQL | undefined = > eq ( User . userId , this . id ) ,
2024-09-24 14:42:39 +02:00
} ) ;
return accounts
. map ( ( account ) = > {
2024-10-11 17:03:33 +02:00
const issuer = providers . find (
2024-09-24 14:42:39 +02:00
( provider ) = > provider . id === account . issuerId ,
) ;
if ( ! issuer ) {
return null ;
}
return {
id : issuer.id ,
name : issuer.name ,
url : issuer.url ,
icon : proxyUrl ( issuer . icon ) || undefined ,
server_id : account.serverId ,
} ;
} )
. filter ( ( x ) = > x !== null ) ;
}
2024-10-24 17:20:00 +02:00
/ * *
* Like a note .
*
* If the note is already liked , it will return the existing like . Also creates a notification for the author of the note .
* @param note The note to like
2024-10-24 19:08:28 +02:00
* @param uri The URI of the like , if it is remote
2024-10-24 17:20:00 +02:00
* @returns The like object created or the existing like
* /
2024-10-24 19:08:28 +02:00
public async like ( note : Note , uri? : string ) : Promise < Like > {
2024-10-24 17:20:00 +02:00
// Check if the user has already liked the note
const existingLike = await Like . fromSql (
and ( eq ( Likes . likerId , this . id ) , eq ( Likes . likedId , note . id ) ) ,
) ;
if ( existingLike ) {
return existingLike ;
}
const newLike = await Like . insert ( {
likerId : this.id ,
likedId : note.id ,
2024-10-24 19:08:28 +02:00
uri ,
2024-10-24 17:20:00 +02:00
} ) ;
2024-10-24 19:08:28 +02:00
if ( this . isLocal ( ) && note . author . isLocal ( ) ) {
2024-10-24 17:20:00 +02:00
// Notify the user that their post has been favourited
2024-11-04 10:43:30 +01:00
await Notification . insert ( {
2024-10-24 17:20:00 +02:00
accountId : this.id ,
type : "favourite" ,
notifiedId : note.author.id ,
noteId : note.id ,
} ) ;
2024-10-24 19:08:28 +02:00
} else if ( this . isLocal ( ) && note . author . isRemote ( ) ) {
2024-10-24 17:31:39 +02:00
// Federate the like
this . federateToFollowers ( newLike . toVersia ( ) ) ;
2024-10-24 17:20:00 +02:00
}
return newLike ;
}
/ * *
* Unlike a note .
*
* If the note is not liked , it will return without doing anything . Also removes any notifications for this like .
* @param note The note to unlike
* @returns
* /
public async unlike ( note : Note ) : Promise < void > {
const likeToDelete = await Like . fromSql (
and ( eq ( Likes . likerId , this . id ) , eq ( Likes . likedId , note . id ) ) ,
) ;
if ( ! likeToDelete ) {
return ;
}
await likeToDelete . delete ( ) ;
2024-10-24 19:08:28 +02:00
if ( this . isLocal ( ) && note . author . isLocal ( ) ) {
// Remove any eventual notifications for this like
2024-11-04 10:43:30 +01:00
await likeToDelete . clearRelatedNotifications ( ) ;
2024-10-24 19:08:28 +02:00
} else if ( this . isLocal ( ) && note . author . isRemote ( ) ) {
2024-10-24 17:20:00 +02:00
// User is local, federate the delete
2024-10-24 17:31:39 +02:00
this . federateToFollowers ( likeToDelete . unlikeToVersia ( this ) ) ;
2024-10-24 17:20:00 +02:00
}
}
2024-11-04 10:43:30 +01:00
public async clearAllNotifications ( ) : Promise < void > {
await db
. update ( Notifications )
. set ( {
dismissed : true ,
} )
. where ( eq ( Notifications . notifiedId , this . id ) ) ;
}
public async clearSomeNotifications ( ids : string [ ] ) : Promise < void > {
await db
. update ( Notifications )
. set ( {
dismissed : true ,
} )
. where (
and (
inArray ( Notifications . id , ids ) ,
eq ( Notifications . notifiedId , this . id ) ,
) ,
) ;
}
2024-11-01 21:20:12 +01:00
public async updateFromRemote ( ) : Promise < User > {
2024-06-06 06:49:06 +02:00
if ( ! this . isRemote ( ) ) {
2024-06-06 06:58:28 +02:00
throw new Error (
"Cannot refetch a local user (they are not remote)" ,
) ;
2024-06-06 06:49:06 +02:00
}
2024-04-25 05:40:27 +02:00
2024-06-06 06:49:06 +02:00
const updated = await User . saveFromRemote ( this . getUri ( ) ) ;
2024-04-25 05:40:27 +02:00
2024-06-13 02:45:07 +02:00
this . data = updated . data ;
2024-06-06 06:49:06 +02:00
return this ;
}
2024-11-01 21:20:12 +01:00
public static async saveFromRemote ( uri : string ) : Promise < User > {
2024-04-25 05:40:27 +02:00
if ( ! URL . canParse ( uri ) ) {
2024-06-30 08:58:39 +02:00
throw new Error ( ` Invalid URI: ${ uri } ` ) ;
2024-04-25 05:40:27 +02:00
}
2024-06-30 08:58:39 +02:00
const instance = await Instance . resolve ( uri ) ;
2024-08-19 15:16:01 +02:00
if ( instance . data . protocol === "versia" ) {
return await User . saveFromVersia ( uri , instance ) ;
2024-06-30 08:58:39 +02:00
}
if ( instance . data . protocol === "activitypub" ) {
2024-07-16 23:30:52 +02:00
if ( ! config . federation . bridge . enabled ) {
throw new Error ( "ActivityPub bridge is not enabled" ) ;
}
2024-07-16 23:29:20 +02:00
const bridgeUri = new URL (
` /apbridge/lysand/query? ${ new URLSearchParams ( {
user_url : uri ,
} ) } ` ,
config . federation . bridge . url ,
) ;
2024-08-19 15:16:01 +02:00
return await User . saveFromVersia ( bridgeUri . toString ( ) , instance ) ;
2024-06-30 08:58:39 +02:00
}
throw new Error ( ` Unsupported protocol: ${ instance . data . protocol } ` ) ;
}
2024-08-19 15:16:01 +02:00
private static async saveFromVersia (
2024-06-30 08:58:39 +02:00
uri : string ,
instance : Instance ,
) : Promise < User > {
2024-08-26 19:27:40 +02:00
const requester = await User . getFederationRequester ( ) ;
2024-08-19 15:16:01 +02:00
const { data : json } = await requester . get < Partial < VersiaUser > > ( uri , {
2024-07-24 18:52:30 +02:00
// @ts-expect-error Bun extension
2024-06-26 05:13:40 +02:00
proxy : config.http.proxy.address ,
2024-04-25 05:40:27 +02:00
} ) ;
2024-06-06 06:49:06 +02:00
const validator = new EntityValidator ( ) ;
const data = await validator . User ( json ) ;
2024-04-25 05:40:27 +02:00
2024-08-19 15:16:01 +02:00
const user = await User . fromVersia ( data , instance ) ;
2024-06-30 08:58:39 +02:00
2024-04-25 05:40:27 +02:00
const userEmojis =
2024-08-26 19:06:49 +02:00
data . extensions ? . [ "pub.versia:custom_emojis" ] ? . emojis ? ? [ ] ;
2024-06-13 06:52:01 +02:00
const emojis = await Promise . all (
2024-08-19 15:16:01 +02:00
userEmojis . map ( ( emoji ) = > Emoji . fromVersia ( emoji , instance . id ) ) ,
2024-06-13 06:52:01 +02:00
) ;
2024-04-25 05:40:27 +02:00
if ( emojis . length > 0 ) {
2024-06-13 04:26:43 +02:00
await db . delete ( EmojiToUser ) . where ( eq ( EmojiToUser . userId , user . id ) ) ;
2024-04-25 05:40:27 +02:00
await db . insert ( EmojiToUser ) . values (
emojis . map ( ( emoji ) = > ( {
emojiId : emoji.id ,
2024-06-13 04:26:43 +02:00
userId : user.id ,
2024-04-25 05:40:27 +02:00
} ) ) ,
) ;
}
2024-06-13 04:26:43 +02:00
const finalUser = await User . fromId ( user . id ) ;
if ( ! finalUser ) {
throw new Error ( "Failed to save user from remote" ) ;
}
2024-04-25 05:40:27 +02:00
2024-06-29 11:40:44 +02:00
await searchManager . addUser ( finalUser ) ;
2024-04-25 05:40:27 +02:00
return finalUser ;
}
2024-11-01 21:20:12 +01:00
public static async fromVersia (
2024-08-19 15:16:01 +02:00
user : VersiaUser ,
2024-06-30 08:58:39 +02:00
instance : Instance ,
2024-06-13 04:26:43 +02:00
) : Promise < User > {
const data = {
username : user.username ,
uri : user.uri ,
createdAt : new Date ( user . created_at ) . toISOString ( ) ,
endpoints : {
2024-08-26 19:06:49 +02:00
dislikes :
user . collections [ "pub.versia:likes/Dislikes" ] ? ? undefined ,
featured : user.collections.featured ,
likes : user.collections [ "pub.versia:likes/Likes" ] ? ? undefined ,
followers : user.collections.followers ,
following : user.collections.following ,
2024-06-13 04:26:43 +02:00
inbox : user.inbox ,
2024-08-26 19:06:49 +02:00
outbox : user.collections.outbox ,
2024-06-13 04:26:43 +02:00
} ,
fields : user.fields ? ? [ ] ,
updatedAt : new Date ( user . created_at ) . toISOString ( ) ,
instanceId : instance.id ,
avatar : user.avatar
? Object . entries ( user . avatar ) [ 0 ] [ 1 ] . content
: "" ,
header : user.header
? Object . entries ( user . header ) [ 0 ] [ 1 ] . content
: "" ,
displayName : user.display_name ? ? "" ,
note : getBestContentType ( user . bio ) . content ,
2024-08-26 19:06:49 +02:00
publicKey : user.public_key.key ,
2024-06-13 04:26:43 +02:00
source : {
language : null ,
note : "" ,
privacy : "public" ,
sensitive : false ,
fields : [ ] ,
} ,
} ;
// Check if new user already exists
const foundUser = await User . fromSql ( eq ( Users . uri , user . uri ) ) ;
// If it exists, simply update it
if ( foundUser ) {
await foundUser . update ( data ) ;
return foundUser ;
}
// Else, create a new user
return await User . insert ( data ) ;
}
public static async insert (
data : InferInsertModel < typeof Users > ,
) : Promise < User > {
const inserted = ( await db . insert ( Users ) . values ( data ) . returning ( ) ) [ 0 ] ;
const user = await User . fromId ( inserted . id ) ;
if ( ! user ) {
throw new Error ( "Failed to insert user" ) ;
}
return user ;
}
2024-11-01 21:20:12 +01:00
public static async resolve ( uri : string ) : Promise < User | null > {
2024-06-06 06:49:06 +02:00
// Check if user not already in database
const foundUser = await User . fromSql ( eq ( Users . uri , uri ) ) ;
2024-06-13 04:26:43 +02:00
if ( foundUser ) {
return foundUser ;
}
2024-06-06 06:49:06 +02:00
// Check if URI is of a local user
if ( uri . startsWith ( config . http . base_url ) ) {
const uuid = uri . match ( idValidator ) ;
2024-06-13 04:26:43 +02:00
if ( ! uuid ? . [ 0 ] ) {
2024-06-06 06:49:06 +02:00
throw new Error (
` URI ${ uri } is of a local user, but it could not be parsed ` ,
) ;
}
return await User . fromId ( uuid [ 0 ] ) ;
}
return await User . saveFromRemote ( uri ) ;
}
2024-04-25 05:40:27 +02:00
/ * *
* Get the user ' s avatar in raw URL format
* @param config The config to use
* @returns The raw URL for the user ' s avatar
* /
2024-11-02 00:43:33 +01:00
public getAvatarUrl ( config : Config ) : string {
2024-06-13 04:26:43 +02:00
if ( ! this . data . avatar ) {
2024-04-25 05:40:27 +02:00
return (
config . defaults . avatar ||
2024-06-13 02:45:07 +02:00
` https://api.dicebear.com/8.x/ ${ config . defaults . placeholder_style } /svg?seed= ${ this . data . username } `
2024-04-25 05:40:27 +02:00
) ;
2024-06-13 04:26:43 +02:00
}
2024-06-13 02:45:07 +02:00
return this . data . avatar ;
2024-04-25 05:40:27 +02:00
}
2024-11-02 00:43:33 +01:00
public static async generateKeys ( ) : Promise < {
private_key : string ;
public_key : string ;
} > {
2024-04-25 05:48:39 +02:00
const keys = await crypto . subtle . generateKey ( "Ed25519" , true , [
"sign" ,
"verify" ,
] ) ;
const privateKey = Buffer . from (
await crypto . subtle . exportKey ( "pkcs8" , keys . privateKey ) ,
) . toString ( "base64" ) ;
const publicKey = Buffer . from (
await crypto . subtle . exportKey ( "spki" , keys . publicKey ) ,
) . toString ( "base64" ) ;
// Add header, footer and newlines later on
// These keys are base64 encrypted
return {
private_key : privateKey ,
public_key : publicKey ,
} ;
}
2024-11-01 21:20:12 +01:00
public static async fromDataLocal ( data : {
2024-04-25 05:48:39 +02:00
username : string ;
display_name? : string ;
2024-05-08 02:10:14 +02:00
password : string | undefined ;
email : string | undefined ;
2024-04-25 05:48:39 +02:00
bio? : string ;
avatar? : string ;
header? : string ;
admin? : boolean ;
skipPasswordHash? : boolean ;
2024-06-14 11:05:04 +02:00
} ) : Promise < User > {
2024-04-25 05:48:39 +02:00
const keys = await User . generateKeys ( ) ;
const newUser = (
await db
. insert ( Users )
. values ( {
username : data.username ,
displayName : data.display_name ? ? data . username ,
2024-05-08 02:10:14 +02:00
password :
data . skipPasswordHash || ! data . password
? data . password
: await Bun . password . hash ( data . password ) ,
2024-04-25 05:48:39 +02:00
email : data.email ,
note : data.bio ? ? "" ,
2024-05-16 04:37:25 +02:00
avatar : data.avatar ? ? config . defaults . avatar ? ? "" ,
header : data.header ? ? config . defaults . avatar ? ? "" ,
2024-04-25 05:48:39 +02:00
isAdmin : data.admin ? ? false ,
publicKey : keys.public_key ,
2024-04-25 06:37:55 +02:00
fields : [ ] ,
2024-04-25 05:48:39 +02:00
privateKey : keys.private_key ,
updatedAt : new Date ( ) . toISOString ( ) ,
source : {
language : null ,
note : "" ,
privacy : "public" ,
sensitive : false ,
fields : [ ] ,
} ,
} )
. returning ( )
) [ 0 ] ;
const finalUser = await User . fromId ( newUser . id ) ;
2024-06-13 04:26:43 +02:00
if ( ! finalUser ) {
2024-06-14 11:05:04 +02:00
throw new Error ( "Failed to create user" ) ;
2024-06-13 04:26:43 +02:00
}
2024-04-25 05:48:39 +02:00
2024-06-29 11:40:44 +02:00
// Add to search index
await searchManager . addUser ( finalUser ) ;
2024-04-25 05:48:39 +02:00
return finalUser ;
}
2024-04-25 05:40:27 +02:00
/ * *
* Get the user ' s header in raw URL format
* @param config The config to use
* @returns The raw URL for the user ' s header
* /
2024-11-02 00:43:33 +01:00
public getHeaderUrl ( config : Config ) : string {
2024-06-13 04:26:43 +02:00
if ( ! this . data . header ) {
return config . defaults . header || "" ;
}
2024-06-13 02:45:07 +02:00
return this . data . header ;
2024-04-25 05:40:27 +02:00
}
2024-11-02 00:43:33 +01:00
public getAcct ( ) : string {
2024-04-25 05:40:27 +02:00
return this . isLocal ( )
2024-06-13 02:45:07 +02:00
? this . data . username
: ` ${ this . data . username } @ ${ this . data . instance ? . baseUrl } ` ;
2024-04-25 05:40:27 +02:00
}
2024-11-01 21:20:12 +01:00
public static getAcct (
isLocal : boolean ,
username : string ,
baseUrl? : string ,
2024-11-02 00:43:33 +01:00
) : string {
2024-04-25 05:40:27 +02:00
return isLocal ? username : ` ${ username } @ ${ baseUrl } ` ;
}
2024-11-01 21:20:12 +01:00
public async update (
2024-06-13 02:45:07 +02:00
newUser : Partial < UserWithRelations > ,
) : Promise < UserWithRelations > {
await db . update ( Users ) . set ( newUser ) . where ( eq ( Users . id , this . id ) ) ;
2024-05-12 03:27:28 +02:00
2024-06-13 02:45:07 +02:00
const updated = await User . fromId ( this . data . id ) ;
2024-05-12 03:27:28 +02:00
2024-06-13 02:45:07 +02:00
if ( ! updated ) {
throw new Error ( "Failed to update user" ) ;
}
2024-05-12 03:27:28 +02:00
2024-06-06 06:58:28 +02:00
// If something important is updated, federate it
if (
2024-06-30 10:55:50 +02:00
this . isLocal ( ) &&
( newUser . username ||
newUser . displayName ||
newUser . note ||
newUser . avatar ||
newUser . header ||
newUser . fields ||
newUser . publicKey ||
newUser . isAdmin ||
newUser . isBot ||
newUser . isLocked ||
newUser . endpoints ||
newUser . isDiscoverable )
2024-06-06 06:58:28 +02:00
) {
2024-08-19 15:16:01 +02:00
await this . federateToFollowers ( this . toVersia ( ) ) ;
2024-06-06 06:58:28 +02:00
}
2024-06-13 02:45:07 +02:00
return updated . data ;
2024-05-12 03:27:28 +02:00
}
2024-06-06 07:25:49 +02:00
2024-07-26 18:51:39 +02:00
/ * *
2024-08-19 15:16:01 +02:00
* Signs a Versia entity with that user ' s private key
2024-07-26 18:51:39 +02:00
*
* @param entity Entity to sign
* @param signatureUrl URL to embed in signature ( must be the same URI of queries made with this signature )
* @param signatureMethod HTTP method to embed in signature ( default : POST )
* @returns The signed string and headers to send with the request
* /
2024-11-01 21:20:12 +01:00
public async sign (
2024-08-26 19:40:15 +02:00
entity : KnownEntity | Collection ,
2024-07-26 18:51:39 +02:00
signatureUrl : string | URL ,
signatureMethod : HttpVerb = "POST" ,
) : Promise < {
headers : Headers ;
signedString : string ;
} > {
const signatureConstructor = await SignatureConstructor . fromStringKey (
this . data . privateKey ? ? "" ,
this . getUri ( ) ,
) ;
const output = await signatureConstructor . sign (
signatureMethod ,
new URL ( signatureUrl ) ,
JSON . stringify ( entity ) ,
) ;
if ( config . debug . federation ) {
const logger = getLogger ( "federation" ) ;
// Log public key
logger . debug ` Sender public key: ${ this . data . publicKey } ` ;
// Log signed string
logger . debug ` Signed string: \ n ${ output . signedString } ` ;
}
return output ;
}
2024-08-26 19:27:40 +02:00
/ * *
* Helper to get the appropriate Versia SDK requester with the instance ' s private key
*
* @returns The requester
* /
2024-11-01 21:20:12 +01:00
public static async getFederationRequester ( ) : Promise < FederationRequester > {
2024-08-26 19:27:40 +02:00
const signatureConstructor = await SignatureConstructor . fromStringKey (
config . instance . keys . private ,
config . http . base_url ,
) ;
return new FederationRequester ( signatureConstructor ) ;
}
2024-07-26 18:51:39 +02:00
/ * *
2024-08-19 15:16:01 +02:00
* Helper to get the appropriate Versia SDK requester with this user ' s private key
2024-07-26 18:51:39 +02:00
*
* @returns The requester
* /
2024-11-01 21:20:12 +01:00
public async getFederationRequester ( ) : Promise < FederationRequester > {
2024-07-26 18:51:39 +02:00
const signatureConstructor = await SignatureConstructor . fromStringKey (
this . data . privateKey ? ? "" ,
this . getUri ( ) ,
) ;
return new FederationRequester ( signatureConstructor ) ;
}
/ * *
* Federates an entity to all followers of the user
*
* @param entity Entity to federate
* /
2024-11-01 21:20:12 +01:00
public async federateToFollowers ( entity : KnownEntity ) : Promise < void > {
2024-06-06 07:25:49 +02:00
// Get followers
const followers = await User . manyFromSql (
and (
sql ` EXISTS (SELECT 1 FROM "Relationships" WHERE "Relationships"."subjectId" = ${ this . id } AND "Relationships"."ownerId" = ${ Users . id } AND "Relationships"."following" = true) ` ,
isNotNull ( Users . instanceId ) ,
) ,
) ;
for ( const follower of followers ) {
2024-07-26 18:51:39 +02:00
await this . federateToUser ( entity , follower ) ;
}
}
/ * *
* Federates an entity to any user .
*
* @param entity Entity to federate
* @param user User to federate to
* @returns Whether the federation was successful
* /
2024-11-01 21:20:12 +01:00
public async federateToUser (
2024-08-26 19:06:49 +02:00
entity : KnownEntity ,
user : User ,
) : Promise < { ok : boolean } > {
2024-07-26 18:51:39 +02:00
const { headers } = await this . sign (
entity ,
user . data . endpoints ? . inbox ? ? "" ,
) ;
try {
await new FederationRequester ( ) . post (
user . data . endpoints ? . inbox ? ? "" ,
entity ,
{
// @ts-expect-error Bun extension
proxy : config.http.proxy.address ,
2024-07-26 20:35:26 +02:00
headers : {
. . . headers . toJSON ( ) ,
"Content-Type" : "application/json; charset=utf-8" ,
} ,
2024-07-26 18:51:39 +02:00
} ,
2024-06-06 07:25:49 +02:00
) ;
2024-07-26 18:51:39 +02:00
} catch ( e ) {
getLogger ( "federation" )
. error ` Federating ${ chalk . gray ( entity . type ) } to ${ user . getUri ( ) } ${ chalk . bold . red ( "failed" ) } ` ;
getLogger ( "federation" ) . error ` ${ e } ` ;
sentry ? . captureException ( e ) ;
2024-06-06 07:25:49 +02:00
2024-07-26 18:51:39 +02:00
return { ok : false } ;
2024-06-06 07:25:49 +02:00
}
2024-07-26 18:51:39 +02:00
return { ok : true } ;
2024-06-06 07:25:49 +02:00
}
2024-05-12 03:27:28 +02:00
2024-11-01 21:20:12 +01:00
public toApi ( isOwnAccount = false ) : ApiAccount {
2024-06-13 02:45:07 +02:00
const user = this . data ;
2024-04-25 05:40:27 +02:00
return {
id : user.id ,
username : user.username ,
display_name : user.displayName ,
note : user.note ,
2024-07-17 14:02:29 +02:00
uri : this.getUri ( ) ,
2024-04-25 05:40:27 +02:00
url :
user . uri ||
new URL ( ` /@ ${ user . username } ` , config . http . base_url ) . toString ( ) ,
2024-05-05 07:13:23 +02:00
avatar : proxyUrl ( this . getAvatarUrl ( config ) ) ? ? "" ,
header : proxyUrl ( this . getHeaderUrl ( config ) ) ? ? "" ,
2024-04-25 05:40:27 +02:00
locked : user.isLocked ,
created_at : new Date ( user . createdAt ) . toISOString ( ) ,
followers_count : user.followerCount ,
following_count : user.followingCount ,
statuses_count : user.statusCount ,
2024-06-13 06:52:01 +02:00
emojis : user.emojis.map ( ( emoji ) = > new Emoji ( emoji ) . toApi ( ) ) ,
2024-04-25 06:37:55 +02:00
fields : user.fields.map ( ( field ) = > ( {
name : htmlToText ( getBestContentType ( field . key ) . content ) ,
value : getBestContentType ( field . value ) . content ,
} ) ) ,
2024-04-25 05:40:27 +02:00
bot : user.isBot ,
source : isOwnAccount ? user.source : undefined ,
// TODO: Add static avatar and header
2024-05-05 07:13:23 +02:00
avatar_static : proxyUrl ( this . getAvatarUrl ( config ) ) ? ? "" ,
header_static : proxyUrl ( this . getHeaderUrl ( config ) ) ? ? "" ,
2024-04-25 05:40:27 +02:00
acct : this.getAcct ( ) ,
// TODO: Add these fields
limited : false ,
moved : null ,
noindex : false ,
suspended : false ,
discoverable : undefined ,
mute_expires_at : undefined ,
2024-06-12 02:29:59 +02:00
roles : user.roles
2024-06-13 03:03:57 +02:00
. map ( ( role ) = > new Role ( role ) )
2024-06-12 02:29:59 +02:00
. concat (
2024-06-13 03:03:57 +02:00
new Role ( {
2024-06-12 02:29:59 +02:00
id : "default" ,
name : "Default" ,
permissions : config.permissions.default ,
priority : 0 ,
description : "Default role for all users" ,
visible : false ,
icon : null ,
} ) ,
)
. concat (
user . isAdmin
? [
2024-06-13 03:03:57 +02:00
new Role ( {
2024-06-12 02:29:59 +02:00
id : "admin" ,
name : "Admin" ,
permissions : config.permissions.admin ,
priority : 2 * * 31 - 1 ,
description :
"Default role for all administrators" ,
visible : false ,
icon : null ,
} ) ,
]
: [ ] ,
)
2024-06-13 04:26:43 +02:00
. map ( ( r ) = > r . toApi ( ) ) ,
2024-04-25 05:40:27 +02:00
group : false ,
} ;
}
2024-11-01 21:20:12 +01:00
public toVersia ( ) : VersiaUser {
2024-04-25 05:40:27 +02:00
if ( this . isRemote ( ) ) {
2024-08-19 15:16:01 +02:00
throw new Error ( "Cannot convert remote user to Versia format" ) ;
2024-04-25 05:40:27 +02:00
}
2024-06-13 02:45:07 +02:00
const user = this . data ;
2024-04-25 05:40:27 +02:00
return {
id : user.id ,
type : "User" ,
uri : this.getUri ( ) ,
bio : {
"text/html" : {
content : user.note ,
2024-08-26 19:06:49 +02:00
remote : false ,
2024-04-25 05:40:27 +02:00
} ,
"text/plain" : {
content : htmlToText ( user . note ) ,
2024-08-26 19:06:49 +02:00
remote : false ,
2024-04-25 05:40:27 +02:00
} ,
} ,
created_at : new Date ( user . createdAt ) . toISOString ( ) ,
2024-08-26 19:06:49 +02:00
collections : {
featured : new URL (
` /users/ ${ user . id } /featured ` ,
config . http . base_url ,
) . toString ( ) ,
"pub.versia:likes/Likes" : new URL (
` /users/ ${ user . id } /likes ` ,
config . http . base_url ,
) . toString ( ) ,
"pub.versia:likes/Dislikes" : new URL (
` /users/ ${ user . id } /dislikes ` ,
config . http . base_url ,
) . toString ( ) ,
followers : new URL (
` /users/ ${ user . id } /followers ` ,
config . http . base_url ,
) . toString ( ) ,
following : new URL (
` /users/ ${ user . id } /following ` ,
config . http . base_url ,
) . toString ( ) ,
outbox : new URL (
` /users/ ${ user . id } /outbox ` ,
config . http . base_url ,
) . toString ( ) ,
} ,
2024-04-25 05:40:27 +02:00
inbox : new URL (
` /users/ ${ user . id } /inbox ` ,
config . http . base_url ,
) . toString ( ) ,
indexable : false ,
username : user.username ,
2024-08-26 19:27:40 +02:00
manually_approves_followers : this.data.isLocked ,
2024-04-25 05:40:27 +02:00
avatar : urlToContentFormat ( this . getAvatarUrl ( config ) ) ? ? undefined ,
header : urlToContentFormat ( this . getHeaderUrl ( config ) ) ? ? undefined ,
display_name : user.displayName ,
2024-05-17 19:39:59 +02:00
fields : user.fields ,
2024-04-25 05:40:27 +02:00
public_key : {
actor : new URL (
` /users/ ${ user . id } ` ,
config . http . base_url ,
) . toString ( ) ,
2024-08-26 19:06:49 +02:00
key : user.publicKey ,
algorithm : "ed25519" ,
2024-04-25 05:40:27 +02:00
} ,
extensions : {
2024-08-26 19:27:40 +02:00
"pub.versia:custom_emojis" : {
2024-06-13 06:52:01 +02:00
emojis : user.emojis.map ( ( emoji ) = >
2024-08-19 15:16:01 +02:00
new Emoji ( emoji ) . toVersia ( ) ,
2024-06-13 06:52:01 +02:00
) ,
2024-04-25 05:40:27 +02:00
} ,
} ,
} ;
}
2024-11-01 21:20:12 +01:00
public toMention ( ) : ApiMention {
2024-04-25 05:40:27 +02:00
return {
url : this.getUri ( ) ,
2024-06-13 02:45:07 +02:00
username : this.data.username ,
2024-04-25 05:40:27 +02:00
acct : this.getAcct ( ) ,
id : this.id ,
} ;
}
}