import { In, Not } from "typeorm"; import Ajv from "ajv"; import { User } from "../entities/user.js"; import config from "../../config/index.js"; import { awaitAll } from "../../prelude/await-all.js"; import { populateEmojis } from "../../misc/populate-emojis.js"; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from "../../const.js"; import { Cache } from "../../misc/cache.js"; import { db } from "../../db/postgre.js"; import { isActor, getApId } from "../../remote/activitypub/type.js"; import DbResolver from "../../remote/activitypub/db-resolver.js"; import Resolver from "../../remote/activitypub/resolver.js"; import { createPerson } from "../../remote/activitypub/models/person.js"; import { AnnouncementReads, Announcements, Blockings, ChannelFollowings, DriveFiles, Followings, FollowRequests, Instances, MessagingMessages, Mutings, RenoteMutings, Notes, NoteUnreads, Notifications, Pages, Plans, UserGroupJoinings, UserNotePinings, UserPlans, UserProfiles, UserSecurityKeys } from "../index.js"; import AsyncLock from "async-lock"; const userInstanceCache = new Cache("userInstance", 60 * 60 * 3); function isMissingRelationError(err) { const error = err; return error.code === "42P01" || error.driverError?.code === "42P01"; } const ajv = new Ajv(); const localUsernameSchema = { type: "string", pattern: /^\w{1,20}$/.toString().slice(1, -1) }; const passwordSchema = { type: "string", minLength: 1 }; const nameSchema = { type: "string", minLength: 1, maxLength: 50 }; const descriptionSchema = { type: "string", minLength: 1, maxLength: 2048 }; const locationSchema = { type: "string", minLength: 1, maxLength: 50 }; const birthdaySchema = { type: "string", pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) }; /** * Returns true if the user is local. * * @param user The user to check. * @returns True if the user is local. */ function isLocalUser(user) { return user.host == null; } /** * Returns true if the user is remote. * * @param user The user to check. * @returns True if the user is remote. */ function isRemoteUser(user) { 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, target) { 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) { const mute = await Mutings.findBy({ muterId: userId }); const joinings = await UserGroupJoinings.findBy({ 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([ MessagingMessages.count({ where: { recipientId: userId, isRead: false, ...mute.length > 0 ? { userId: Not(In(mute.map((x)=>x.muteeId))) } : {} }, take: 1 }).then((count)=>count > 0), groupQs ]); return withUser || withGroups.some((x)=>x); }, async getHasUnreadAnnouncement (userId) { const reads = await AnnouncementReads.findBy({ userId: userId }); const count = await Announcements.countBy(reads.length > 0 ? { id: Not(In(reads.map((read)=>read.announcementId))) } : {}); return count > 0; }, async userFromURI (uri) { try { const dbResolver = new DbResolver(); let local = await dbResolver.getUserFromApId(uri); if (local) { return local; } // fetching Object once from remote const resolver = new Resolver(); const object = await resolver.resolve(uri); // /@user If a URI other than the id is specified, // the URI is determined here if (uri !== object.id) { local = await dbResolver.getUserFromApId(object.id); if (local != null) return local; } return isActor(object) ? await createPerson(getApId(object)) : null; } catch { return null; } }, async getHasUnreadAntenna (userId) { // try { // const myAntennas = (await getAntennas()).filter( // (a) => a.userId === userId, // ); // const unread = // myAntennas.length > 0 // ? await AntennaNotes.findOneBy({ // antennaId: In(myAntennas.map((x) => x.id)), // read: false, // }) // : null; // return unread != null; // } catch (e) { // return false; // } return false; // TODO }, async getHasUnreadChannel (userId) { const channels = await ChannelFollowings.findBy({ followerId: userId }); const unread = channels.length > 0 ? await NoteUnreads.findOneBy({ userId: userId, noteChannelId: In(channels.map((x)=>x.followeeId)) }) : null; return unread != null; }, async getHasUnreadNotification (userId) { const mute = await Mutings.findBy({ muterId: userId }); const mutedUserIds = mute.map((m)=>m.muteeId); const count = await Notifications.count({ where: { notifieeId: userId, ...mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}, isRead: false }, take: 1 }); return count > 0; }, async getHasPendingReceivedFollowRequest (userId) { const count = await FollowRequests.countBy({ followeeId: userId }); return count > 0; }, getOnlineStatus (user) { 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) { if (user.avatar) { return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); } else if (user.avatarId) { if (user.avatarUrl) return DriveFiles.getFinalUrl(user.avatarUrl); 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) { if (user.avatarId && user.avatarUrl) { return DriveFiles.getFinalUrl(user.avatarUrl); } else if (user.avatar) { return DriveFiles.getPublicUrl(user.avatar, true) || this.getIdenticonUrl(user.id); } else { return this.getIdenticonUrl(user.id); } }, getIdenticonUrl (userId) { return `${config.url}/identicon/${userId}`; }, getFreshPackedUserCache () { return { locks: new AsyncLock(), results: [] }; }, async getRandomFollower (targetId) { return await this.createQueryBuilder("u").select(`u.id`).leftJoinAndSelect("following", "f", `f."followerId" = u.id`).where(`f."followeeId" = :id`, { id: targetId }).getOne(); }, async packCached (src, cache, me, options) { const id = typeof src === "object" ? src.id : src; return cache.locks.acquire(id, async ()=>{ const result = cache.results.find((p)=>p.id === id); if (result) return result; return this.pack(src, me, options).then((result)=>{ cache.results.push(result); return result; }); }); }, async pack (src, me, options) { const opts = Object.assign({ detail: false, includeSecrets: false, isPrivateMode: false }, options); let user; if (typeof src === "object") { user = src; } else { user = await this.findOneOrFail({ where: { id: src } }); } 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 followingCount = profile == null ? null : profile.ffVisibility === "public" || isMe ? user.followingCount : profile.ffVisibility === "followers" && relation && relation.isFollowing ? user.followingCount : null; const followersCount = profile == null ? null : profile.ffVisibility === "public" || isMe ? user.followersCount : profile.ffVisibility === "followers" && relation && relation.isFollowing ? user.followersCount : null; const falsy = opts.detail ? false : undefined; if (opts.isPrivateMode) { const packed = { id: user.id, username: user.username, host: user.host, ...opts.detail ? { twoFactorEnabled: profile.twoFactorEnabled, usePasswordLessLogin: profile.usePasswordLessLogin, securityKeys: profile.twoFactorEnabled ? UserSecurityKeys.countBy({ userId: user.id }).then((result)=>result >= 1) : false } : {} }; return await awaitAll(packed); } const packed = { id: user.id, name: user.name, username: user.username, host: user.host, avatarUrl: this.getAvatarUrlSync(user), avatarBlurhash: user.avatarId ? user.avatarBlurhash ?? user.avatar?.blurhash ?? null : null, avatarColor: null, isAdmin: user.isAdmin || falsy, isModerator: user.isModerator || falsy, isVerified: user.isVerified || falsy, minorBadges: user.minorBadges ?? [], plans: UserPlans.find({ where: { userId: user.id }, relations: [ "plan" ], order: { createdAt: "ASC" } }).then((joins)=>Plans.packMany(joins.map((join)=>join.plan).filter((plan)=>plan != null))).catch((err)=>{ if (isMissingRelationError(err)) return []; throw err; }), isBot: user.isBot || falsy, isLocked: user.isLocked, isCat: user.isCat || falsy, speakAsCat: user.speakAsCat || falsy, instance: user.host ? userInstanceCache.fetch(user.host, ()=>Instances.findOneBy({ host: user.host }), (v)=>v != null).then((instance)=>instance ? { name: instance.name, softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, iconUrl: instance.iconUrl, faviconUrl: instance.faviconUrl, themeColor: instance.themeColor } : undefined) : undefined, emojis: populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), driveCapacityOverrideMb: user.driveCapacityOverrideMb, canBite: user.canBite, ...opts.detail ? { url: profile.url, uri: user.uri, movedToUri: user.movedToUri ? await this.userFromURI(user.movedToUri) : null, alsoKnownAs: user.alsoKnownAs, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, bannerUrl: user.bannerId ? DriveFiles.getFinalUrlMaybe(user.bannerUrl) ?? (user.banner ? DriveFiles.getPublicUrl(user.banner, false) : null) : null, bannerBlurhash: user.bannerId ? user.bannerBlurhash ?? user.banner?.blurhash ?? null : null, bannerColor: null, isSilenced: user.isSilenced || falsy, isSuspended: user.isSuspended || falsy, 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, allowCalls: profile.allowCalls, symbolFileId: profile.symbolFileId, ffVisibility: profile.ffVisibility, twoFactorEnabled: profile.twoFactorEnabled, usePasswordLessLogin: profile.usePasswordLessLogin, securityKeys: profile.twoFactorEnabled ? UserSecurityKeys.countBy({ userId: user.id }).then((result)=>result >= 1) : false, pronouns: profile.pronouns } : {}, ...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, preventAiLearning: profile.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, 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), integrations: profile.integrations, mutedWords: profile.mutedWords, mutedInstances: profile.mutedInstances, mutingNotificationTypes: profile.mutingNotificationTypes, emailNotificationTypes: profile.emailNotificationTypes } : {}, ...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 } : {} }; return await awaitAll(packed); }, packMany (users, me, options, cache) { return Promise.all(users.map((u)=>this.packCached(u, cache ?? this.getFreshPackedUserCache(), me, options))); }, isLocalUser, isRemoteUser });