import { Blockings, DriveFiles, Followings, FollowRequests, Mutings, NoteFavorites, NoteReactions, Notes, NoteWatchings, RegistryItems, UserNotePinings, UserProfiles, Users } from "../../../../models/index.js"; import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import AsyncLock from "async-lock"; import { getUser } from "../../common/getters.js"; import { PaginationHelpers } from "./pagination.js"; import { awaitAll } from "../../../../prelude/await-all.js"; import createFollowing from "../../../../services/following/create.js"; import deleteFollowing from "../../../../services/following/delete.js"; import cancelFollowRequest from "../../../../services/following/requests/cancel.js"; import createBlocking from "../../../../services/blocking/create.js"; import deleteBlocking from "../../../../services/blocking/delete.js"; import { genId } from "../../../../misc/gen-id.js"; import { publishUserEvent } from "../../../../services/stream.js"; import { UserConverter } from "../converters/user.js"; import acceptFollowRequest from "../../../../services/following/requests/accept.js"; import { rejectFollowRequest } from "../../../../services/following/reject.js"; import { Brackets, IsNull } from "typeorm"; import { VisibilityConverter } from "../converters/visibility.js"; import { toSingleLast } from "../../../../prelude/array.js"; import { MediaHelpers } from "./media.js"; import { verifyLink } from "../../../../services/fetch-rel-me.js"; import { MastoApiError } from "../middleware/catch-errors.js"; import { resolveUser } from "../../../../remote/resolve-user.js"; import { updatePerson } from "../../../../remote/activitypub/models/person.js"; import { promiseEarlyReturn } from "../../../../prelude/promise.js"; import { updateUserProfileData } from "../../../../services/i/update.js"; export class UserHelpers { static async followUser(target, reblogs, notify, ctx) { //FIXME: implement reblogs & notify params const localUser = ctx.user; const following = await Followings.exist({ where: { followerId: localUser.id, followeeId: target.id } }); const requested = await FollowRequests.exist({ where: { followerId: localUser.id, followeeId: target.id } }); if (!following && !requested) await createFollowing(localUser, target); return this.getUserRelationshipTo(target.id, localUser.id); } static async unfollowUser(target, ctx) { const localUser = ctx.user; const following = await Followings.exist({ where: { followerId: localUser.id, followeeId: target.id } }); const requested = await FollowRequests.exist({ where: { followerId: localUser.id, followeeId: target.id } }); if (following) await deleteFollowing(localUser, target); if (requested) await cancelFollowRequest(target, localUser); return this.getUserRelationshipTo(target.id, localUser.id); } static async blockUser(target, ctx) { const localUser = ctx.user; const blocked = await Blockings.exist({ where: { blockerId: localUser.id, blockeeId: target.id } }); if (!blocked) await createBlocking(localUser, target); return this.getUserRelationshipTo(target.id, localUser.id); } static async unblockUser(target, ctx) { const localUser = ctx.user; const blocked = await Blockings.exist({ where: { blockerId: localUser.id, blockeeId: target.id } }); if (blocked) await deleteBlocking(localUser, target); return this.getUserRelationshipTo(target.id, localUser.id); } static async muteUser(target, notifications = true, duration = 0, ctx) { //FIXME: respect notifications parameter const localUser = ctx.user; const muted = await Mutings.exist({ where: { muterId: localUser.id, muteeId: target.id } }); if (!muted) { await Mutings.insert({ id: genId(), createdAt: new Date(), expiresAt: duration === 0 ? null : new Date(new Date().getTime() + duration * 1000), muterId: localUser.id, muteeId: target.id }); publishUserEvent(localUser.id, "mute", target); NoteWatchings.delete({ userId: localUser.id, noteUserId: target.id }); } return this.getUserRelationshipTo(target.id, localUser.id); } static async unmuteUser(target, ctx) { const localUser = ctx.user; const muting = await Mutings.findOneBy({ muterId: localUser.id, muteeId: target.id }); if (muting) { await Mutings.delete({ id: muting.id }); publishUserEvent(localUser.id, "unmute", target); } return this.getUserRelationshipTo(target.id, localUser.id); } static async acceptFollowRequest(target, ctx) { const localUser = ctx.user; const pending = await FollowRequests.exist({ where: { followerId: target.id, followeeId: localUser.id } }); if (pending) await acceptFollowRequest(localUser, target); return this.getUserRelationshipTo(target.id, localUser.id); } static async rejectFollowRequest(target, ctx) { const localUser = ctx.user; const pending = await FollowRequests.exist({ where: { followerId: target.id, followeeId: localUser.id } }); if (pending) await rejectFollowRequest(localUser, target); return this.getUserRelationshipTo(target.id, localUser.id); } static async updateCredentials(ctx) { const user = ctx.user; const files = ctx.request.files; const formData = ctx.request.body; const updates = {}; const profileUpdates = {}; const avatar = toSingleLast(files?.avatar); const header = toSingleLast(files?.header); if (avatar) { const file = await MediaHelpers.uploadMediaBasic(avatar, ctx); updates.avatarId = file.id; updates.avatarBlurhash = file.blurhash; updates.avatarUrl = DriveFiles.getDatabasePrefetchUrl(file, true); } if (header) { const file = await MediaHelpers.uploadMediaBasic(header, ctx); updates.bannerId = file.id; updates.bannerBlurhash = file.blurhash; updates.bannerUrl = DriveFiles.getDatabasePrefetchUrl(file, false); } if (formData.fields_attributes) { profileUpdates.fields = await Promise.all(formData.fields_attributes.map(async (field)=>{ if (!(field.name.trim() === "" && field.value.trim() === "")) { if (field.name.trim() === "") throw new MastoApiError(400, "Field name can not be empty"); if (field.value.trim() === "") throw new MastoApiError(400, "Field value can not be empty"); } const verified = field.value.startsWith("http") ? await promiseEarlyReturn(verifyLink(field.value, user.username), 1500) ?? false : undefined; return { ...field, verified }; })).then((p)=>p.filter((field)=>field.name.trim().length > 0 && field.value.length > 0)); } if (formData.display_name) updates.name = formData.display_name; if (formData.note) profileUpdates.description = formData.note; if (formData.locked) updates.isLocked = formData.locked; if (formData.bot) updates.isBot = formData.bot; if (formData.discoverable) updates.isExplorable = formData.discoverable; await updateUserProfileData(user, null, updates, profileUpdates, false); return this.verifyCredentials(ctx); } static async verifyCredentials(ctx) { const user = ctx.user; const acct = UserConverter.encode(user, ctx); const profile = UserProfiles.findOneByOrFail({ userId: user.id }); const followRequests = FollowRequests.count({ where: { followeeId: user.id } }); const privacy = this.getDefaultNoteVisibility(ctx); const fields = profile.then((profile)=>profile.fields.map((field)=>{ return { name: field.name, value: field.value }; })); return acct.then((acct)=>{ const source = { note: profile.then((profile)=>profile.description ?? ''), fields: fields, privacy: privacy.then((p)=>VisibilityConverter.encode(p)), sensitive: profile.then((p)=>p.alwaysMarkNsfw), language: profile.then((p)=>p.lang ?? ''), follow_requests_count: followRequests }; const result = { ...acct, source: awaitAll(source) }; return awaitAll(result); }); } static async getUserFromAcct(acct) { const split = acct.toLowerCase().split('@'); if (split.length > 2) throw new Error('Invalid acct'); return split[1] == null ? Users.findOneBy({ usernameLower: split[0], host: split[1] ?? IsNull() }).then((p)=>{ if (p) return p; throw new MastoApiError(404); }) : resolveUser(split[0], split[1], 'no-refresh').catch(()=>{ throw new MastoApiError(404); }); } static async getUserMutes(maxId, sinceId, minId, limit = 40, ctx) { if (limit > 80) limit = 80; const user = ctx.user; const query = PaginationHelpers.makePaginationQuery(Mutings.createQueryBuilder("muting"), sinceId, maxId, minId); query.andWhere("muting.muterId = :userId", { userId: user.id }).innerJoinAndSelect("muting.mutee", "mutee"); return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then(async (mutes)=>{ const users = mutes.map((p)=>p.mutee).filter((p)=>p); return await UserConverter.encodeMany(users, ctx).then((res)=>res.map((m)=>{ const muting = mutes.find((acc)=>acc.muteeId === m.id); return { ...m, mute_expires_at: muting?.expiresAt?.toISOString() ?? null }; })); }); } static async getUserBlocks(maxId, sinceId, minId, limit = 40, ctx) { if (limit > 80) limit = 80; const user = ctx.user; const query = PaginationHelpers.makePaginationQuery(Blockings.createQueryBuilder("blocking"), sinceId, maxId, minId); query.andWhere("blocking.blockerId = :userId", { userId: user.id }).innerJoinAndSelect("blocking.blockee", "blockee"); return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((blocks)=>{ return blocks.map((p)=>p.blockee).filter((p)=>p); }); } static async getUserFollowRequests(maxId, sinceId, minId, limit = 40, ctx) { if (limit > 80) limit = 80; const user = ctx.user; const query = PaginationHelpers.makePaginationQuery(FollowRequests.createQueryBuilder("request"), sinceId, maxId, minId); query.andWhere("request.followeeId = :userId", { userId: user.id }).innerJoinAndSelect("request.follower", "follower"); return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((requests)=>{ return requests.map((p)=>p.follower).filter((p)=>p); }); } static async getUserStatuses(user, maxId, sinceId, minId, limit = 20, onlyMedia = false, excludeReplies = false, excludeReblogs = false, pinned = false, tagged, ctx) { if (limit > 40) limit = 40; const localUser = ctx.user; if (tagged !== undefined && tagged.length > 0) { //FIXME respect tagged return []; } const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).andWhere("note.userId = :userId"); if (pinned) { const sq = UserNotePinings.createQueryBuilder("pin").select("pin.noteId").where("pin.userId = :userId"); query.andWhere(`note.id IN (${sq.getQuery()})`); } if (excludeReblogs) { query.andWhere(new Brackets((qb)=>{ qb.where('note.renoteId IS NULL').orWhere('note.text IS NOT NULL').orWhere('note.hasPoll = TRUE').orWhere("note.fileIds != '{}'"); })); } if (excludeReplies) { query.leftJoin("note", "thread", "note.threadId = thread.id").andWhere(new Brackets((qb)=>{ qb.where("note.replyId IS NULL").orWhere(new Brackets((qb)=>{ qb.where('note.mentions = :mentions', { mentions: [] }).andWhere('thread.userId = :userId'); })); })); } query.leftJoinAndSelect("note.renote", "renote"); generateVisibilityQuery(query, localUser); if (localUser) { generateMutedUserQuery(query, localUser, user); generateBlockedUserQuery(query, localUser); } if (onlyMedia) query.andWhere("note.fileIds != '{}'"); query.andWhere("note.visibility != 'hidden'"); query.andWhere("note.visibility != 'specified'"); query.setParameters({ userId: user.id }); return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); } static async getUserBookmarks(maxId, sinceId, minId, limit = 20, ctx) { if (limit > 40) limit = 40; const localUser = ctx.user; const query = PaginationHelpers.makePaginationQuery(NoteFavorites.createQueryBuilder("favorite"), sinceId, maxId, minId).andWhere("favorite.userId = :meId", { meId: localUser.id }).leftJoinAndSelect("favorite.note", "note"); generateVisibilityQuery(query, localUser); return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((res)=>res.map((p)=>p.note)); } static async getUserFavorites(maxId, sinceId, minId, limit = 20, ctx) { if (limit > 40) limit = 40; const localUser = ctx.user; const query = PaginationHelpers.makePaginationQuery(NoteReactions.createQueryBuilder("reaction"), sinceId, maxId, minId).andWhere("reaction.userId = :meId", { meId: localUser.id }).leftJoinAndSelect("reaction.note", "note"); generateVisibilityQuery(query, localUser); return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((res)=>res.map((p)=>p.note)); } static async getUserRelationships(type, user, maxId, sinceId, minId, limit = 40, ctx) { if (limit > 80) limit = 80; const localUser = ctx.user; const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); if (profile.ffVisibility === "private") { if (!localUser || user.id !== localUser.id) return []; } else if (profile.ffVisibility === "followers") { if (!localUser) return []; if (user.id !== localUser.id) { const isFollowed = await Followings.exist({ where: { followeeId: user.id, followerId: localUser.id } }); if (!isFollowed) return []; } } const query = PaginationHelpers.makePaginationQuery(Followings.createQueryBuilder("following"), sinceId, maxId, minId); if (type === "followers") { query.andWhere("following.followeeId = :userId", { userId: user.id }).innerJoinAndSelect("following.follower", "follower"); } else { query.andWhere("following.followerId = :userId", { userId: user.id }).innerJoinAndSelect("following.followee", "followee"); } return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((relations)=>relations.map((p)=>type === "followers" ? p.follower : p.followee).filter((p)=>p)); } static async getUserFollowers(user, maxId, sinceId, minId, limit = 40, ctx) { return this.getUserRelationships('followers', user, maxId, sinceId, minId, limit, ctx); } static async getUserFollowing(user, maxId, sinceId, minId, limit = 40, ctx) { return this.getUserRelationships('following', user, maxId, sinceId, minId, limit, ctx); } static async getUserRelationhipToMany(targetIds, localUserId) { return Promise.all(targetIds.map((targetId)=>this.getUserRelationshipTo(targetId, localUserId))); } static async getUserRelationshipTo(targetId, localUserId) { const relation = await Users.getRelation(localUserId, targetId); const response = { id: targetId, following: relation.isFollowing, followed_by: relation.isFollowed, blocking: relation.isBlocking, blocked_by: relation.isBlocked, muting: relation.isMuted, muting_notifications: relation.isMuted, requested: relation.hasPendingFollowRequestFromYou, domain_blocking: false, showing_reblogs: !relation.isRenoteMuted, endorsed: false, notifying: false, note: '' //FIXME }; return awaitAll(response); } static async getUserCached(id, ctx) { const cache = ctx.cache; return cache.locks.acquire(id, async ()=>{ const cacheHit = cache.users.find((p)=>p.id == id); if (cacheHit) return cacheHit; return getUser(id).then((p)=>{ cache.users.push(p); return p; }); }); } static async getUserCachedOr404(id, ctx) { return this.getUserCached(id, ctx).catch((_)=>{ throw new MastoApiError(404); }); } static async getUserOr404(id) { return getUser(id).catch((_)=>{ throw new MastoApiError(404); }); } static async updateUserInBackground(user) { if (Users.isLocalUser(user)) return; if (user.lastFetchedAt != null && Date.now() - user.lastFetchedAt.getTime() < 1000 * 60 * 60 * 24) return; await Users.update(user.id, { lastFetchedAt: new Date() }); // noinspection ES6MissingAwait updatePerson(user.uri, undefined, undefined, user); } static getFreshAccountCache() { return { locks: new AsyncLock(), accounts: [], users: [] }; } static async getDefaultNoteVisibility(ctx) { const user = ctx.user; return RegistryItems.findOneBy({ domain: IsNull(), userId: user.id, key: 'defaultNoteVisibility', scope: '{client,base}' }).then((p)=>p?.value ?? 'public'); } }