import { In, Not } from 'typeorm'; import Ajv from 'ajv'; import { User, ILocalUser, IRemoteUser } from '@/models/entities/user.js'; import config from '@/config/index.js'; import { Packed } from '@/misc/schema.js'; import { awaitAll, Promiseable } from '@/prelude/await-all.js'; import { populateEmojis } from '@/misc/populate-emojis.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD, HOUR } from '@/const.js'; import { Cache } from '@/misc/cache.js'; import { db } from '@/db/postgre.js'; import { Instance } from '../entities/instance.js'; import { Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, RenoteMutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, AntennaNotes, ChannelFollowings, Instances, DriveFiles } from '../index.js'; const userInstanceCache = new Cache( 3 * HOUR, (host) => Instances.findOneBy({ host }).then(x => x ?? undefined), ); type IsUserDetailed = Detailed extends true ? Packed<'UserDetailed'> : Packed<'UserLite'>; type IsMeAndIsUserDetailed = Detailed extends true ? ExpectsMe extends true ? Packed<'MeDetailed'> : ExpectsMe extends false ? Packed<'UserDetailedNotMe'> : Packed<'UserDetailed'> : Packed<'UserLite'>; const ajv = new Ajv(); // It is important that localUsernameSchema does not allow any usernames // containing dots because those are used for system actors. const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const; const passwordSchema = { type: 'string', minLength: 1 } as const; const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; const descriptionSchema = { type: 'string', minLength: 1, maxLength: 2048 } as const; const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const; function isLocalUser(user: User): user is ILocalUser; function isLocalUser(user: T): user is T & { host: null; token: string; }; function isLocalUser(user: User | { host: User['host'] }): boolean { return user.host == null; } function isRemoteUser(user: User): user is IRemoteUser; function isRemoteUser(user: T): user is T & { host: string; }; function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } export const UserRepository = db.getRepository(User).extend({ localUsernameSchema, passwordSchema, nameSchema, descriptionSchema, locationSchema, birthdaySchema, //#region Validators validateLocalUsername: ajv.compile(localUsernameSchema), validatePassword: ajv.compile(passwordSchema), validateName: ajv.compile(nameSchema), validateDescription: ajv.compile(descriptionSchema), validateLocation: ajv.compile(locationSchema), validateBirthday: ajv.compile(birthdaySchema), //#endregion async getRelation(me: User['id'], target: User['id']) { return awaitAll({ id: target, isFollowing: Followings.count({ where: { followerId: me, followeeId: target, }, take: 1, }).then(n => n > 0), isFollowed: Followings.count({ where: { followerId: target, followeeId: me, }, take: 1, }).then(n => n > 0), hasPendingFollowRequestFromYou: FollowRequests.count({ where: { followerId: me, followeeId: target, }, take: 1, }).then(n => n > 0), hasPendingFollowRequestToYou: FollowRequests.count({ where: { followerId: target, followeeId: me, }, take: 1, }).then(n => n > 0), isBlocking: Blockings.count({ where: { blockerId: me, blockeeId: target, }, take: 1, }).then(n => n > 0), isBlocked: Blockings.count({ where: { blockerId: target, blockeeId: me, }, take: 1, }).then(n => n > 0), isMuted: Mutings.count({ where: { muterId: me, muteeId: target, }, take: 1, }).then(n => n > 0), isRenoteMuted: RenoteMutings.count({ where: { muterId: me, muteeId: target, }, take: 1, }).then(n => n > 0), }); }, async getHasUnreadMessagingMessage(userId: User['id']): Promise { return await db.query( `SELECT EXISTS ( SELECT 1 FROM "messaging_message" WHERE "recipientId" = $1 AND NOT "isRead" AND "userId" NOT IN ( SELECT "muteeId" FROM "muting" WHERE "muterId" = $1 ) UNION SELECT 1 FROM "messaging_message" JOIN "user_group_joining" ON "messaging_message"."groupId" = "user_group_joining"."userGroupId" WHERE "user_group_joining"."userId" = $1 AND "messaging_message"."userId" != $1 AND NOT $1 = ANY("messaging_message"."reads") AND "messaging_message"."createdAt" > "user_group_joining"."createdAt" ) AS exists`, [userId] ).then(res => res[0].exists); }, async getHasUnreadAnnouncement(userId: User['id']): Promise { return await db.query( `SELECT EXISTS (SELECT 1 FROM "announcement" WHERE "id" NOT IN (SELECT "announcementId" FROM "announcement_read" WHERE "userId" = $1)) AS exists`, [userId] ).then(res => res[0].exists); }, async getHasUnreadAntenna(userId: User['id']): Promise { return await db.query( `SELECT EXISTS (SELECT 1 FROM "antenna_note" WHERE NOT "read" AND "antennaId" IN (SELECT "id" FROM "antenna" WHERE "userId" = $1)) AS exists`, [userId] ).then(res => res[0].exists); }, async getHasUnreadChannel(userId: User['id']): Promise { return await db.query( `SELECT EXISTS (SELECT 1 FROM "note_unread" WHERE "noteChannelId" IN (SELECT "followeeId" FROM "channel_following" WHERE "followerId" = $1)) AS exists`, [userId] ).then(res => res[0].exists); }, async getHasUnreadNotification(userId: User['id']): Promise { return await db.query( `SELECT EXISTS (SELECT 1 FROM "notification" WHERE NOT "isRead" AND "notifieeId" = $1 AND "notifierId" NOT IN (SELECT "muteeId" FROM "muting" WHERE "muterId" = $1)) AS exists`, [userId] ).then(res => res[0].exists); }, async getHasPendingReceivedFollowRequest(userId: User['id']): Promise { return await db.query( `SELECT EXISTS (SELECT 1 FROM "follow_request" WHERE "followeeId" = $1) AS exists`, [userId] ).then(res => res[0].exists); }, getOnlineStatus(user: User): 'unknown' | 'online' | 'active' | 'offline' { if (user.hideOnlineStatus) return 'unknown'; if (user.lastActiveDate == null) return 'unknown'; const elapsed = Date.now() - user.lastActiveDate.getTime(); return ( elapsed < USER_ONLINE_THRESHOLD ? 'online' : elapsed < USER_ACTIVE_THRESHOLD ? 'active' : 'offline' ); }, async getAvatarUrl(user: User): Promise { if (user.avatar) { return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); } else if (user.avatarId) { const avatar = await DriveFiles.findOneByOrFail({ id: user.avatarId }); return DriveFiles.getPublicUrl(avatar, true) || this.getIdenticonUrl(user.id); } else { return this.getIdenticonUrl(user.id); } }, getAvatarUrlSync(user: User): string { if (user.avatar) { return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); } else { return this.getIdenticonUrl(user.id); } }, getIdenticonUrl(userId: User['id']): string { return `${config.url}/identicon/${userId}`; }, /** * Determines whether the followers/following of user `user` are visibile to user `me`. */ async areFollowersVisibleTo(user: User, me: { id: User['id'] } | null | undefined): Promise { const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); switch (profile.ffVisibility) { case 'public': return true; case 'followers': if (me == null) { return false; } else if (me.id === user.id) { return true; } else { return await Followings.count({ where: { followerId: me.id, followeeId: user.id, }, take: 1, }).then(n => n > 0); } case 'private': return me?.id === user.id; case 'nobody': return false; } }, async pack( src: User['id'] | User, me?: { id: User['id'] } | null | undefined, options?: { detail?: D, includeSecrets?: boolean, }, ): Promise> { const opts = Object.assign({ detail: false, includeSecrets: false, }, options); let user: User; if (typeof src === 'object') { user = src; if (src.avatar === undefined && src.avatarId) src.avatar = await DriveFiles.findOneBy({ id: src.avatarId }) ?? null; if (src.banner === undefined && src.bannerId) src.banner = await DriveFiles.findOneBy({ id: src.bannerId }) ?? null; } else { user = await this.findOneOrFail({ where: { id: src }, relations: { avatar: true, banner: true, }, }); } const meId = me ? me.id : null; const isMe = meId === user.id; const relation = meId && !isMe && opts.detail ? await this.getRelation(meId, user.id) : null; const pins = opts.detail ? await UserNotePinings.createQueryBuilder('pin') .where('pin.userId = :userId', { userId: user.id }) .innerJoinAndSelect('pin.note', 'note') .orderBy('pin.id', 'DESC') .getMany() : []; const profile = opts.detail ? await UserProfiles.findOneByOrFail({ userId: user.id }) : null; const ffVisible = await this.areFollowersVisibleTo(user, me); const followingCount = !opts.detail ? null : ffVisible ? user.followingCount : null; const followersCount = !opts.detail ? null : ffVisible ? user.followersCount : null; const packed = { id: user.id, name: user.name, username: user.username, host: user.host, avatarUrl: this.getAvatarUrlSync(user), avatarBlurhash: user.avatar?.blurhash || null, isAdmin: user.isAdmin, isModerator: user.isModerator, isBot: user.isBot, isCat: user.isCat, instance: !user.host ? undefined : userInstanceCache.fetch(user.host) .then(instance => !instance ? undefined : { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor, }), emojis: populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), movedTo: !user.movedToId ? undefined : this.pack(user.movedTo ?? user.movedToId, me, { ...opts, detail: false, }), ...(opts.detail ? { url: profile!.url, uri: user.uri, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, bannerUrl: user.banner ? DriveFiles.getPublicUrl(user.banner, false) : null, bannerBlurhash: user.banner?.blurhash || null, isLocked: user.isLocked, isSilenced: user.isSilenced, isSuspended: user.isSuspended, description: profile!.description, location: profile!.location, birthday: profile!.birthday, lang: profile!.lang, fields: profile!.fields, followersCount: followersCount || 0, followingCount: followingCount || 0, notesCount: user.notesCount, pinnedNoteIds: pins.map(pin => pin.noteId), pinnedNotes: Notes.packMany(pins.map(pin => pin.note!), me, { detail: true, }), pinnedPageId: profile!.pinnedPageId, pinnedPage: profile!.pinnedPageId ? Pages.pack(profile!.pinnedPageId, me) : null, publicReactions: profile!.publicReactions, ffVisibility: profile!.ffVisibility, twoFactorEnabled: profile!.twoFactorEnabled, usePasswordLessLogin: profile!.usePasswordLessLogin, securityKeys: profile!.twoFactorEnabled ? UserSecurityKeys.countBy({ userId: user.id, }).then(result => result >= 1) : false, } : {}), ...(opts.detail && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, injectFeaturedNote: profile!.injectFeaturedNote, receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, alwaysMarkNsfw: profile!.alwaysMarkNsfw, carefulBot: profile!.carefulBot, autoAcceptFollowed: profile!.autoAcceptFollowed, noCrawle: profile!.noCrawle, isExplorable: user.isExplorable, isDeleted: user.isDeleted != null, hideOnlineStatus: user.hideOnlineStatus, hasUnreadSpecifiedNotes: NoteUnreads.count({ where: { userId: user.id, isSpecified: true }, take: 1, }).then(count => count > 0), hasUnreadMentions: NoteUnreads.count({ where: { userId: user.id, isMentioned: true }, take: 1, }).then(count => count > 0), hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadChannel: this.getHasUnreadChannel(user.id), hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, mutingNotificationTypes: profile!.mutingNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes, showTimelineReplies: user.showTimelineReplies, federateBlocks: user!.federateBlocks, } : {}), ...(opts.includeSecrets ? { email: profile!.email, emailVerified: profile!.emailVerified, securityKeysList: profile!.twoFactorEnabled ? UserSecurityKeys.find({ where: { userId: user.id, }, select: { id: true, name: true, lastUsed: true, }, }) : [], } : {}), ...(relation ? { isFollowing: relation.isFollowing, isFollowed: relation.isFollowed, hasPendingFollowRequestFromYou: relation.hasPendingFollowRequestFromYou, hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; return await awaitAll(packed); }, packMany( users: (User['id'] | User)[], me?: { id: User['id'] } | null | undefined, options?: { detail?: D, includeSecrets?: boolean, }, ): Promise[]> { return Promise.all(users.map(u => this.pack(u, me, options))); }, isLocalUser, isRemoteUser, });