import $ from 'cafy'; import { EntityRepository, Repository, In } from 'typeorm'; import { User, ILocalUser, IRemoteUser } from '../entities/user'; import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserGroupJoinings } from '..'; import { ensure } from '../../prelude/ensure'; import config from '../../config'; import { SchemaType, bool, types } from '../../misc/schema'; import { awaitAll } from '../../prelude/await-all'; export type PackedUser = SchemaType; @EntityRepository(User) export class UserRepository extends Repository { public async getRelation(me: User['id'], target: User['id']) { const [following1, following2, followReq1, followReq2, toBlocking, fromBlocked, mute] = await Promise.all([ Followings.findOne({ followerId: me, followeeId: target }), Followings.findOne({ followerId: target, followeeId: me }), FollowRequests.findOne({ followerId: me, followeeId: target }), FollowRequests.findOne({ followerId: target, followeeId: me }), Blockings.findOne({ blockerId: me, blockeeId: target }), Blockings.findOne({ blockerId: target, blockeeId: me }), Mutings.findOne({ muterId: me, muteeId: target }) ]); return { id: target, isFollowing: following1 != null, hasPendingFollowRequestFromYou: followReq1 != null, hasPendingFollowRequestToYou: followReq2 != null, isFollowed: following2 != null, isBlocking: toBlocking != null, isBlocked: fromBlocked != null, isMuted: mute != null }; } public async getHasUnreadMessagingMessage(userId: User['id']): Promise { const joinings = await UserGroupJoinings.find({ userId: userId }); const groupQs = Promise.all(joinings.map(j => MessagingMessages.createQueryBuilder('message') .where(`message.groupId = :groupId`, { groupId: j.userGroupId }) .andWhere('message.userId != :userId', { userId: userId }) .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) .andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない .getOne().then(x => x != null))); const [withUser, withGroups] = await Promise.all([ // TODO: ミュートを考慮 MessagingMessages.count({ where: { recipientId: userId, isRead: false }, take: 1 }).then(count => count > 0), groupQs ]); return withUser || withGroups.some(x => x); } public async pack( src: User['id'] | User, me?: User['id'] | User | null | undefined, options?: { detail?: boolean, includeSecrets?: boolean, includeHasUnreadNotes?: boolean } ): Promise { const opts = Object.assign({ detail: false, includeSecrets: false }, options); const user = typeof src === 'object' ? src : await this.findOne(src).then(ensure); const meId = me ? typeof me === 'string' ? me : me.id : null; const relation = meId && (meId !== user.id) && opts.detail ? await this.getRelation(meId, user.id) : null; const pins = opts.detail ? await UserNotePinings.find({ where: { userId: user.id }, order: { id: 'DESC' } }) : []; const profile = opts.detail ? await UserProfiles.findOne(user.id).then(ensure) : null; const falsy = opts.detail ? false : undefined; const packed = { id: user.id, name: user.name, username: user.username, host: user.host, avatarUrl: user.avatarUrl ? user.avatarUrl : config.url + '/avatar/' + user.id, avatarColor: user.avatarColor, isAdmin: user.isAdmin || falsy, isBot: user.isBot || falsy, isCat: user.isCat || falsy, // カスタム絵文字添付 emojis: user.emojis.length > 0 ? Emojis.find({ where: { name: In(user.emojis), host: user.host }, select: ['name', 'host', 'url', 'aliases'] }) : [], ...(opts.includeHasUnreadNotes ? { hasUnreadSpecifiedNotes: NoteUnreads.count({ where: { userId: user.id, isSpecified: true }, take: 1 }).then(count => count > 0), hasUnreadMentions: NoteUnreads.count({ where: { userId: user.id }, take: 1 }).then(count => count > 0), } : {}), ...(opts.detail ? { url: profile!.url, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, bannerUrl: user.bannerUrl, bannerColor: user.bannerColor, isLocked: user.isLocked, isModerator: user.isModerator || falsy, description: profile!.description, location: profile!.location, birthday: profile!.birthday, followersCount: user.followersCount, followingCount: user.followingCount, notesCount: user.notesCount, pinnedNoteIds: pins.map(pin => pin.noteId), pinnedNotes: Notes.packMany(pins.map(pin => pin.noteId), meId, { detail: true }), twoFactorEnabled: profile!.twoFactorEnabled, twitter: profile!.twitter ? { id: profile!.twitterUserId, screenName: profile!.twitterScreenName } : null, github: profile!.github ? { id: profile!.githubId, login: profile!.githubLogin } : null, discord: profile!.discord ? { id: profile!.discordId, username: profile!.discordUsername, discriminator: profile!.discordDiscriminator } : null, } : {}), ...(opts.detail && meId === user.id ? { avatarId: user.avatarId, bannerId: user.bannerId, autoWatch: profile!.autoWatch, alwaysMarkNsfw: profile!.alwaysMarkNsfw, carefulBot: profile!.carefulBot, autoAcceptFollowed: profile!.autoAcceptFollowed, hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadNotification: Notifications.count({ where: { notifieeId: user.id, isRead: false }, take: 1 }).then(count => count > 0), pendingReceivedFollowRequestsCount: FollowRequests.count({ followeeId: user.id }), } : {}), ...(opts.includeSecrets ? { clientData: profile!.clientData, email: profile!.email, emailVerified: profile!.emailVerified, } : {}), ...(relation ? { isFollowing: relation.isFollowing, isFollowed: relation.isFollowed, hasPendingFollowRequestFromYou: relation.hasPendingFollowRequestFromYou, hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, } : {}) }; return await awaitAll(packed); } public packMany( users: (User['id'] | User)[], me?: User['id'] | User | null | undefined, options?: { detail?: boolean, includeSecrets?: boolean, includeHasUnreadNotes?: boolean } ) { return Promise.all(users.map(u => this.pack(u, me, options))); } public isLocalUser(user: User): user is ILocalUser { return user.host == null; } public isRemoteUser(user: User): user is IRemoteUser { return !this.isLocalUser(user); } //#region Validators public validateLocalUsername = $.str.match(/^\w{1,20}$/); public validateRemoteUsername = $.str.match(/^\w([\w-]*\w)?$/); public validatePassword = $.str.min(1); public validateName = $.str.min(1).max(50); public validateDescription = $.str.min(1).max(500); public validateLocation = $.str.min(1).max(50); public validateBirthday = $.str.match(/^([0-9]{4})-([0-9]{2})-([0-9]{2})$/); //#endregion } export const packedUserSchema = { type: types.object, nullable: bool.false, optional: bool.false, properties: { id: { type: types.string, nullable: bool.false, optional: bool.false, format: 'id', description: 'The unique identifier for this User.', example: 'xxxxxxxxxx', }, username: { type: types.string, nullable: bool.false, optional: bool.false, description: 'The screen name, handle, or alias that this user identifies themselves with.', example: 'ai' }, name: { type: types.string, nullable: bool.true, optional: bool.false, description: 'The name of the user, as they’ve defined it.', example: '藍' }, url: { type: types.string, format: 'url', nullable: bool.true, optional: bool.true, }, avatarUrl: { type: types.string, format: 'url', nullable: bool.true, optional: bool.false, }, avatarColor: { type: types.any, nullable: bool.true, optional: bool.false, }, bannerUrl: { type: types.string, format: 'url', nullable: bool.true, optional: bool.true, }, bannerColor: { type: types.any, nullable: bool.true, optional: bool.true, }, emojis: { type: types.any, nullable: bool.true, optional: bool.false, }, host: { type: types.string, nullable: bool.true, optional: bool.false, example: 'misskey.example.com' }, description: { type: types.string, nullable: bool.true, optional: bool.true, description: 'The user-defined UTF-8 string describing their account.', example: 'Hi masters, I am Ai!' }, birthday: { type: types.string, nullable: bool.true, optional: bool.true, example: '2018-03-12' }, createdAt: { type: types.string, nullable: bool.false, optional: bool.true, format: 'date-time', description: 'The date that the user account was created on Misskey.' }, updatedAt: { type: types.string, nullable: bool.true, optional: bool.true, format: 'date-time', }, location: { type: types.string, nullable: bool.true, optional: bool.true, }, followersCount: { type: types.number, nullable: bool.false, optional: bool.true, description: 'The number of followers this account currently has.' }, followingCount: { type: types.number, nullable: bool.false, optional: bool.true, description: 'The number of users this account is following.' }, notesCount: { type: types.number, nullable: bool.false, optional: bool.true, description: 'The number of Notes (including renotes) issued by the user.' }, isBot: { type: types.boolean, nullable: bool.false, optional: bool.true, description: 'Whether this account is a bot.' }, pinnedNoteIds: { type: types.array, nullable: bool.false, optional: bool.true, items: { type: types.string, nullable: bool.false, optional: bool.false, format: 'id', } }, pinnedNotes: { type: types.array, nullable: bool.false, optional: bool.true, items: { type: types.object, nullable: bool.false, optional: bool.false, ref: 'Note' } }, isCat: { type: types.boolean, nullable: bool.false, optional: bool.true, description: 'Whether this account is a cat.' }, isAdmin: { type: types.boolean, nullable: bool.false, optional: bool.true, description: 'Whether this account is the admin.' }, isModerator: { type: types.boolean, nullable: bool.false, optional: bool.true, description: 'Whether this account is a moderator.' }, isLocked: { type: types.boolean, nullable: bool.false, optional: bool.true, }, hasUnreadSpecifiedNotes: { type: types.boolean, nullable: bool.false, optional: bool.true, }, hasUnreadMentions: { type: types.boolean, nullable: bool.false, optional: bool.true, }, }, };