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,212 @@
import { secureRndstr } from "../../../../misc/secure-rndstr.js";
import { OAuthApps, OAuthTokens } from "../../../../models/index.js";
import { genId } from "../../../../misc/gen-id.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { difference, toSingleLast, unique } from "../../../../prelude/array.js";
export class AuthHelpers {
static async registerApp(ctx) {
const body = ctx.request.body || ctx.request.query;
const scopes = (typeof body.scopes === "string" ? body.scopes.split(' ') : body.scopes) ?? [
'read'
];
const redirect_uris = body.redirect_uris?.split('\n');
const client_name = body.client_name;
const website = body.website;
if (client_name == null) throw new MastoApiError(400, 'Missing client_name param');
if (redirect_uris == null || redirect_uris.length < 1) throw new MastoApiError(400, 'Missing redirect_uris param');
try {
redirect_uris.every((u)=>this.validateRedirectUri(u));
} catch {
throw new MastoApiError(400, 'Invalid redirect_uris');
}
const app = await OAuthApps.insert({
id: genId(),
clientId: secureRndstr(32),
clientSecret: secureRndstr(32),
createdAt: new Date(),
name: client_name,
website: website,
scopes: scopes,
redirectUris: redirect_uris
}).then((x)=>OAuthApps.findOneByOrFail(x.identifiers[0]));
return {
id: app.id,
name: app.name,
website: app.website,
redirect_uri: app.redirectUris.join('\n'),
client_id: app.clientId,
client_secret: app.clientSecret,
vapid_key: await fetchMeta().then((meta)=>meta.swPublicKey)
};
}
static async getAuthCode(ctx) {
const user = ctx.miauth[0];
if (!user) throw new MastoApiError(401, "Unauthorized");
const body = ctx.request.body;
const scopes = (typeof body.scopes === "string" ? body.scopes.split(' ') : body.scopes) ?? [
'read'
];
const clientId = toSingleLast(body.client_id);
if (clientId == null) throw new MastoApiError(400, "Invalid client_id");
const app = await OAuthApps.findOneBy({
clientId: clientId
});
this.validateRedirectUri(body.redirect_uri);
if (!app) throw new MastoApiError(400, "Invalid client_id");
if (!scopes.every((p)=>app.scopes.includes(p))) throw new MastoApiError(400, "Cannot request more scopes than application");
if (!app.redirectUris.includes(body.redirect_uri)) throw new MastoApiError(400, "Redirect URI not in list");
const token = await OAuthTokens.insert({
id: genId(),
active: false,
code: secureRndstr(32),
token: secureRndstr(32),
appId: app.id,
userId: user.id,
createdAt: new Date(),
scopes: scopes,
redirectUri: body.redirect_uri
}).then((x)=>OAuthTokens.findOneByOrFail(x.identifiers[0]));
return {
code: token.code
};
}
static async getAppInfo(ctx) {
const body = ctx.request.body;
const clientId = toSingleLast(body.client_id);
if (clientId == null) throw new MastoApiError(400, "Invalid client_id");
const app = await OAuthApps.findOneBy({
clientId: clientId
});
if (!app) throw new MastoApiError(400, "Invalid client_id");
return {
name: app.name
};
}
static async getAuthToken(ctx) {
const body = ctx.request.body || ctx.request.query;
const scopes = (typeof body.scope === "string" ? body.scope.split(' ') : body.scope) ?? [
'read'
];
const clientId = toSingleLast(body.client_id);
const code = toSingleLast(body.code);
const invalidScopeError = new MastoApiError(400, "invalid_scope", "The requested scope is invalid, unknown, or malformed.");
const invalidClientError = new MastoApiError(401, "invalid_client", "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.");
if (clientId == null) throw invalidClientError;
if (code == null) throw new MastoApiError(401, "Invalid code");
const app = await OAuthApps.findOneBy({
clientId: clientId
});
const token = await OAuthTokens.findOneBy({
code: code
});
this.validateRedirectUri(body.redirect_uri);
if (body.grant_type !== 'authorization_code') throw new MastoApiError(400, "Invalid grant_type");
if (!app || body.client_secret !== app.clientSecret) throw invalidClientError;
if (!token || app.id !== token.appId) throw new MastoApiError(401, "Invalid code");
if (difference(scopes, app.scopes).length > 0) throw invalidScopeError;
if (!app.redirectUris.includes(body.redirect_uri)) throw new MastoApiError(400, "Redirect URI not in list");
await OAuthTokens.update(token.id, {
active: true
});
return {
"access_token": token.token,
"token_type": "Bearer",
"scope": token.scopes.join(' '),
"created_at": Math.floor(token.createdAt.getTime() / 1000)
};
}
static async revokeAuthToken(ctx) {
const error = new MastoApiError(403, "unauthorized_client", "You are not authorized to revoke this token");
const body = ctx.request.body || ctx.request.query;
const clientId = toSingleLast(body.client_id);
const clientSecret = toSingleLast(body.client_secret);
const token = toSingleLast(body.token);
if (clientId == null || clientSecret == null || token == null) throw error;
const app = await OAuthApps.findOneBy({
clientId: clientId,
clientSecret: clientSecret
});
const oatoken = await OAuthTokens.findOneBy({
token: token
});
if (!app || !oatoken || app.id !== oatoken.appId) throw error;
await OAuthTokens.delete(oatoken.id);
return {};
}
static async verifyAppCredentials(ctx) {
console.log(ctx.appId);
if (!ctx.appId) throw new MastoApiError(401, "The access token is invalid");
const app = await OAuthApps.findOneByOrFail({
id: ctx.appId
});
return {
name: app.name,
website: app.website,
vapid_key: await fetchMeta().then((meta)=>meta.swPublicKey ?? undefined)
};
}
static validateRedirectUri(redirectUri) {
const error = new MastoApiError(400, "Invalid redirect_uri");
if (redirectUri == null) throw error;
if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') return;
try {
const url = new URL(redirectUri);
if ([
"javascript:",
"file:",
"data:",
"mailto:",
"tel:"
].includes(url.protocol)) throw error;
} catch {
throw error;
}
}
static readScopes = [
"read:accounts",
"read:blocks",
"read:bookmarks",
"read:favourites",
"read:filters",
"read:follows",
"read:lists",
"read:mutes",
"read:notifications",
"read:search",
"read:statuses"
];
static writeScopes = [
"write:accounts",
"write:blocks",
"write:bookmarks",
"write:conversations",
"write:favourites",
"write:filters",
"write:follows",
"write:lists",
"write:media",
"write:mutes",
"write:notifications",
"write:reports",
"write:statuses"
];
static followScopes = [
"read:follows",
"read:blocks",
"read:mutes",
"write:follows",
"write:blocks",
"write:mutes"
];
static expandScopes(scopes) {
const res = [];
for (const scope of scopes){
if (scope === "read") res.push(...this.readScopes);
else if (scope === "write") res.push(...this.writeScopes);
else if (scope === "follow") res.push(...this.followScopes);
res.push(scope);
}
return unique(res);
}
}
@@ -0,0 +1,161 @@
import { Blockings, Followings, UserListJoinings, UserLists } from "../../../../models/index.js";
import { PaginationHelpers } from "./pagination.js";
import { pushUserToUserList } from "../../../../services/user-list/push.js";
import { genId } from "../../../../misc/gen-id.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { pullUserFromUserList } from "../../../../services/user-list/pull.js";
import { publishUserEvent } from "../../../../services/stream.js";
export class ListHelpers {
static async getLists(ctx) {
const user = ctx.user;
return UserLists.findBy({
userId: user.id
}).then((p)=>p.map((list)=>{
return {
id: list.id,
title: list.name,
exclusive: list.hideFromHomeTl
};
}));
}
static async getList(id, ctx) {
const user = ctx.user;
return UserLists.findOneByOrFail({
userId: user.id,
id: id
}).then((list)=>{
return {
id: list.id,
title: list.name,
exclusive: list.hideFromHomeTl
};
});
}
static async getListOr404(id, ctx) {
return this.getList(id, ctx).catch((_)=>{
throw new MastoApiError(404);
});
}
static async getListUsers(id, maxId, sinceId, minId, limit = 40, ctx) {
if (limit > 80) limit = 80;
const user = ctx.user;
const list = await UserLists.findOneBy({
userId: user.id,
id: id
});
if (!list) throw new MastoApiError(404);
const query = PaginationHelpers.makePaginationQuery(UserListJoinings.createQueryBuilder('member'), sinceId, maxId, minId).andWhere("member.userListId = :listId", {
listId: list.id
}).innerJoinAndSelect("member.user", "user");
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((members)=>{
return members.map((p)=>p.user).filter((p)=>p);
});
}
static async deleteList(list, ctx) {
const user = ctx.user;
if (user.id != list.userId) throw new Error("List is not owned by user");
await UserLists.delete(list.id);
}
static async addToList(list, usersToAdd, ctx) {
const localUser = ctx.user;
if (localUser.id != list.userId) throw new Error("List is not owned by user");
for (const user of usersToAdd){
if (user.id !== localUser.id) {
const isBlocked = await Blockings.exist({
where: {
blockerId: user.id,
blockeeId: localUser.id
}
});
const isFollowed = await Followings.exist({
where: {
followeeId: user.id,
followerId: localUser.id
}
});
if (isBlocked) throw Error("Can't add users you've been blocked by to list");
if (!isFollowed) throw Error("Can't add users you're not following to list");
}
const exist = await UserListJoinings.exist({
where: {
userListId: list.id,
userId: user.id
}
});
if (exist) continue;
await pushUserToUserList(user, list);
}
}
static async removeFromList(list, usersToRemove, ctx) {
const localUser = ctx.user;
if (localUser.id != list.userId) throw new Error("List is not owned by user");
for (const user of usersToRemove){
const exist = await UserListJoinings.exist({
where: {
userListId: list.id,
userId: user.id
}
});
if (!exist) continue;
await pullUserFromUserList(user, list);
}
}
static async createList(title, ctx) {
if (title.length < 1) throw new MastoApiError(400, "Title must not be empty");
const user = ctx.user;
const list = await UserLists.insert({
id: genId(),
createdAt: new Date(),
userId: user.id,
name: title
}).then(async (res)=>await UserLists.findOneByOrFail(res.identifiers[0]));
return {
id: list.id,
title: list.name,
exclusive: list.hideFromHomeTl
};
}
static async updateList(list, title, exclusive, ctx) {
if (title.length < 1 && exclusive === undefined) throw new MastoApiError(400, "Either title or exclusive must be set");
const user = ctx.user;
if (user.id != list.userId) throw new Error("List is not owned by user");
const name = title.length > 0 ? title : undefined;
const partial = {
name: name,
hideFromHomeTl: exclusive
};
const result = await UserLists.update(list.id, partial).then(async (_)=>await UserLists.findOneByOrFail({
id: list.id
}));
if (exclusive !== undefined) {
UserListJoinings.findBy({
userListId: list.id
}).then((members)=>{
for (const member of members){
publishUserEvent(list.userId, exclusive ? "userHidden" : "userUnhidden", member.userId);
}
});
}
return {
id: result.id,
title: result.name,
exclusive: result.hideFromHomeTl
};
}
static async getListsByMember(member, ctx) {
const user = ctx.user;
const joinQuery = UserListJoinings.createQueryBuilder('member').select("member.userListId").where("member.userId = :memberId");
const query = UserLists.createQueryBuilder('list').where("list.userId = :userId", {
userId: user.id
}).andWhere(`list.id IN (${joinQuery.getQuery()})`).setParameters({
memberId: member.id
});
return query.getMany().then((results)=>results.map((result)=>{
return {
id: result.id,
title: result.name,
exclusive: result.hideFromHomeTl
};
}));
}
}
@@ -0,0 +1,63 @@
import { addFile } from "../../../../services/drive/add-file.js";
import { DriveFiles } from "../../../../models/index.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { toSingleLast } from "../../../../prelude/array.js";
export class MediaHelpers {
static async uploadMedia(ctx) {
const files = ctx.request.files;
const file = toSingleLast(files?.file);
const user = ctx.user;
const body = ctx.request.body;
if (!file) throw new MastoApiError(400, "Validation failed: File content type is invalid, File is invalid");
return addFile({
user: user,
path: file.filepath,
name: file.originalFilename !== null && file.originalFilename !== 'file' ? file.originalFilename : undefined,
comment: body?.description ?? undefined,
sensitive: false
}).then((p)=>DriveFiles.pack(p));
}
static async uploadMediaBasic(file, ctx) {
const user = ctx.user;
return addFile({
user: user,
path: file.filepath,
name: file.originalFilename !== null && file.originalFilename !== 'file' ? file.originalFilename : undefined,
sensitive: false
});
}
static async updateMedia(file, ctx) {
const user = ctx.user;
const body = ctx.request.body;
await DriveFiles.update(file.id, {
comment: body?.description ?? undefined
});
return DriveFiles.findOneByOrFail({
id: file.id,
userId: user.id
}).then((p)=>DriveFiles.pack(p));
}
static async getMediaPacked(id, ctx) {
const user = ctx.user;
return this.getMedia(id, ctx).then((p)=>p ? DriveFiles.pack(p) : null);
}
static async getMediaPackedOr404(id, ctx) {
return this.getMediaPacked(id, ctx).then((p)=>{
if (p) return p;
throw new MastoApiError(404);
});
}
static async getMedia(id, ctx) {
const user = ctx.user;
return DriveFiles.findOneBy({
id: id,
userId: user.id
});
}
static async getMediaOr404(id, ctx) {
return this.getMedia(id, ctx).then((p)=>{
if (p) return p;
throw new MastoApiError(404);
});
}
}
@@ -0,0 +1,191 @@
import { Window as HappyDom } from "happy-dom";
import config from "../../../../config/index.js";
import { intersperse } from "../../../../prelude/array.js";
import { resolveMentionFromCache } from "../../../../remote/resolve-user.js";
export class MfmHelpers {
static async toHtml(nodes, mentionedRemoteUsers = [], objectHost, inline = false, quoteUri = null) {
if (nodes == null) {
return null;
}
const window = new HappyDom();
const doc = window.document;
function appendTextWithGlyphs(text, targetElement) {
const regexp = /;([^:;\s]{1,100});/g;
let last = 0;
for (const match of text.matchAll(regexp)){
if (match.index > last) {
targetElement.appendChild(doc.createTextNode(text.slice(last, match.index)));
}
targetElement.appendChild(doc.createTextNode(`\u200B:${match[1]}:\u200B`));
last = match.index + match[0].length;
}
if (last < text.length) {
targetElement.appendChild(doc.createTextNode(text.slice(last)));
}
}
async function appendChildren(children, targetElement) {
if (children) {
for (const child of (await Promise.all(children.map(async (x)=>await handlers[x.type](x)))))targetElement.appendChild(child);
}
}
const handlers = {
async bold (node) {
const el = doc.createElement("span");
el.textContent = '**';
await appendChildren(node.children, el);
el.textContent += '**';
return el;
},
async small (node) {
const el = doc.createElement("small");
await appendChildren(node.children, el);
return el;
},
async strike (node) {
const el = doc.createElement("span");
el.textContent = '~~';
await appendChildren(node.children, el);
el.textContent += '~~';
return el;
},
async italic (node) {
const el = doc.createElement("span");
el.textContent = '*';
await appendChildren(node.children, el);
el.textContent += '*';
return el;
},
async fn (node) {
const el = doc.createElement("span");
el.textContent = '*';
await appendChildren(node.children, el);
el.textContent += '*';
return el;
},
blockCode (node) {
const pre = doc.createElement("pre");
const inner = doc.createElement("code");
const nodes = node.props.code.split(/\r\n|\r|\n/).map((x)=>doc.createTextNode(x));
for (const x of intersperse("br", nodes)){
inner.appendChild(x === "br" ? doc.createElement("br") : x);
}
pre.appendChild(inner);
return pre;
},
async center (node) {
const el = doc.createElement("div");
await appendChildren(node.children, el);
return el;
},
emojiCode (node) {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji (node) {
return doc.createTextNode(node.props.emoji);
},
hashtag (node) {
const a = doc.createElement("a");
a.setAttribute('href', `${config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute("rel", "tag");
a.setAttribute("class", "hashtag");
return a;
},
inlineCode (node) {
const el = doc.createElement("code");
el.textContent = node.props.code;
return el;
},
mathInline (node) {
const el = doc.createElement("code");
el.textContent = node.props.formula;
return el;
},
mathBlock (node) {
const el = doc.createElement("code");
el.textContent = node.props.formula;
return el;
},
async link (node) {
const a = doc.createElement("a");
a.setAttribute("rel", "nofollow noopener noreferrer");
a.setAttribute("target", "_blank");
a.setAttribute('href', node.props.url);
await appendChildren(node.children, a);
return a;
},
async mention (node) {
const { username, host, acct } = node.props;
const resolved = await resolveMentionFromCache(username, host, objectHost, mentionedRemoteUsers);
const el = doc.createElement("span");
if (resolved === null) {
el.textContent = acct;
} else {
el.setAttribute("class", "h-card");
el.setAttribute("translate", "no");
const a = doc.createElement("a");
a.setAttribute('href', resolved.href);
a.className = "u-url mention";
const span = doc.createElement("span");
span.textContent = resolved.username;
a.textContent = '@';
a.appendChild(span);
el.appendChild(a);
}
return el;
},
async quote (node) {
const el = doc.createElement("blockquote");
await appendChildren(node.children, el);
return el;
},
text (node) {
const el = doc.createElement("span");
const lines = node.props.text.split(/\r\n|\r|\n/);
for (const x of intersperse("br", lines)){
if (x === "br") {
el.appendChild(doc.createElement("br"));
continue;
}
appendTextWithGlyphs(x, el);
}
return el;
},
url (node) {
const a = doc.createElement("a");
a.setAttribute("rel", "nofollow noopener noreferrer");
a.setAttribute("target", "_blank");
a.setAttribute('href', node.props.url);
a.textContent = node.props.url.replace(/^https?:\/\//, '');
return a;
},
search (node) {
const a = doc.createElement("a");
a.setAttribute('href', `${config.searchEngine}${node.props.query}`);
a.textContent = node.props.content;
return a;
},
async plain (node) {
const el = doc.createElement("span");
await appendChildren(node.children, el);
return el;
}
};
await appendChildren(nodes, doc.body);
if (quoteUri !== null) {
const a = doc.createElement("a");
a.setAttribute('href', quoteUri);
a.textContent = quoteUri.replace(/^https?:\/\//, '');
const quote = doc.createElement("span");
quote.setAttribute("class", "quote-inline");
quote.appendChild(doc.createElement("br"));
quote.appendChild(doc.createElement("br"));
quote.innerHTML += 'RE: ';
quote.appendChild(a);
doc.body.appendChild(quote);
}
const html = inline ? doc.body.innerHTML : `<p>${doc.body.innerHTML}</p>`;
await window.happyDOM.close();
return html;
}
}
@@ -0,0 +1,215 @@
import config from "../../../../config/index.js";
import { FILE_TYPE_BROWSERSAFE, MAX_NOTE_TEXT_LENGTH } from "../../../../const.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { AnnouncementReads, Announcements, Emojis, Instances, Notes, UserProfiles, Users } from "../../../../models/index.js";
import { IsNull } from "typeorm";
import { awaitAll } from "../../../../prelude/await-all.js";
import { UserConverter } from "../converters/user.js";
import { AnnouncementConverter } from "../converters/announcement.js";
import { genId } from "../../../../misc/gen-id.js";
import * as Acct from "../../../../misc/acct.js";
import { UserHelpers } from "./user.js";
import { generateMutedUserQueryForUsers } from "../../common/generate-muted-user-query.js";
import { generateBlockQueryForUsers } from "../../common/generate-block-query.js";
import { uniqBy } from "../../../../prelude/array.js";
import { EmojiConverter } from "../converters/emoji.js";
import { populateEmojis } from "../../../../misc/populate-emojis.js";
import { NoteConverter } from "../converters/note.js";
import { VisibilityConverter } from "../converters/visibility.js";
export class MiscHelpers {
static async getInstance(ctx) {
const userCount = Users.count({
where: {
host: IsNull()
}
});
const noteCount = Notes.count({
where: {
userHost: IsNull()
}
});
const instanceCount = Instances.count({
cache: 3600000
});
const contact = await Users.findOne({
where: {
host: IsNull(),
isAdmin: true,
isDeleted: false,
isSuspended: false
},
order: {
id: "ASC"
}
}).then((p)=>p ? UserConverter.encode(p, ctx) : null);
const meta = await fetchMeta(true);
const res = {
uri: config.domain,
title: meta.name || "FrozenFriendsYume",
short_description: meta.description?.substring(0, 50) || "This is an FrozenFriendsYume instance. It doesn't seem to have a description.",
description: meta.description || "This is an FrozenFriendsYume instance. It doesn't seem to have a description.",
email: meta.maintainerEmail || "",
version: `4.2.1 (compatible; FrozenFriendsYume ${config.version})`,
urls: {
streaming_api: `${config.url.replace(/^http(?=s?:\/\/)/, "ws")}`
},
stats: awaitAll({
user_count: userCount,
status_count: noteCount,
domain_count: instanceCount
}),
max_toot_chars: MAX_NOTE_TEXT_LENGTH,
thumbnail: meta.bannerUrl || "/static-assets/transparent.png",
languages: meta.langs,
registrations: !meta.disableRegistration,
approval_required: meta.disableRegistration,
invites_enabled: meta.disableRegistration,
configuration: {
accounts: {
max_featured_tags: 20
},
statuses: {
supported_mime_types: [
'text/x.misskeymarkdown'
],
max_characters: MAX_NOTE_TEXT_LENGTH,
max_media_attachments: 16,
characters_reserved_per_url: 23
},
media_attachments: {
supported_mime_types: FILE_TYPE_BROWSERSAFE,
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_limit: 60,
video_matrix_limit: 2304000
},
polls: {
max_options: 10,
max_characters_per_option: 50,
min_expiration: 50,
max_expiration: 2629746
},
reactions: {
max_reactions: 1,
default_reaction: meta.defaultReaction
}
},
contact_account: contact,
rules: []
};
return awaitAll(res);
}
static async getAnnouncements(includeRead = false, ctx) {
const user = ctx.user;
if (includeRead) {
const [announcements, reads] = await Promise.all([
Announcements.createQueryBuilder("announcement").orderBy({
"announcement.id": "DESC"
}).getMany(),
AnnouncementReads.findBy({
userId: user.id
}).then((p)=>p.map((x)=>x.announcementId))
]);
return Promise.all(announcements.map(async (p)=>AnnouncementConverter.encode(p, reads.includes(p.id))));
}
const sq = AnnouncementReads.createQueryBuilder("reads").select("reads.announcementId").where("reads.userId = :userId");
const query = Announcements.createQueryBuilder("announcement").where(`announcement.id NOT IN (${sq.getQuery()})`).orderBy({
"announcement.id": "DESC"
}).setParameter("userId", user.id);
return query.getMany().then((p)=>Promise.all(p.map(async (x)=>AnnouncementConverter.encode(x, false))));
}
static async dismissAnnouncement(announcement, ctx) {
const user = ctx.user;
const exists = await AnnouncementReads.exist({
where: {
userId: user.id,
announcementId: announcement.id
}
});
if (!exists) {
await AnnouncementReads.insert({
id: genId(),
createdAt: new Date(),
userId: user.id,
announcementId: announcement.id
});
}
}
static async getFollowSuggestions(limit, ctx) {
const user = ctx.user;
const results = [];
const pinned = fetchMeta().then((meta)=>Promise.all(meta.pinnedUsers.map((acct)=>Acct.parse(acct)).map((acct)=>Users.findOneBy({
usernameLower: acct.username.toLowerCase(),
host: acct.host ?? IsNull()
}))).then((p)=>p.filter((x)=>!!x)).then((p)=>UserConverter.encodeMany(p, ctx)).then((p)=>p.map((x)=>{
return {
source: "staff",
account: x
};
})));
const query = Users.createQueryBuilder("user").where("user.isExplorable = TRUE").andWhere("user.host IS NULL").orderBy("user.followersCount", "DESC").andWhere("user.updatedAt > :date", {
date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5)
});
generateMutedUserQueryForUsers(query, user);
generateBlockQueryForUsers(query, user);
const global = query.take(limit).getMany().then((p)=>UserConverter.encodeMany(p, ctx)).then((p)=>p.map((x)=>{
return {
source: "global",
account: x
};
}));
results.push(pinned);
results.push(global);
return Promise.all(results).then((p)=>uniqBy(p.flat(), (x)=>x.account.id).slice(0, limit));
}
static async getCustomEmoji() {
return Emojis.find({
where: {
host: IsNull()
},
order: {
category: "ASC",
name: "ASC"
},
cache: {
id: "meta_emojis",
milliseconds: 3600000
}
}).then((dbRes)=>populateEmojis(dbRes.map((p)=>p.name), null).then((p)=>p.map((x)=>EmojiConverter.encode(x)).map((x)=>{
return {
...x,
category: dbRes.find((y)=>y.name === x.shortcode)?.category ?? undefined
};
})));
}
static async getTrendingStatuses(limit = 20, offset = 0, ctx) {
if (limit > 40) limit = 40;
const query = Notes.createQueryBuilder("note").addSelect("note.score").andWhere("note.score > 0").andWhere("note.createdAt > :date", {
date: new Date(Date.now() - 1000 * 60 * 60 * 24)
}).andWhere("note.visibility = 'public'").andWhere("note.userHost IS NULL").orderBy("note.score", "DESC");
return query.skip(offset).take(limit).getMany().then((result)=>NoteConverter.encodeMany(result, ctx));
}
static async getTrendingHashtags(limit = 10, offset = 0) {
if (limit > 20) limit = 20;
return [];
//FIXME: This was already implemented in api/endpoints/hashtags/trend.ts, but the implementation is sketchy at best. Rewrite from scratch.
}
static getPreferences(ctx) {
const user = ctx.user;
const profile = UserProfiles.findOneByOrFail({
userId: user.id
});
const sensitive = profile.then((p)=>p.alwaysMarkNsfw);
const language = profile.then((p)=>p.lang);
const privacy = UserHelpers.getDefaultNoteVisibility(ctx).then((p)=>VisibilityConverter.encode(p));
const res = {
"posting:default:visibility": privacy,
"posting:default:sensitive": sensitive,
"posting:default:language": language,
"reading:expand:media": "default",
"reading:expand:spoilers": false //FIXME: store this on server instead of client
};
return awaitAll(res);
}
}
@@ -0,0 +1,366 @@
import { makePaginationQuery } from "../../common/make-pagination-query.js";
import { DriveFiles, Metas, NoteEdits, NoteFavorites, NoteReactions, Notes, UserNotePinings } 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 { getNote } from "../../common/getters.js";
import createReaction from "../../../../services/note/reaction/create.js";
import deleteReaction from "../../../../services/note/reaction/delete.js";
import createNote, { extractMentionedUsers } from "../../../../services/note/create.js";
import editNote from "../../../../services/note/edit.js";
import deleteNote from "../../../../services/note/delete.js";
import { genId } from "../../../../misc/gen-id.js";
import { PaginationHelpers } from "./pagination.js";
import { UserConverter } from "../converters/user.js";
import { UserHelpers } from "./user.js";
import { addPinned, removePinned } from "../../../../services/i/pin.js";
import { NoteConverter } from "../converters/note.js";
import { awaitAll } from "../../../../prelude/await-all.js";
import { VisibilityConverter } from "../converters/visibility.js";
import mfm from "mfm-js";
import { FileConverter } from "../converters/file.js";
import { MfmHelpers } from "./mfm.js";
import { toArray, unique } from "../../../../prelude/array.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { Cache } from "../../../../misc/cache.js";
import AsyncLock from "async-lock";
import { IdentifiableError } from "../../../../misc/identifiable-error.js";
import { IsNull } from "typeorm";
import { getStubMastoContext } from "../index.js";
export class NoteHelpers {
static postIdempotencyCache = new Cache('postIdempotencyCache', 60 * 60);
static postIdempotencyLocks = new AsyncLock();
static async getDefaultReaction() {
return Metas.createQueryBuilder().select('"defaultReaction"').execute().then((p)=>p[0].defaultReaction).then((p)=>{
if (p != null) return p;
throw new MastoApiError(500, "Failed to get default reaction");
});
}
static async reactToNote(note, reaction, ctx) {
const user = ctx.user;
await createReaction(user, note, reaction).catch((e)=>{
if (e instanceof IdentifiableError && e.id == '51c42bb4-931a-456b-bff7-e5a8a70dd298') return;
throw e;
});
return getNote(note.id, user);
}
static async removeReactFromNote(note, ctx) {
const user = ctx.user;
await deleteReaction(user, note);
return getNote(note.id, user);
}
static async reblogNote(note, ctx) {
const user = ctx.user;
const existingRenote = await Notes.findOneBy({
userId: user.id,
renoteId: note.id,
text: IsNull()
});
if (existingRenote) return existingRenote;
const data = {
createdAt: new Date(),
files: [],
renote: note
};
return await createNote(user, data);
}
static async unreblogNote(note, ctx) {
const user = ctx.user;
return Notes.findBy({
userId: user.id,
renoteId: note.id
}).then((p)=>p.map((n)=>deleteNote(user, n))).then((p)=>Promise.all(p)).then((_)=>getNote(note.id, user));
}
static async bookmarkNote(note, ctx) {
const user = ctx.user;
const bookmarked = await NoteFavorites.exist({
where: {
noteId: note.id,
userId: user.id
}
});
if (!bookmarked) {
await NoteFavorites.insert({
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id
});
}
return note;
}
static async unbookmarkNote(note, ctx) {
const user = ctx.user;
return NoteFavorites.findOneBy({
noteId: note.id,
userId: user.id
}).then((p)=>p !== null ? NoteFavorites.delete(p.id) : null).then((_)=>note);
}
static async pinNote(note, ctx) {
const user = ctx.user;
const pinned = await UserNotePinings.exist({
where: {
userId: user.id,
noteId: note.id
}
});
if (!pinned) {
await addPinned(user, note.id);
}
return note;
}
static async unpinNote(note, ctx) {
const user = ctx.user;
const pinned = await UserNotePinings.exist({
where: {
userId: user.id,
noteId: note.id
}
});
if (pinned) {
await removePinned(user, note.id);
}
return note;
}
static async deleteNote(note, ctx) {
const user = ctx.user;
if (user.id !== note.userId) throw new MastoApiError(404);
const status = await NoteConverter.encode(note, ctx);
await deleteNote(user, note);
status.content = undefined;
return status;
}
static async getNoteFavoritedBy(note, maxId, sinceId, minId, limit = 40, ctx) {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(NoteReactions.createQueryBuilder("reaction"), sinceId, maxId, minId).andWhere("reaction.noteId = :noteId", {
noteId: note.id
}).innerJoinAndSelect("reaction.user", "user");
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((reactions)=>{
return reactions.map((p)=>p.user).filter((p)=>p);
});
}
static async getNoteEditHistory(note, ctx) {
const user = Promise.resolve(note.user ?? await UserHelpers.getUserCached(note.userId, ctx));
const account = user.then((p)=>UserConverter.encode(p, ctx));
const edits = await NoteEdits.find({
where: {
noteId: note.id
},
order: {
id: "ASC"
}
});
const history = [];
const curr = {
id: note.id,
noteId: note.id,
note: note,
text: note.text,
cw: note.cw,
fileIds: note.fileIds,
updatedAt: note.updatedAt ?? note.createdAt
};
edits.push(curr);
let lastDate = note.createdAt;
for (const edit of edits){
const files = DriveFiles.packMany(edit.fileIds);
const item = {
account: account,
content: MfmHelpers.toHtml(mfm.parse(edit.text ?? ''), JSON.parse(note.mentionedRemoteUsers), note.userHost).then((p)=>p ?? ''),
created_at: lastDate.toISOString(),
emojis: [],
sensitive: files.then((files)=>files.length > 0 ? files.some((f)=>f.isSensitive) : false),
spoiler_text: edit.cw ?? '',
poll: null,
media_attachments: files.then((files)=>files.length > 0 ? files.map((f)=>FileConverter.encode(f)) : [])
};
lastDate = edit.updatedAt;
history.push(awaitAll(item));
}
return Promise.all(history);
}
static getNoteSource(note) {
return {
id: note.id,
text: note.text ?? '',
spoiler_text: note.cw ?? '',
content_type: 'text/x.misskeymarkdown'
};
}
static async getNoteRebloggedBy(note, maxId, sinceId, minId, limit = 40, ctx) {
if (limit > 80) limit = 80;
const user = ctx.user;
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).andWhere("note.renoteId = :noteId", {
noteId: note.id
}).andWhere("note.text IS NULL") // We don't want to count quotes as renotes
.andWhere('note.hasPoll = FALSE').andWhere("note.fileIds = '{}'").innerJoinAndSelect("note.user", "user");
generateVisibilityQuery(query, user);
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx).then((renotes)=>{
return renotes.map((p)=>p.user).filter((p)=>p);
});
}
static async getNoteDescendants(note, limit = 10, depth = 2, ctx) {
const user = ctx.user;
const noteId = typeof note === "string" ? note : note.id;
const query = makePaginationQuery(Notes.createQueryBuilder("note")).andWhere("note.id IN (SELECT id FROM note_replies(:noteId, :depth, :limit))", {
noteId,
depth,
limit
});
generateVisibilityQuery(query, user);
if (user) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
return query.getMany().then((p)=>p.reverse());
}
static async getNoteAncestors(rootNote, limit = 10, ctx) {
const user = ctx.user;
const notes = new Array;
for(let i = 0; i < limit; i++){
const currentNote = notes.at(-1) ?? rootNote;
if (!currentNote.replyId) break;
const nextNote = await getNote(currentNote.replyId, user).catch((e)=>{
if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") return null;
throw e;
});
if (nextNote && await Notes.isVisibleForMe(nextNote, user?.id ?? null)) notes.push(nextNote);
else break;
}
return notes.reverse();
}
static async createNote(request, ctx) {
const user = ctx.user;
const files = request.media_ids && request.media_ids.length > 0 ? DriveFiles.findByIds(request.media_ids) : [];
const reply = request.in_reply_to_id ? await getNote(request.in_reply_to_id, user) : undefined;
const renote = request.quote_id ? await getNote(request.quote_id, user) : undefined;
const visibility = request.visibility ?? UserHelpers.getDefaultNoteVisibility(ctx);
const data = {
createdAt: new Date(),
files: files,
poll: request.poll ? {
choices: request.poll.options,
multiple: request.poll.multiple,
expiresAt: request.poll.expires_in && request.poll.expires_in > 0 ? new Date(new Date().getTime() + request.poll.expires_in * 1000) : null
} : undefined,
text: request.text,
reply: reply,
renote: renote,
cw: request.spoiler_text,
visibility: visibility,
visibleUsers: Promise.resolve(visibility).then((p)=>p === 'specified' ? this.extractMentions(request.text ?? '', ctx) : undefined)
};
return createNote(user, await awaitAll(data));
}
static async editNote(request, note, ctx) {
const user = ctx.user;
const files = request.media_ids && request.media_ids.length > 0 ? DriveFiles.findByIds(request.media_ids) : [];
const data = {
files: files,
poll: request.poll ? {
choices: request.poll.options,
multiple: request.poll.multiple,
expiresAt: request.poll.expires_in && request.poll.expires_in > 0 ? new Date(new Date().getTime() + request.poll.expires_in * 1000) : null
} : null,
text: request.text,
cw: request.spoiler_text
};
return editNote(user, note, await awaitAll(data));
}
static async extractMentions(text, ctx) {
const user = ctx.user;
return extractMentionedUsers(user, mfm.parse(text));
}
static normalizeComposeOptions(body) {
const result = {};
if (body.status != null && body.status.trim().length > 0) result.text = body.status;
if (body.spoiler_text != null && body.spoiler_text.trim().length > 0) result.spoiler_text = body.spoiler_text;
if (body.visibility != null) result.visibility = VisibilityConverter.decode(body.visibility);
if (body.language != null) result.language = body.language;
if (body.scheduled_at != null) result.scheduled_at = new Date(Date.parse(body.scheduled_at));
if (body.in_reply_to_id) result.in_reply_to_id = body.in_reply_to_id;
if (body.quoted_status_id ?? body.quote_id) result.quote_id = body.quoted_status_id ?? body.quote_id;
if (body.media_ids) result.media_ids = body.media_ids && body.media_ids.length > 0 ? toArray(body.media_ids) : undefined;
if (body.poll) {
result.poll = {
expires_in: parseInt(body.poll.expires_in, 10),
options: body.poll.options,
multiple: !!body.poll.multiple
};
}
result.sensitive = !!body.sensitive;
return result;
}
static normalizeEditOptions(body) {
const result = {};
if (body.status != null && body.status.trim().length > 0) result.text = body.status;
if (body.spoiler_text != null && body.spoiler_text.trim().length > 0) result.spoiler_text = body.spoiler_text;
if (body.language != null) result.language = body.language;
if (body.media_ids) result.media_ids = body.media_ids && body.media_ids.length > 0 ? toArray(body.media_ids) : undefined;
if (body.poll) {
result.poll = {
expires_in: parseInt(body.poll.expires_in, 10),
options: body.poll.options,
multiple: !!body.poll.multiple
};
}
result.sensitive = !!body.sensitive;
return result;
}
static async getNoteOr404(id, ctx) {
const user = ctx.user;
return getNote(id, user).catch((_)=>{
throw new MastoApiError(404);
});
}
static async getConversationFromEvent(noteId, user) {
const ctx = getStubMastoContext(user);
const note = await getNote(noteId, ctx.user);
const conversationId = note.threadId ?? note.id;
const userIds = unique([
note.userId
].concat(note.visibleUserIds).filter((p)=>p != ctx.user.id));
const users = userIds.map((id)=>UserHelpers.getUserCached(id, ctx).catch((_)=>null));
const accounts = Promise.all(users).then((u)=>UserConverter.encodeMany(u.filter((u)=>u), ctx));
const res = {
id: conversationId,
accounts: accounts.then((u)=>u.length > 0 ? u : UserConverter.encodeMany([
ctx.user
], ctx)),
last_status: NoteConverter.encode(note, ctx),
unread: true
};
return awaitAll(res);
}
static fixupEventNote(note) {
note.createdAt = note.createdAt ? new Date(note.createdAt) : note.createdAt;
note.updatedAt = note.updatedAt ? new Date(note.updatedAt) : note.updatedAt;
note.reply = null;
note.renote = null;
note.user = null;
return note;
}
static getIdempotencyKey(ctx) {
const headers = ctx.headers;
const user = ctx.user;
if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null;
return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1) : headers["idempotency-key"]}`;
}
static async getFromIdempotencyCache(key) {
return this.postIdempotencyLocks.acquire(key, async ()=>{
if (await this.postIdempotencyCache.get(key) !== undefined) {
let i = 5;
while((await this.postIdempotencyCache.get(key))?.status === undefined){
if (++i > 5) throw new Error('Post is duplicate but unable to resolve original');
await new Promise((resolve)=>{
setTimeout(resolve, 500);
});
}
return (await this.postIdempotencyCache.get(key))?.status;
} else {
await this.postIdempotencyCache.set(key, {});
return undefined;
}
});
}
}
@@ -0,0 +1,83 @@
import { Notes, Notifications } from "../../../../models/index.js";
import { PaginationHelpers } from "./pagination.js";
import { MastoApiError } from "../middleware/catch-errors.js";
export class NotificationHelpers {
static async getNotifications(maxId, sinceId, minId, limit = 40, types, excludeTypes, accountId, ctx) {
if (limit > 80) limit = 80;
const user = ctx.user;
let requestedTypes = types ? this.decodeTypes(types) : [
'follow',
'mention',
'reply',
'renote',
'quote',
'reaction',
'pollEnded',
'receiveFollowRequest'
];
if (excludeTypes) {
const excludedTypes = this.decodeTypes(excludeTypes);
requestedTypes = requestedTypes.filter((p)=>!excludedTypes.includes(p));
}
const query = PaginationHelpers.makePaginationQuery(Notifications.createQueryBuilder("notification"), sinceId, maxId, minId).andWhere("notification.notifieeId = :userId", {
userId: user.id
}).andWhere("notification.type IN (:...types)", {
types: requestedTypes
});
if (accountId !== undefined) query.andWhere("notification.notifierId = :notifierId", {
notifierId: accountId
});
query.leftJoinAndSelect("notification.note", "note").leftJoinAndSelect("notification.notifier", "notifier").leftJoinAndSelect("notification.notifiee", "notifiee");
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx);
}
static async getNotification(id, ctx) {
const user = ctx.user;
return Notifications.findOneBy({
id: id,
notifieeId: user.id
});
}
static async getNotificationOr404(id, ctx) {
return this.getNotification(id, ctx).then((p)=>{
if (p) return p;
throw new MastoApiError(404);
});
}
static async dismissNotification(id, ctx) {
const user = ctx.user;
await Notifications.update({
id: id,
notifieeId: user.id
}, {
isRead: true
});
}
static async clearAllNotifications(ctx) {
const user = ctx.user;
await Notifications.update({
notifieeId: user.id
}, {
isRead: true
});
}
static async markConversationAsRead(id, ctx) {
const user = ctx.user;
const notesQuery = Notes.createQueryBuilder("note").select("note.id").andWhere("COALESCE(note.threadId, note.id) = :conversationId");
await Notifications.createQueryBuilder("notification").where(`notification."noteId" IN (${notesQuery.getQuery()})`).andWhere(`notification."notifieeId" = :userId`).andWhere(`notification."isRead" = FALSE`).andWhere("notification.type IN (:...types)").setParameter("userId", user.id).setParameter("conversationId", id).setParameter("types", [
'reply',
'mention'
]).update().set({
isRead: true
}).execute();
}
static decodeTypes(types) {
const result = [];
if (types.includes('follow')) result.push('follow');
if (types.includes('mention')) result.push('mention', 'reply');
if (types.includes('reblog')) result.push('renote', 'quote');
if (types.includes('favourite')) result.push('reaction');
if (types.includes('poll')) result.push('pollEnded');
if (types.includes('follow_request')) result.push('receiveFollowRequest');
return result;
}
}
@@ -0,0 +1,56 @@
import { generatePaginationData } from "../middleware/pagination.js";
export class PaginationHelpers {
static makePaginationQuery(q, sinceId, maxId, minId, idField = `${q.alias}.id`) {
if (sinceId && minId) throw new Error("Can't user both sinceId and minId params");
if (sinceId && maxId) {
q.andWhere(`${idField} > :sinceId`, {
sinceId: sinceId
});
q.andWhere(`${idField} < :maxId`, {
maxId: maxId
});
q.orderBy(`${idField}`, "DESC");
}
if (minId && maxId) {
q.andWhere(`${idField} > :minId`, {
minId: minId
});
q.andWhere(`${idField} < :maxId`, {
maxId: maxId
});
q.orderBy(`${idField}`, "ASC");
} else if (sinceId) {
q.andWhere(`${idField} > :sinceId`, {
sinceId: sinceId
});
q.orderBy(`${idField}`, "DESC");
} else if (minId) {
q.andWhere(`${idField} > :minId`, {
minId: minId
});
q.orderBy(`${idField}`, "ASC");
} else if (maxId) {
q.andWhere(`${idField} < :maxId`, {
maxId: maxId
});
q.orderBy(`${idField}`, "DESC");
} else {
q.orderBy(`${idField}`, "DESC");
}
return q;
}
/**
*
* @param query
* @param limit
* @param reverse whether the result needs to be .reverse()'d. Set this to true when the parameter minId is not undefined in the original request.
*/ static async execQuery(query, limit, reverse) {
return query.take(limit).getMany().then((found)=>reverse ? found.reverse() : found);
}
static async execQueryLinkPagination(query, limit, reverse, ctx) {
return this.execQuery(query, limit, reverse).then((p)=>{
ctx.pagination = generatePaginationData(p.map((x)=>x.id), limit);
return p;
});
}
}
@@ -0,0 +1,99 @@
import { populatePoll } from "../../../../models/repositories/note.js";
import { PollConverter } from "../converters/poll.js";
import { Blockings, Notes, NoteWatchings, Polls, PollVotes, Users } from "../../../../models/index.js";
import { genId } from "../../../../misc/gen-id.js";
import { publishNoteStream } from "../../../../services/stream.js";
import { createNotification } from "../../../../services/create-notification.js";
import { deliver } from "../../../../queue/index.js";
import { renderActivity } from "../../../../remote/activitypub/renderer/index.js";
import renderVote from "../../../../remote/activitypub/renderer/vote.js";
import { Not } from "typeorm";
import { MastoApiError } from "../middleware/catch-errors.js";
import { populateEmojis } from "../../../../misc/populate-emojis.js";
import { EmojiConverter } from "../converters/emoji.js";
import { UserHelpers } from "./user.js";
export class PollHelpers {
static async getPoll(note, ctx) {
const user = ctx.user;
if (!await Notes.isVisibleForMe(note, user?.id ?? null)) throw new Error('Cannot encode poll not visible for user');
const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, ctx);
const host = Promise.resolve(noteUser).then((noteUser)=>noteUser.host ?? null);
const noteEmoji = await host.then(async (host)=>populateEmojis(note.emojis, host).then((noteEmoji)=>noteEmoji.filter((e)=>e.name.indexOf("@") === -1).map((e)=>EmojiConverter.encode(e))));
return populatePoll(note, user?.id ?? null).then((p)=>PollConverter.encode(p, note.id, noteEmoji));
}
static async voteInPoll(choices, note, ctx) {
if (!note.hasPoll) throw new MastoApiError(404);
const user = ctx.user;
for (const choice of choices){
const createdAt = new Date();
if (!note.hasPoll) throw new MastoApiError(404);
// Check blocking
if (note.userId !== user.id) {
const block = await Blockings.findOneBy({
blockerId: note.userId,
blockeeId: user.id
});
if (block) throw new Error('You are blocked by the poll author');
}
const poll = await Polls.findOneByOrFail({
noteId: note.id
});
if (poll.expiresAt && poll.expiresAt < createdAt) throw new Error('Poll is expired');
if (poll.choices[choice] == null) throw new Error('Invalid choice');
// if already voted
const exist = await PollVotes.findBy({
noteId: note.id,
userId: user.id
});
if (exist.length) {
if (poll.multiple) {
if (exist.some((x)=>x.choice === choice)) throw new Error('You already voted for this option');
} else {
throw new Error('You already voted in this poll');
}
}
// Create vote
const vote = await PollVotes.insert({
id: genId(),
createdAt,
noteId: note.id,
userId: user.id,
choice: choice
}).then((x)=>PollVotes.findOneByOrFail(x.identifiers[0]));
// Increment votes count
const index = choice + 1; // In SQL, array index is 1 based
await Polls.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
publishNoteStream(note.id, "pollVoted", {
choice: choice,
userId: user.id
});
// Notify
createNotification(note.userId, "pollVote", {
notifierId: user.id,
noteId: note.id,
choice: choice
});
// Fetch watchers
NoteWatchings.findBy({
noteId: note.id,
userId: Not(user.id)
}).then((watchers)=>{
for (const watcher of watchers){
createNotification(watcher.userId, "pollVote", {
notifierId: user.id,
noteId: note.id,
choice: choice
});
}
});
// リモート投票の場合リプライ送信
if (note.userHost != null) {
const pollOwner = await Users.findOneByOrFail({
id: note.userId
});
deliver(user, renderActivity(await renderVote(user, vote, note, poll, pollOwner)), pollOwner.inbox);
}
}
return this.getPoll(note, ctx);
}
}
@@ -0,0 +1,181 @@
import { Followings, Hashtags, Notes, Users } from "../../../../models/index.js";
import { sqlLikeEscape } from "../../../../misc/sql-like-escape.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 { PaginationHelpers } from "./pagination.js";
import { Brackets, IsNull } from "typeorm";
import { awaitAll } from "../../../../prelude/await-all.js";
import { NoteConverter } from "../converters/note.js";
import Resolver from "../../../../remote/activitypub/resolver.js";
import { getApId, isActor, isPost } from "../../../../remote/activitypub/type.js";
import DbResolver from "../../../../remote/activitypub/db-resolver.js";
import { createPerson } from "../../../../remote/activitypub/models/person.js";
import { UserConverter } from "../converters/user.js";
import { resolveUser } from "../../../../remote/resolve-user.js";
import { createNote } from "../../../../remote/activitypub/models/note.js";
import config from "../../../../config/index.js";
import { logger } from "../index.js";
import { generateFtsQuery } from "../../common/generate-fts-query.js";
export class SearchHelpers {
static async search(q, type, resolve = false, following = false, accountId, excludeUnreviewed = false, maxId, minId, limit = 20, offset, ctx) {
if (q === undefined || q.trim().length === 0) throw new Error('Search query cannot be empty');
if (limit > 40) limit = 40;
const user = ctx.user;
const notes = type === 'statuses' || !type ? this.searchNotes(q, resolve, following, accountId, maxId, minId, limit, offset, ctx) : [];
const users = type === 'accounts' || !type ? this.searchUsers(q, resolve, following, maxId, minId, limit, offset, ctx) : [];
const tags = type === 'hashtags' || !type ? this.searchTags(q, excludeUnreviewed, limit, offset) : [];
const result = {
statuses: Promise.resolve(notes).then((p)=>NoteConverter.encodeMany(p, ctx)),
accounts: Promise.resolve(users).then((p)=>UserConverter.encodeMany(p, ctx)),
hashtags: Promise.resolve(tags)
};
return awaitAll(result);
}
static async searchUsers(q, resolve, following, maxId, minId, limit, offset, ctx) {
const user = ctx.user;
if (resolve) {
try {
if (q.startsWith('https://') || q.startsWith('http://')) {
// try resolving locally first
const dbResolver = new DbResolver();
const dbResult = await dbResolver.getUserFromApId(q);
if (dbResult) return [
dbResult
];
// ask remote
const resolver = new Resolver();
resolver.setUser(user);
const object = await resolver.resolve(q);
if (q !== object.id) {
const result = await dbResolver.getUserFromApId(getApId(object));
if (result) return [
result
];
}
return isActor(object) ? Promise.all([
createPerson(getApId(object), resolver.reset())
]) : [];
} else {
let match = q.match(/^@?(?<user>[a-zA-Z0-9_]+)@(?<host>[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)$/);
if (!match) match = q.match(/^@(?<user>[a-zA-Z0-9_]+)$/);
if (match) {
// check if user is already in database
const dbResult = await Users.findOneBy({
usernameLower: match.groups.user.toLowerCase(),
host: match.groups?.host ?? IsNull()
});
if (dbResult) return [
dbResult
];
const result = await resolveUser(match.groups.user.toLowerCase(), match.groups?.host ?? null);
if (result) return [
result
];
// no matches found
return [];
}
}
} catch (e) {
console.log(`[mastodon-client] resolve user '${q}' failed: ${e.message}`);
return [];
}
}
const query = PaginationHelpers.makePaginationQuery(Users.createQueryBuilder("user"), undefined, minId, maxId);
if (following) {
const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :followerId", {
followerId: user.id
});
query.andWhere(new Brackets((qb)=>{
qb.where(`user.id IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, {
meId: user.id
});
}));
}
query.andWhere(new Brackets((qb)=>{
qb.where("user.name ILIKE :q", {
q: `%${sqlLikeEscape(q)}%`
});
qb.orWhere("concat_ws('@', user.usernameLower, user.host) ILIKE :q", {
q: `%${sqlLikeEscape(q)}%`
});
}));
query.orderBy({
'user.notesCount': 'DESC'
});
return query.skip(offset ?? 0).take(limit).getMany().then((p)=>minId ? p.reverse() : p);
}
static async searchNotes(q, resolve, following, accountId, maxId, minId, limit, offset, ctx) {
if (accountId && following) throw new Error("The 'following' and 'accountId' parameters cannot be used simultaneously");
const user = ctx.user;
if (resolve) {
try {
if (q.startsWith('https://') || q.startsWith('http://')) {
// try resolving locally first
const dbResolver = new DbResolver();
const dbResult = await dbResolver.getNoteFromApId(q);
if (dbResult) return [
dbResult
];
// ask remote
const resolver = new Resolver();
resolver.setUser(user);
const object = await resolver.resolve(q);
if (q !== object.id) {
const result = await dbResolver.getNoteFromApId(getApId(object));
if (result) return [
result
];
}
return isPost(object) ? createNote(getApId(object), resolver.reset(), true).then((p)=>p ? [
p
] : []) : [];
}
} catch (e) {
logger.warn(`Resolving note '${q}' failed: ${e.message}`);
return [];
}
}
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), undefined, minId, maxId);
if (accountId) {
query.andWhere("note.userId = :userId", {
userId: accountId
});
}
if (following) {
const followingQuery = Followings.createQueryBuilder("following").select("following.followeeId").where("following.followerId = :followerId", {
followerId: user.id
});
query.andWhere(new Brackets((qb)=>{
qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, {
meId: user.id
});
}));
}
query.leftJoinAndSelect("note.renote", "renote");
generateFtsQuery(query, q);
generateVisibilityQuery(query, user);
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
query.setParameter("meId", user.id);
return query.skip(offset ?? 0).take(limit).getMany().then((p)=>minId ? p.reverse() : p);
}
static async searchTags(q, excludeUnreviewed, limit, offset) {
const tags = Hashtags.createQueryBuilder('tag').select('tag.name').distinctOn([
'tag.name'
]).where("tag.name ILIKE :q", {
q: `%${sqlLikeEscape(q)}%`
}).orderBy({
'tag.name': 'ASC'
}).skip(offset ?? 0).take(limit).getMany();
return tags.then((p)=>p.map((tag)=>{
return {
name: tag.name,
url: `${config.url}/tags/${tag.name}`,
history: null
};
}));
}
}
@@ -0,0 +1,148 @@
import { Notes, Notifications, UserListJoinings } from "../../../../models/index.js";
import { Brackets } from "typeorm";
import { generateChannelQuery } from "../../common/generate-channel-query.js";
import { generateRepliesQuery } from "../../common/generate-replies-query.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 { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js";
import { fetchMeta } from "../../../../misc/fetch-meta.js";
import { PaginationHelpers } from "./pagination.js";
import { UserHelpers } from "./user.js";
import { UserConverter } from "../converters/user.js";
import { NoteConverter } from "../converters/note.js";
import { awaitAll } from "../../../../prelude/await-all.js";
import { unique } from "../../../../prelude/array.js";
import { MastoApiError } from "../middleware/catch-errors.js";
import { generatePaginationData } from "../middleware/pagination.js";
import { generateListQuery } from "../../common/generate-list-query.js";
import { generateFollowingQuery } from "../../common/generate-following-query.js";
export class TimelineHelpers {
static async getHomeTimeline(maxId, sinceId, minId, limit = 20, ctx) {
if (limit > 40) limit = 40;
const user = ctx.user;
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).leftJoinAndSelect("note.user", "user").leftJoinAndSelect("note.renote", "renote");
await generateFollowingQuery(query, user);
generateListQuery(query, user);
generateChannelQuery(query, user);
generateRepliesQuery(query, true, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
query.andWhere("note.visibility != 'hidden'");
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx);
}
static async getPublicTimeline(maxId, sinceId, minId, limit = 20, onlyMedia = false, local = false, remote = false, ctx) {
if (limit > 40) limit = 40;
const user = ctx.user;
if (local && remote) {
throw new Error("local and remote are mutually exclusive options");
}
if (!local) {
const m = await fetchMeta();
if (m.disableGlobalTimeline) {
if (user == null || !(user.isAdmin || user.isModerator)) {
throw new Error("global timeline is disabled");
}
}
}
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).andWhere("note.visibility = 'public'");
if (remote) query.andWhere("note.userHost IS NOT NULL");
if (local) query.andWhere("note.userHost IS NULL");
if (!local) query.andWhere("note.channelId IS NULL");
query.leftJoinAndSelect("note.user", "user").leftJoinAndSelect("note.renote", "renote");
generateRepliesQuery(query, true, user);
if (user) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
}
if (onlyMedia) query.andWhere("note.fileIds != '{}'");
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx);
}
static async getListTimeline(list, maxId, sinceId, minId, limit = 20, ctx) {
if (limit > 40) limit = 40;
const user = ctx.user;
if (user.id != list.userId) throw new Error("List is not owned by user");
const listQuery = UserListJoinings.createQueryBuilder("member").select("member.userId", 'userId').where("member.userListId = :listId");
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).andWhere(`note.userId IN (${listQuery.getQuery()})`).andWhere("note.visibility != 'specified'").leftJoinAndSelect("note.user", "user").leftJoinAndSelect("note.renote", "renote").setParameters({
listId: list.id
});
generateVisibilityQuery(query, user);
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx);
}
static async getTagTimeline(tag, maxId, sinceId, minId, limit = 20, any, all, none, onlyMedia = false, local = false, remote = false, ctx) {
if (limit > 40) limit = 40;
const user = ctx.user;
if (tag.length < 1) throw new MastoApiError(400, "Tag cannot be empty");
if (local && remote) {
throw new Error("local and remote are mutually exclusive options");
}
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).andWhere("note.visibility = 'public'").andWhere("note.tags @> array[:tag]::varchar[]", {
tag: tag
});
if (any.length > 0) query.andWhere("note.tags && array[:...any]::varchar[]", {
any: any
});
if (all.length > 0) query.andWhere("note.tags @> array[:...all]::varchar[]", {
all: all
});
if (none.length > 0) query.andWhere("NOT(note.tags @> array[:...none]::varchar[])", {
none: none
});
if (remote) query.andWhere("note.userHost IS NOT NULL");
if (local) query.andWhere("note.userHost IS NULL");
if (!local) query.andWhere("note.channelId IS NULL");
query.leftJoinAndSelect("note.user", "user").leftJoinAndSelect("note.renote", "renote");
generateRepliesQuery(query, true, user);
if (user) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
}
if (onlyMedia) query.andWhere("note.fileIds != '{}'");
return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx);
}
static async getConversations(maxId, sinceId, minId, limit = 20, ctx) {
if (limit > 40) limit = 40;
const user = ctx.user;
const sq = Notes.createQueryBuilder("note").select("COALESCE(note.threadId, note.id)", "conversationId").addSelect("note.id", "latest").distinctOn([
"COALESCE(note.threadId, note.id)"
]).orderBy({
"COALESCE(note.threadId, note.id)": minId ? "ASC" : "DESC",
"note.id": "DESC"
}).andWhere("note.visibility = 'specified'").andWhere(new Brackets((qb)=>{
qb.where("note.userId = :userId");
qb.orWhere("note.visibleUserIds @> array[:userId]::varchar[]");
}));
const query = PaginationHelpers.makePaginationQuery(Notes.createQueryBuilder("note"), sinceId, maxId, minId).innerJoin(`(${sq.getQuery()})`, "sq", "note.id = sq.latest").setParameters({
userId: user.id
});
return query.take(limit).getMany().then((p)=>{
if (minId !== undefined) p = p.reverse();
const conversations = p.map((c)=>{
// Gather all unique IDs except for the local user
const userIds = unique([
c.userId
].concat(c.visibleUserIds).filter((p)=>p != user.id));
const users = userIds.map((id)=>UserHelpers.getUserCached(id, ctx).catch((_)=>null));
const accounts = Promise.all(users).then((u)=>UserConverter.encodeMany(u.filter((u)=>u), ctx));
const unread = Notifications.createQueryBuilder('notification').where("notification.noteId = :noteId").andWhere("notification.notifieeId = :userId").andWhere("notification.isRead = FALSE").andWhere("notification.type IN (:...types)").setParameter("noteId", c.id).setParameter("userId", user.id).setParameter("types", [
'reply',
'mention'
]).getExists();
return {
id: c.threadId ?? c.id,
accounts: accounts.then((u)=>u.length > 0 ? u : UserConverter.encodeMany([
user
], ctx)),
last_status: NoteConverter.encode(c, ctx),
unread: unread
};
});
ctx.pagination = generatePaginationData(p.map((p)=>p.threadId ?? p.id), limit);
return Promise.all(conversations.map((c)=>awaitAll(c)));
});
}
}
@@ -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');
}
}