504 lines
20 KiB
JavaScript
504 lines
20 KiB
JavaScript
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
|
|
});
|