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