Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -0,0 +1,441 @@
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');
}
}