Fixed 267U.pre2
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user