Fixed 267U.pre2
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { MfmHelpers } from "../helpers/mfm.js";
|
||||
import mfm from "mfm-js";
|
||||
export class AnnouncementConverter {
|
||||
static async encode(announcement, isRead) {
|
||||
return {
|
||||
id: announcement.id,
|
||||
content: `<h1>${await MfmHelpers.toHtml(mfm.parse(announcement.title), [], null) ?? 'Announcement'}</h1>${await MfmHelpers.toHtml(mfm.parse(announcement.text), [], null) ?? ''}`,
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
published: true,
|
||||
all_day: false,
|
||||
published_at: announcement.createdAt.toISOString(),
|
||||
updated_at: announcement.updatedAt?.toISOString() ?? announcement.createdAt.toISOString(),
|
||||
read: isRead,
|
||||
mentions: [],
|
||||
statuses: [],
|
||||
tags: [],
|
||||
emojis: [],
|
||||
reactions: []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class EmojiConverter {
|
||||
static encode(e) {
|
||||
return {
|
||||
shortcode: e.name,
|
||||
static_url: e.url,
|
||||
url: e.url,
|
||||
visible_in_picker: true,
|
||||
category: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export class FileConverter {
|
||||
static encode(f) {
|
||||
return {
|
||||
id: f.id,
|
||||
type: this.encodefileType(f.type),
|
||||
url: f.url ?? "",
|
||||
remote_url: f.url,
|
||||
preview_url: f.thumbnailUrl ?? f.url ?? "",
|
||||
text_url: f.url,
|
||||
meta: {
|
||||
width: f.properties.width,
|
||||
height: f.properties.height
|
||||
},
|
||||
description: f.comment,
|
||||
blurhash: f.blurhash
|
||||
};
|
||||
}
|
||||
static encodefileType(s) {
|
||||
if (s === "image/gif") {
|
||||
return "gifv";
|
||||
}
|
||||
if (s.includes("image")) {
|
||||
return "image";
|
||||
}
|
||||
if (s.includes("video")) {
|
||||
return "video";
|
||||
}
|
||||
if (s.includes("audio")) {
|
||||
return "audio";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import config from "../../../../config/index.js";
|
||||
export class MentionConverter {
|
||||
static encode(u, m) {
|
||||
let acct = u.username;
|
||||
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
|
||||
let url = null;
|
||||
if (u.host) {
|
||||
const info = m.find((r)=>r.username === u.username && r.host === u.host);
|
||||
acct = `${u.username}@${u.host}`;
|
||||
acctUrl = `https://${u.host}/@${u.username}`;
|
||||
if (info) url = info.url ?? info.uri;
|
||||
}
|
||||
return {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
acct: acct,
|
||||
url: url ?? acctUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const escapeMFM = (text)=>text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'").replace(/`/g, "`").replace(/\r?\n/g, "<br>");
|
||||
@@ -0,0 +1,326 @@
|
||||
import { getNote } from "../../common/getters.js";
|
||||
import config from "../../../../config/index.js";
|
||||
import mfm from "mfm-js";
|
||||
import { UserConverter } from "./user.js";
|
||||
import { VisibilityConverter } from "./visibility.js";
|
||||
import { escapeMFM } from "./mfm.js";
|
||||
import { aggregateNoteEmojis, populateEmojis, prefetchEmojis } from "../../../../misc/populate-emojis.js";
|
||||
import { EmojiConverter } from "./emoji.js";
|
||||
import { DriveFiles, HtmlNoteCacheEntries, NoteFavorites, NoteReactions, Notes, NoteThreadMutings, UserNotePinings } from "../../../../models/index.js";
|
||||
import { decodeReaction } from "../../../../misc/reaction-lib.js";
|
||||
import { MentionConverter } from "./mention.js";
|
||||
import { PollConverter } from "./poll.js";
|
||||
import { populatePoll } from "../../../../models/repositories/note.js";
|
||||
import { FileConverter } from "./file.js";
|
||||
import { awaitAll } from "../../../../prelude/await-all.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { In, IsNull } from "typeorm";
|
||||
import { MfmHelpers } from "../helpers/mfm.js";
|
||||
import { getStubMastoContext } from "../index.js";
|
||||
import { NoteHelpers } from "../helpers/note.js";
|
||||
import isQuote from "../../../../misc/is-quote.js";
|
||||
import { unique } from "../../../../prelude/array.js";
|
||||
import { Cache } from "../../../../misc/cache.js";
|
||||
import { isFiltered } from "../../../../misc/is-filtered.js";
|
||||
export class NoteConverter {
|
||||
static noteContentHtmlCache = new Cache('html:note:content', config.htmlCache?.ttlSeconds ?? 60 * 60);
|
||||
static async encode(note, ctx, recurseCounter = 2) {
|
||||
const user = ctx.user;
|
||||
const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, ctx);
|
||||
if (!await Notes.isVisibleForMe(note, user?.id ?? null)) throw new Error('Cannot encode note not visible for user');
|
||||
const host = Promise.resolve(noteUser).then((noteUser)=>noteUser.host ?? null);
|
||||
const reactionEmojiNames = Object.keys(note.reactions).filter((x)=>x?.startsWith(":")).map((x)=>decodeReaction(x).reaction).map((x)=>x.replace(/:/g, ""));
|
||||
const populated = host.then(async (host)=>populateEmojis(note.emojis.concat(reactionEmojiNames), host));
|
||||
const noteEmoji = populated.then((noteEmoji)=>noteEmoji.filter((e)=>e.name.indexOf("@") === -1).map((e)=>EmojiConverter.encode(e)));
|
||||
const reactionCount = Object.values(note.reactions).reduce((a, b)=>a + b, 0);
|
||||
const aggregateReaction = ctx.reactionAggregate?.get(note.id);
|
||||
const reaction = aggregateReaction !== undefined ? aggregateReaction : user ? NoteReactions.findOneBy({
|
||||
userId: user.id,
|
||||
noteId: note.id
|
||||
}) : null;
|
||||
const isFavorited = Promise.resolve(reaction).then((p)=>!!p);
|
||||
const isReblogged = ctx.renoteAggregate?.get(note.id) ?? (user ? Notes.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
renoteId: note.id,
|
||||
text: IsNull()
|
||||
}
|
||||
}) : null);
|
||||
const renote = note.renote ?? (note.renoteId && recurseCounter > 0 ? getNote(note.renoteId, user) : null);
|
||||
const isBookmarked = ctx.bookmarkAggregate?.get(note.id) ?? (user ? NoteFavorites.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
noteId: note.id
|
||||
},
|
||||
take: 1
|
||||
}) : false);
|
||||
const isMuted = ctx.mutingAggregate?.get(note.threadId ?? note.id) ?? (user ? NoteThreadMutings.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
threadId: note.threadId || note.id
|
||||
}
|
||||
}) : false);
|
||||
const files = DriveFiles.packMany(note.fileIds);
|
||||
const mentions = Promise.all(note.mentions.map((p)=>UserHelpers.getUserCached(p, ctx).then((u)=>MentionConverter.encode(u, JSON.parse(note.mentionedRemoteUsers))).catch(()=>null))).then((p)=>p.filter((m)=>m));
|
||||
const quoteUri = Promise.resolve(renote).then((renote)=>{
|
||||
if (!renote || !isQuote(note)) return null;
|
||||
return renote.url ?? renote.uri ?? `${config.url}/notes/${renote.id}`;
|
||||
});
|
||||
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
|
||||
const text = quoteUri.then((quoteUri)=>note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null);
|
||||
const content = this.noteContentHtmlCache.fetch(identifier, async ()=>Promise.resolve(await this.fetchFromCacheWithFallback(note, ctx) ?? text.then((text)=>text !== null ? quoteUri.then((quoteUri)=>MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri)).then((p)=>p ?? escapeMFM(text)) : "")), true).then((p)=>p ?? '');
|
||||
const isPinned = ctx.pinAggregate?.get(note.id) ?? (user && note.userId === user.id ? UserNotePinings.exist({
|
||||
where: {
|
||||
userId: user.id,
|
||||
noteId: note.id
|
||||
}
|
||||
}) : undefined);
|
||||
const tags = note.tags.map((tag)=>{
|
||||
return {
|
||||
name: tag,
|
||||
url: `${config.url}/tags/${tag}`
|
||||
};
|
||||
});
|
||||
const reblog = Promise.resolve(renote).then((renote)=>recurseCounter > 0 && renote ? this.encode(renote, ctx, isQuote(renote) && !isQuote(note) ? --recurseCounter : 0) : null);
|
||||
const filtered = isFiltered(note, user).then((res)=>{
|
||||
if (!res || ctx.filterContext == null || ![
|
||||
'home',
|
||||
'public'
|
||||
].includes(ctx.filterContext)) return null;
|
||||
return [
|
||||
{
|
||||
filter: {
|
||||
id: '0',
|
||||
title: 'Hard word mutes',
|
||||
context: [
|
||||
'home',
|
||||
'public'
|
||||
],
|
||||
expires_at: null,
|
||||
filter_action: 'hide',
|
||||
keywords: [],
|
||||
statuses: []
|
||||
}
|
||||
}
|
||||
];
|
||||
});
|
||||
// noinspection ES6MissingAwait
|
||||
return await awaitAll({
|
||||
id: note.id,
|
||||
uri: note.uri ?? `https://${config.host}/notes/${note.id}`,
|
||||
url: note.url ?? note.uri ?? `https://${config.host}/notes/${note.id}`,
|
||||
account: Promise.resolve(noteUser).then((p)=>UserConverter.encode(p, ctx)),
|
||||
in_reply_to_id: note.replyId,
|
||||
in_reply_to_account_id: note.replyUserId,
|
||||
reblog: reblog.then((reblog)=>!isQuote(note) ? reblog : null),
|
||||
content: content,
|
||||
content_type: 'text/x.misskeymarkdown',
|
||||
text: text,
|
||||
created_at: note.createdAt.toISOString(),
|
||||
emojis: noteEmoji,
|
||||
replies_count: note.repliesCount,
|
||||
reblogs_count: note.renoteCount,
|
||||
favourites_count: reactionCount,
|
||||
reblogged: isReblogged,
|
||||
favourited: isFavorited,
|
||||
muted: isMuted,
|
||||
sensitive: files.then((files)=>files.length > 0 ? files.some((f)=>f.isSensitive) : false),
|
||||
spoiler_text: note.cw ? note.cw : "",
|
||||
visibility: VisibilityConverter.encode(note.visibility),
|
||||
media_attachments: files.then((files)=>files.length > 0 ? files.map((f)=>FileConverter.encode(f)) : []),
|
||||
mentions: mentions,
|
||||
tags: tags,
|
||||
card: null,
|
||||
poll: note.hasPoll ? populatePoll(note, user?.id ?? null).then((p)=>noteEmoji.then((emojis)=>PollConverter.encode(p, note.id, emojis))) : null,
|
||||
application: null,
|
||||
language: null,
|
||||
pinned: isPinned,
|
||||
reactions: populated.then((populated)=>Promise.resolve(reaction).then((reaction)=>this.encodeReactions(note.reactions, reaction?.reaction, populated))),
|
||||
bookmarked: isBookmarked,
|
||||
quote: reblog.then((reblog)=>reblog !== null && isQuote(note) ? {
|
||||
state: "accepted",
|
||||
quoted_status: reblog,
|
||||
...reblog
|
||||
} : null),
|
||||
quote_id: isQuote(note) ? note.renoteId : null,
|
||||
edited_at: note.updatedAt?.toISOString() ?? null,
|
||||
filtered: filtered,
|
||||
quote_approval: {
|
||||
automatic: [
|
||||
"public"
|
||||
],
|
||||
manual: [],
|
||||
current_user: "automatic"
|
||||
}
|
||||
});
|
||||
}
|
||||
static async encodeMany(notes, ctx) {
|
||||
await this.aggregateData(notes, ctx);
|
||||
const encoded = notes.map((n)=>this.encode(n, ctx));
|
||||
return Promise.all(encoded);
|
||||
}
|
||||
static async aggregateData(notes, ctx) {
|
||||
if (notes.length === 0) return;
|
||||
const user = ctx.user;
|
||||
const reactionAggregate = new Map();
|
||||
const renoteAggregate = new Map();
|
||||
const mutingAggregate = new Map();
|
||||
const bookmarkAggregate = new Map();
|
||||
const pinAggregate = new Map();
|
||||
const htmlNoteCacheAggregate = new Map();
|
||||
const renoteIds = notes.filter((n)=>n.renoteId != null).map((n)=>n.renoteId);
|
||||
const noteIds = unique(notes.map((n)=>n.id));
|
||||
const targets = unique([
|
||||
...noteIds,
|
||||
...renoteIds
|
||||
]);
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
const htmlNoteCacheEntries = await HtmlNoteCacheEntries.findBy({
|
||||
noteId: In(targets)
|
||||
});
|
||||
for (const target of targets){
|
||||
htmlNoteCacheAggregate.set(target, htmlNoteCacheEntries.find((n)=>n.noteId === target) ?? null);
|
||||
}
|
||||
}
|
||||
if (user?.id != null) {
|
||||
const mutingTargets = unique([
|
||||
...notes.map((n)=>n.threadId ?? n.id)
|
||||
]);
|
||||
const pinTargets = unique([
|
||||
...notes.filter((n)=>n.userId === user.id).map((n)=>n.id)
|
||||
]);
|
||||
const reactions = await NoteReactions.findBy({
|
||||
userId: user.id,
|
||||
noteId: In(targets)
|
||||
});
|
||||
const renotes = await Notes.createQueryBuilder('note').select('note.renoteId').where('note.userId = :meId', {
|
||||
meId: user.id
|
||||
}).andWhere('note.renoteId IN (:...targets)', {
|
||||
targets
|
||||
}).andWhere('note.text IS NULL').andWhere('note.hasPoll = FALSE').andWhere(`note.fileIds = '{}'`).getMany();
|
||||
const mutings = await NoteThreadMutings.createQueryBuilder('muting').select('muting.threadId').where('muting.userId = :meId', {
|
||||
meId: user.id
|
||||
}).andWhere('muting.threadId IN (:...targets)', {
|
||||
targets: mutingTargets
|
||||
}).getMany();
|
||||
const bookmarks = await NoteFavorites.createQueryBuilder('bookmark').select('bookmark.noteId').where('bookmark.userId = :meId', {
|
||||
meId: user.id
|
||||
}).andWhere('bookmark.noteId IN (:...targets)', {
|
||||
targets
|
||||
}).getMany();
|
||||
const pins = pinTargets.length > 0 ? await UserNotePinings.createQueryBuilder('pin').select('pin.noteId').where('pin.userId = :meId', {
|
||||
meId: user.id
|
||||
}).andWhere('pin.noteId IN (:...targets)', {
|
||||
targets: pinTargets
|
||||
}).getMany() : [];
|
||||
for (const target of targets){
|
||||
reactionAggregate.set(target, reactions.find((r)=>r.noteId === target) ?? null);
|
||||
renoteAggregate.set(target, !!renotes.find((n)=>n.renoteId === target));
|
||||
bookmarkAggregate.set(target, !!bookmarks.find((b)=>b.noteId === target));
|
||||
}
|
||||
for (const target of mutingTargets){
|
||||
mutingAggregate.set(target, !!mutings.find((m)=>m.threadId === target));
|
||||
}
|
||||
for (const target of pinTargets){
|
||||
mutingAggregate.set(target, !!pins.find((m)=>m.noteId === target));
|
||||
}
|
||||
}
|
||||
ctx.reactionAggregate = reactionAggregate;
|
||||
ctx.renoteAggregate = renoteAggregate;
|
||||
ctx.mutingAggregate = mutingAggregate;
|
||||
ctx.bookmarkAggregate = bookmarkAggregate;
|
||||
ctx.pinAggregate = pinAggregate;
|
||||
ctx.htmlNoteCacheAggregate = htmlNoteCacheAggregate;
|
||||
const users = notes.filter((p)=>!!p.user).map((p)=>p.user);
|
||||
const renoteUserIds = notes.filter((p)=>p.renoteUserId !== null).map((p)=>p.renoteUserId);
|
||||
await UserConverter.aggregateData([
|
||||
...users
|
||||
], ctx);
|
||||
await UserConverter.aggregateDataByIds(renoteUserIds, ctx);
|
||||
await prefetchEmojis(aggregateNoteEmojis(notes));
|
||||
}
|
||||
static encodeReactions(reactions, myReaction, populated) {
|
||||
return Object.keys(reactions).map((key)=>{
|
||||
const isCustom = key.startsWith(':') && key.endsWith(':');
|
||||
const name = isCustom ? key.substring(1, key.length - 1) : key;
|
||||
const populatedName = isCustom && name.indexOf('@') === -1 ? `${name}@.` : name;
|
||||
const url = isCustom ? populated.find((p)=>p.name === populatedName)?.url : undefined;
|
||||
return {
|
||||
count: reactions[key],
|
||||
me: key === myReaction,
|
||||
name: name,
|
||||
url: url,
|
||||
static_url: url
|
||||
};
|
||||
}).filter((r)=>r.count > 0);
|
||||
}
|
||||
static async encodeEvent(note, user, filterContext) {
|
||||
const ctx = getStubMastoContext(user, filterContext);
|
||||
NoteHelpers.fixupEventNote(note);
|
||||
return NoteConverter.encode(note, ctx);
|
||||
}
|
||||
static async fetchFromCacheWithFallback(note, ctx) {
|
||||
if (!config.htmlCache?.dbFallback) return null;
|
||||
let dbHit = ctx.htmlNoteCacheAggregate?.get(note.id);
|
||||
if (dbHit === undefined) dbHit = HtmlNoteCacheEntries.findOneBy({
|
||||
noteId: note.id
|
||||
});
|
||||
return Promise.resolve(dbHit).then((res)=>{
|
||||
if (res === null || res.updatedAt?.getTime() !== note.updatedAt?.getTime()) {
|
||||
return this.dbCacheMiss(note, ctx);
|
||||
}
|
||||
return res;
|
||||
}).then((hit)=>hit?.updatedAt === note.updatedAt ? hit?.content ?? null : null);
|
||||
}
|
||||
static async dbCacheMiss(note, ctx) {
|
||||
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
|
||||
const cache = ctx.cache;
|
||||
return cache.locks.acquire(identifier, async ()=>{
|
||||
const cachedContent = await this.noteContentHtmlCache.get(identifier);
|
||||
if (cachedContent !== undefined) {
|
||||
return {
|
||||
content: cachedContent
|
||||
};
|
||||
}
|
||||
const quoteUri = note.renote ? isQuote(note) ? note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}` : null : null;
|
||||
const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null;
|
||||
const content = text !== null ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri).then((p)=>p ?? escapeMFM(text)) : null;
|
||||
HtmlNoteCacheEntries.upsert({
|
||||
noteId: note.id,
|
||||
updatedAt: note.updatedAt ?? note.createdAt,
|
||||
content: await content
|
||||
}, [
|
||||
"noteId"
|
||||
]);
|
||||
await this.noteContentHtmlCache.set(identifier, await content);
|
||||
return {
|
||||
content
|
||||
};
|
||||
});
|
||||
}
|
||||
static async prewarmCache(note) {
|
||||
if (!config.htmlCache?.prewarm) return;
|
||||
const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`;
|
||||
if (await this.noteContentHtmlCache.get(identifier) !== undefined) return;
|
||||
if (note.renoteId !== null && !note.renote) {
|
||||
note.renote = await Notes.findOneBy({
|
||||
id: note.renoteId
|
||||
});
|
||||
}
|
||||
const quoteUri = note.renote ? isQuote(note) ? note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}` : null : null;
|
||||
const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null;
|
||||
const content = text !== null ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri).then((p)=>p ?? escapeMFM(text)) : null;
|
||||
if (note.user) UserConverter.prewarmCache(note.user);
|
||||
else if (note.userId) UserConverter.prewarmCacheById(note.userId);
|
||||
if (note.replyUserId) UserConverter.prewarmCacheById(note.replyUserId);
|
||||
if (note.renoteUserId) UserConverter.prewarmCacheById(note.renoteUserId);
|
||||
this.noteContentHtmlCache.set(identifier, await content);
|
||||
if (config.htmlCache?.dbFallback) HtmlNoteCacheEntries.upsert({
|
||||
noteId: note.id,
|
||||
updatedAt: note.updatedAt ?? note.createdAt,
|
||||
content: await content
|
||||
}, [
|
||||
"noteId"
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { UserConverter } from "./user.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { awaitAll } from "../../../../prelude/await-all.js";
|
||||
import { NoteConverter } from "./note.js";
|
||||
import { getNote } from "../../common/getters.js";
|
||||
import { getStubMastoContext } from "../index.js";
|
||||
import { Notifications } from "../../../../models/index.js";
|
||||
import isQuote from "../../../../misc/is-quote.js";
|
||||
import { unique } from "../../../../prelude/array.js";
|
||||
export class NotificationConverter {
|
||||
static async encode(notification, ctx) {
|
||||
const localUser = ctx.user;
|
||||
if (notification.notifieeId !== localUser.id) throw new Error('User is not recipient of notification');
|
||||
const account = notification.notifierId ? UserHelpers.getUserCached(notification.notifierId, ctx).then((p)=>UserConverter.encode(p, ctx)) : UserConverter.encode(localUser, ctx);
|
||||
let result = {
|
||||
id: notification.id,
|
||||
account: account,
|
||||
created_at: notification.createdAt.toISOString(),
|
||||
type: this.encodeNotificationType(notification.type)
|
||||
};
|
||||
const note = notification.note ?? (notification.noteId ? await getNote(notification.noteId, localUser) : null);
|
||||
if (note) {
|
||||
const isPureRenote = note.renoteId !== null && !isQuote(note);
|
||||
const encodedNote = isPureRenote ? getNote(note.renoteId, localUser).then((note)=>NoteConverter.encode(note, ctx)) : NoteConverter.encode(note, ctx);
|
||||
result = Object.assign(result, {
|
||||
status: encodedNote
|
||||
});
|
||||
if (result.type === 'poll') {
|
||||
result = Object.assign(result, {
|
||||
account: encodedNote.then((p)=>p.account)
|
||||
});
|
||||
}
|
||||
if (notification.reaction) {
|
||||
//FIXME: Implement reactions;
|
||||
}
|
||||
}
|
||||
return awaitAll(result);
|
||||
}
|
||||
static async encodeMany(notifications, ctx) {
|
||||
await this.aggregateData(notifications, ctx);
|
||||
const encoded = notifications.map((u)=>this.encode(u, ctx));
|
||||
return Promise.all(encoded).then((p)=>p.filter((n)=>n !== null));
|
||||
}
|
||||
static async aggregateData(notifications, ctx) {
|
||||
if (notifications.length === 0) return;
|
||||
const notes = unique(notifications.filter((p)=>p.note != null).map((n)=>n.note));
|
||||
const users = unique(notifications.filter((p)=>p.notifier != null).map((n)=>n.notifier).concat(notifications.filter((p)=>p.notifiee != null).map((n)=>n.notifiee)));
|
||||
await NoteConverter.aggregateData(notes, ctx);
|
||||
await UserConverter.aggregateData(users, ctx);
|
||||
}
|
||||
static encodeNotificationType(t) {
|
||||
//FIXME: Implement custom notification for followRequestAccepted
|
||||
//FIXME: Implement mastodon notification type 'update' on misskey side
|
||||
switch(t){
|
||||
case "follow":
|
||||
return 'follow';
|
||||
case "mention":
|
||||
case "reply":
|
||||
return 'mention';
|
||||
case "renote":
|
||||
return 'reblog';
|
||||
case "quote":
|
||||
return 'reblog';
|
||||
case "reaction":
|
||||
return 'favourite';
|
||||
case "pollEnded":
|
||||
return 'poll';
|
||||
case "receiveFollowRequest":
|
||||
return 'follow_request';
|
||||
case "followRequestAccepted":
|
||||
case "pollVote":
|
||||
case "groupInvited":
|
||||
case "app":
|
||||
throw new Error(`Notification type ${t} not supported`);
|
||||
}
|
||||
}
|
||||
static async encodeEvent(target, user, filterContext) {
|
||||
const ctx = getStubMastoContext(user, filterContext);
|
||||
const notification = await Notifications.findOneByOrFail({
|
||||
id: target
|
||||
});
|
||||
return this.encode(notification, ctx).catch((_)=>null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export class PollConverter {
|
||||
static encode(p, noteId, emojis) {
|
||||
const now = new Date();
|
||||
const count = p.choices.reduce((sum, choice)=>sum + choice.votes, 0);
|
||||
return {
|
||||
id: noteId,
|
||||
expires_at: p.expiresAt?.toISOString() ?? null,
|
||||
expired: p.expiresAt == null ? false : now > p.expiresAt,
|
||||
multiple: p.multiple,
|
||||
votes_count: count,
|
||||
options: p.choices.map((c)=>this.encodeChoice(c)),
|
||||
emojis: emojis,
|
||||
voted: p.choices.some((c)=>c.isVoted),
|
||||
own_votes: p.choices.filter((c)=>c.isVoted).map((c)=>p.choices.indexOf(c))
|
||||
};
|
||||
}
|
||||
static encodeChoice(c) {
|
||||
return {
|
||||
title: c.text,
|
||||
votes_count: c.votes
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import config from "../../../../config/index.js";
|
||||
import { DriveFiles, Followings, HtmlUserCacheEntries, UserProfiles, Users } from "../../../../models/index.js";
|
||||
import { EmojiConverter } from "./emoji.js";
|
||||
import { populateEmojis } from "../../../../misc/populate-emojis.js";
|
||||
import { escapeMFM } from "./mfm.js";
|
||||
import mfm from "mfm-js";
|
||||
import { awaitAll } from "../../../../prelude/await-all.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { MfmHelpers } from "../helpers/mfm.js";
|
||||
import { In } from "typeorm";
|
||||
import { unique } from "../../../../prelude/array.js";
|
||||
import { Cache } from "../../../../misc/cache.js";
|
||||
import { getUser } from "../../common/getters.js";
|
||||
import AsyncLock from "async-lock";
|
||||
export class UserConverter {
|
||||
static userBioHtmlCache = new Cache('html:user:bio', config.htmlCache?.ttlSeconds ?? 60 * 60);
|
||||
static userFieldsHtmlCache = new Cache('html:user:fields', config.htmlCache?.ttlSeconds ?? 60 * 60);
|
||||
static async encode(u, ctx) {
|
||||
const localUser = ctx.user;
|
||||
const cache = ctx.cache;
|
||||
return cache.locks.acquire(u.id, async ()=>{
|
||||
const cacheHit = cache.accounts.find((p)=>p.id == u.id);
|
||||
if (cacheHit) return cacheHit;
|
||||
const identifier = `${u.id}:${(u.lastFetchedAt ?? u.updatedAt ?? u.createdAt).getTime()}`;
|
||||
let fqn = `${u.username}@${u.host ?? config.domain}`;
|
||||
let acct = u.username;
|
||||
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
|
||||
if (u.host) {
|
||||
acct = `${u.username}@${u.host}`;
|
||||
acctUrl = `https://${u.host}/@${u.username}`;
|
||||
}
|
||||
const aggregateProfile = ctx.userProfileAggregate?.get(u.id);
|
||||
let htmlCacheEntry = undefined;
|
||||
const htmlCacheEntryLock = new AsyncLock();
|
||||
const profile = aggregateProfile !== undefined ? aggregateProfile : UserProfiles.findOneBy({
|
||||
userId: u.id
|
||||
});
|
||||
const bio = this.userBioHtmlCache.fetch(identifier, async ()=>{
|
||||
return htmlCacheEntryLock.acquire(u.id, async ()=>{
|
||||
if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx);
|
||||
if (htmlCacheEntry === null) {
|
||||
return Promise.resolve(profile).then(async (profile)=>{
|
||||
return MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '<p></p>' ? p : null);
|
||||
});
|
||||
}
|
||||
return htmlCacheEntry?.bio ?? null;
|
||||
});
|
||||
}, true).then((p)=>p ?? '<p></p>');
|
||||
const avatar = u.avatarId ? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? DriveFiles.findOneBy({
|
||||
id: u.avatarId
|
||||
}).then((p)=>p?.url ?? Users.getIdenticonUrl(u.id)).then((p)=>DriveFiles.getFinalUrl(p)) : Users.getIdenticonUrl(u.id);
|
||||
const banner = u.bannerId ? DriveFiles.getFinalUrlMaybe(u.bannerUrl) ?? DriveFiles.findOneBy({
|
||||
id: u.bannerId
|
||||
}).then((p)=>p?.url ?? `${config.url}/static-assets/transparent.png`).then((p)=>DriveFiles.getFinalUrl(p)) : `${config.url}/static-assets/transparent.png`;
|
||||
const isFollowedOrSelf = ctx.followedOrSelfAggregate?.get(u.id) ?? (!!localUser && (localUser.id === u.id || Followings.exist({
|
||||
where: {
|
||||
followeeId: u.id,
|
||||
followerId: localUser.id
|
||||
}
|
||||
})));
|
||||
const followersCount = Promise.resolve(profile).then(async (profile)=>{
|
||||
if (profile === null) return u.followersCount;
|
||||
switch(profile.ffVisibility){
|
||||
case "public":
|
||||
return u.followersCount;
|
||||
case "followers":
|
||||
return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf)=>isFollowedOrSelf ? u.followersCount : 0);
|
||||
case "private":
|
||||
return localUser?.id === profile.userId ? u.followersCount : 0;
|
||||
}
|
||||
});
|
||||
const followingCount = Promise.resolve(profile).then(async (profile)=>{
|
||||
if (profile === null) return u.followingCount;
|
||||
switch(profile.ffVisibility){
|
||||
case "public":
|
||||
return u.followingCount;
|
||||
case "followers":
|
||||
return Promise.resolve(isFollowedOrSelf).then((isFollowedOrSelf)=>isFollowedOrSelf ? u.followingCount : 0);
|
||||
case "private":
|
||||
return localUser?.id === profile.userId ? u.followingCount : 0;
|
||||
}
|
||||
});
|
||||
const fields = this.userFieldsHtmlCache.fetch(identifier, async ()=>{
|
||||
return htmlCacheEntryLock.acquire(u.id, async ()=>{
|
||||
if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx);
|
||||
if (htmlCacheEntry === null) {
|
||||
return Promise.resolve(profile).then((profile)=>Promise.all(profile?.fields.map(async (p)=>this.encodeField(p, u.host, profile?.mentions)) ?? []));
|
||||
}
|
||||
return htmlCacheEntry?.fields ?? [];
|
||||
});
|
||||
}, true);
|
||||
return awaitAll({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
acct: acct,
|
||||
fqn: fqn,
|
||||
display_name: u.name || u.username,
|
||||
locked: u.isLocked,
|
||||
created_at: u.createdAt.toISOString(),
|
||||
followers_count: followersCount,
|
||||
following_count: followingCount,
|
||||
statuses_count: u.notesCount,
|
||||
note: bio,
|
||||
url: u.uri ?? acctUrl,
|
||||
avatar: avatar,
|
||||
avatar_static: avatar,
|
||||
header: banner,
|
||||
header_static: banner,
|
||||
emojis: populateEmojis(u.emojis, u.host).then((emoji)=>emoji.map((e)=>EmojiConverter.encode(e))),
|
||||
moved: null,
|
||||
fields: fields,
|
||||
bot: u.isBot,
|
||||
discoverable: u.isExplorable
|
||||
}).then((p)=>{
|
||||
// noinspection ES6MissingAwait
|
||||
UserHelpers.updateUserInBackground(u);
|
||||
cache.accounts.push(p);
|
||||
return p;
|
||||
});
|
||||
});
|
||||
}
|
||||
static async aggregateData(users, ctx) {
|
||||
const user = ctx.user;
|
||||
const targets = unique(users.map((u)=>u.id));
|
||||
const followedOrSelfAggregate = new Map();
|
||||
const userProfileAggregate = new Map();
|
||||
const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map();
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({
|
||||
userId: In(targets)
|
||||
});
|
||||
for (const target of targets){
|
||||
htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find((n)=>n.userId === target) ?? null);
|
||||
}
|
||||
}
|
||||
if (user) {
|
||||
const targetsWithoutSelf = targets.filter((u)=>u !== user.id);
|
||||
if (targetsWithoutSelf.length > 0) {
|
||||
const followings = await Followings.createQueryBuilder('following').select('following.followeeId').where('following.followerId = :meId', {
|
||||
meId: user.id
|
||||
}).andWhere('following.followeeId IN (:...targets)', {
|
||||
targets: targetsWithoutSelf
|
||||
}).getMany();
|
||||
for (const userId of targetsWithoutSelf){
|
||||
followedOrSelfAggregate.set(userId, !!followings.find((f)=>f.followerId === userId));
|
||||
}
|
||||
}
|
||||
followedOrSelfAggregate.set(user.id, true);
|
||||
}
|
||||
const profiles = await UserProfiles.findBy({
|
||||
userId: In(targets)
|
||||
});
|
||||
for (const userId of targets){
|
||||
userProfileAggregate.set(userId, profiles.find((p)=>p.userId === userId) ?? null);
|
||||
}
|
||||
ctx.followedOrSelfAggregate = followedOrSelfAggregate;
|
||||
ctx.htmlUserCacheAggregate = htmlUserCacheAggregate;
|
||||
}
|
||||
static async aggregateDataByIds(userIds, ctx) {
|
||||
const targets = unique(userIds);
|
||||
const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map();
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({
|
||||
userId: In(targets)
|
||||
});
|
||||
for (const target of targets){
|
||||
htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find((n)=>n.userId === target) ?? null);
|
||||
}
|
||||
}
|
||||
ctx.htmlUserCacheAggregate = htmlUserCacheAggregate;
|
||||
}
|
||||
static async encodeMany(users, ctx) {
|
||||
await this.aggregateData(users, ctx);
|
||||
const encoded = users.map((u)=>this.encode(u, ctx));
|
||||
return Promise.all(encoded);
|
||||
}
|
||||
static async encodeField(f, host, mentions) {
|
||||
return {
|
||||
name: f.name,
|
||||
value: await MfmHelpers.toHtml(mfm.parse(f.value), mentions, host, true) ?? escapeMFM(f.value),
|
||||
verified_at: f.verified ? new Date().toISOString() : null
|
||||
};
|
||||
}
|
||||
static async fetchFromCacheWithFallback(user, profile, ctx) {
|
||||
if (!config.htmlCache?.dbFallback) return null;
|
||||
let dbHit = ctx.htmlUserCacheAggregate?.get(user.id);
|
||||
if (dbHit === undefined) dbHit = HtmlUserCacheEntries.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
return Promise.resolve(dbHit).then((res)=>{
|
||||
if (res === null || res.updatedAt.getTime() !== (user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()) {
|
||||
return this.dbCacheMiss(user, profile, ctx);
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}
|
||||
static async dbCacheMiss(user, profile, ctx) {
|
||||
const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`;
|
||||
const cache = ctx.cache;
|
||||
return cache.locks.acquire(identifier, async ()=>{
|
||||
const cachedBio = await this.userBioHtmlCache.get(identifier);
|
||||
const cachedFields = await this.userFieldsHtmlCache.get(identifier);
|
||||
if (cachedBio !== undefined && cachedFields !== undefined) {
|
||||
return {
|
||||
bio: cachedBio,
|
||||
fields: cachedFields
|
||||
};
|
||||
}
|
||||
if (profile === undefined) {
|
||||
profile = await UserProfiles.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
let bio = cachedBio;
|
||||
let fields = cachedFields;
|
||||
if (bio === undefined) {
|
||||
bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '<p></p>' ? p : null);
|
||||
}
|
||||
if (fields === undefined) {
|
||||
fields = Promise.all(profile.fields.map(async (p)=>this.encodeField(p, user.host, profile.mentions)) ?? []);
|
||||
}
|
||||
HtmlUserCacheEntries.upsert({
|
||||
userId: user.id,
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt,
|
||||
bio: await bio,
|
||||
fields: await fields
|
||||
}, [
|
||||
"userId"
|
||||
]);
|
||||
await this.userBioHtmlCache.set(identifier, await bio);
|
||||
await this.userFieldsHtmlCache.set(identifier, await fields);
|
||||
return {
|
||||
bio,
|
||||
fields
|
||||
};
|
||||
});
|
||||
}
|
||||
static async prewarmCache(user, profile, oldProfile) {
|
||||
const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`;
|
||||
if (profile !== null) {
|
||||
if (config.htmlCache?.dbFallback) {
|
||||
if (profile === undefined) {
|
||||
profile = await UserProfiles.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
if (oldProfile !== undefined && profile?.fields === oldProfile?.fields && profile?.description === oldProfile?.description) {
|
||||
HtmlUserCacheEntries.update({
|
||||
userId: user.id
|
||||
}, {
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!config.htmlCache?.prewarm) return;
|
||||
if (profile === undefined) {
|
||||
profile = await UserProfiles.findOneBy({
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
if (await this.userBioHtmlCache.get(identifier) === undefined) {
|
||||
const bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host).then((p)=>p ?? escapeMFM(profile?.description ?? "")).then((p)=>p !== '<p></p>' ? p : null);
|
||||
this.userBioHtmlCache.set(identifier, await bio);
|
||||
if (config.htmlCache?.dbFallback) HtmlUserCacheEntries.upsert({
|
||||
userId: user.id,
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt,
|
||||
bio: await bio
|
||||
}, [
|
||||
"userId"
|
||||
]);
|
||||
}
|
||||
if (await this.userFieldsHtmlCache.get(identifier) === undefined) {
|
||||
const fields = await Promise.all(profile.fields.map(async (p)=>this.encodeField(p, user.host, profile.mentions)) ?? []);
|
||||
this.userFieldsHtmlCache.set(identifier, fields);
|
||||
if (config.htmlCache?.dbFallback) HtmlUserCacheEntries.upsert({
|
||||
userId: user.id,
|
||||
updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt,
|
||||
fields: fields
|
||||
}, [
|
||||
"userId"
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
static async prewarmCacheById(userId, oldProfile) {
|
||||
await this.prewarmCache(await getUser(userId), undefined, oldProfile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export class VisibilityConverter {
|
||||
static encode(v) {
|
||||
switch(v){
|
||||
case "public":
|
||||
return v;
|
||||
case "home":
|
||||
return "unlisted";
|
||||
case "followers":
|
||||
return "private";
|
||||
case "specified":
|
||||
return "direct";
|
||||
case "hidden":
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
static decode(v) {
|
||||
switch(v){
|
||||
case "public":
|
||||
return v;
|
||||
case "unlisted":
|
||||
return "home";
|
||||
case "private":
|
||||
return "followers";
|
||||
case "direct":
|
||||
return "specified";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js";
|
||||
import { UserConverter } from "../converters/user.js";
|
||||
import { NoteConverter } from "../converters/note.js";
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
import { ListHelpers } from "../helpers/list.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { SearchHelpers } from "../helpers/search.js";
|
||||
import { filterContext } from "../middleware/filter-context.js";
|
||||
export function setupEndpointsAccount(router) {
|
||||
router.get("/v1/accounts/verify_credentials", auth(true, [
|
||||
'read:accounts'
|
||||
]), async (ctx)=>{
|
||||
ctx.body = await UserHelpers.verifyCredentials(ctx);
|
||||
});
|
||||
router.patch("/v1/accounts/update_credentials", auth(true, [
|
||||
'write:accounts'
|
||||
]), async (ctx)=>{
|
||||
ctx.body = await UserHelpers.updateCredentials(ctx);
|
||||
});
|
||||
router.get("/v1/accounts/lookup", async (ctx)=>{
|
||||
const args = normalizeUrlQuery(ctx.query);
|
||||
const user = await UserHelpers.getUserFromAcct(args.acct);
|
||||
ctx.body = await UserConverter.encode(user, ctx);
|
||||
});
|
||||
router.get("/v1/accounts/relationships", auth(true, [
|
||||
'read:follows'
|
||||
]), async (ctx)=>{
|
||||
const ids = normalizeUrlQuery(ctx.query, [
|
||||
'id[]'
|
||||
])['id[]'] ?? normalizeUrlQuery(ctx.query, [
|
||||
'id'
|
||||
])['id'] ?? [];
|
||||
ctx.body = await UserHelpers.getUserRelationhipToMany(ids, ctx.user.id);
|
||||
});
|
||||
// This must come before /accounts/:id, otherwise that will take precedence
|
||||
router.get("/v1/accounts/search", auth(true, [
|
||||
'read:accounts'
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query), [
|
||||
'resolve',
|
||||
'following'
|
||||
]));
|
||||
ctx.body = await SearchHelpers.search(args.q, 'accounts', args.resolve, args.following, undefined, false, undefined, undefined, args.limit, args.offset, ctx).then((p)=>p.accounts);
|
||||
});
|
||||
router.get("/v1/accounts/:id", auth(false), async (ctx)=>{
|
||||
ctx.body = await UserConverter.encode(await UserHelpers.getUserOr404(ctx.params.id), ctx);
|
||||
});
|
||||
router.get("/v1/accounts/:id/statuses", auth(false, [
|
||||
"read:statuses"
|
||||
]), filterContext('account'), async (ctx)=>{
|
||||
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)));
|
||||
const res = await UserHelpers.getUserStatuses(query, args.max_id, args.since_id, args.min_id, args.limit, args['only_media'], args['exclude_replies'], args['exclude_reblogs'], args.pinned, args.tagged, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/accounts/:id/featured_tags", async (ctx)=>{
|
||||
ctx.body = [];
|
||||
});
|
||||
router.get("/v1/accounts/:id/followers", auth(false), async (ctx)=>{
|
||||
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await UserHelpers.getUserFollowers(query, args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/accounts/:id/following", auth(false), async (ctx)=>{
|
||||
const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await UserHelpers.getUserFollowing(query, args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/accounts/:id/lists", auth(true, [
|
||||
"read:lists"
|
||||
]), async (ctx)=>{
|
||||
const member = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await ListHelpers.getListsByMember(member, ctx);
|
||||
});
|
||||
router.post("/v1/accounts/:id/follow", auth(true, [
|
||||
"write:follows"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
//FIXME: Parse form data
|
||||
ctx.body = await UserHelpers.followUser(target, true, false, ctx);
|
||||
});
|
||||
router.post("/v1/accounts/:id/unfollow", auth(true, [
|
||||
"write:follows"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.unfollowUser(target, ctx);
|
||||
});
|
||||
router.post("/v1/accounts/:id/block", auth(true, [
|
||||
"write:blocks"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.blockUser(target, ctx);
|
||||
});
|
||||
router.post("/v1/accounts/:id/unblock", auth(true, [
|
||||
"write:blocks"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.unblockUser(target, ctx);
|
||||
});
|
||||
router.post("/v1/accounts/:id/mute", auth(true, [
|
||||
"write:mutes"
|
||||
]), async (ctx)=>{
|
||||
//FIXME: parse form data
|
||||
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, [
|
||||
'duration'
|
||||
]), [
|
||||
'notifications'
|
||||
]));
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.muteUser(target, args.notifications, args.duration, ctx);
|
||||
});
|
||||
router.post("/v1/accounts/:id/unmute", auth(true, [
|
||||
"write:mutes"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.unmuteUser(target, ctx);
|
||||
});
|
||||
router.get("/v1/featured_tags", async (ctx)=>{
|
||||
ctx.body = [];
|
||||
});
|
||||
router.get("/v1/followed_tags", async (ctx)=>{
|
||||
ctx.body = [];
|
||||
});
|
||||
router.get("/v1/bookmarks", auth(true, [
|
||||
"read:bookmarks"
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await UserHelpers.getUserBookmarks(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/favourites", auth(true, [
|
||||
"read:favourites"
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await UserHelpers.getUserFavorites(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/mutes", auth(true, [
|
||||
"read:mutes"
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
ctx.body = await UserHelpers.getUserMutes(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
});
|
||||
router.get("/v1/blocks", auth(true, [
|
||||
"read:blocks"
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await UserHelpers.getUserBlocks(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/follow_requests", auth(true, [
|
||||
"read:follows"
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await UserHelpers.getUserFollowRequests(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.post("/v1/follow_requests/:id/authorize", auth(true, [
|
||||
"write:follows"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.acceptFollowRequest(target, ctx);
|
||||
});
|
||||
router.post("/v1/follow_requests/:id/reject", auth(true, [
|
||||
"write:follows"
|
||||
]), async (ctx)=>{
|
||||
const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx);
|
||||
ctx.body = await UserHelpers.rejectFollowRequest(target, ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AuthHelpers } from "../helpers/auth.js";
|
||||
import { MiAuth } from "../middleware/auth.js";
|
||||
export function setupEndpointsAuth(router) {
|
||||
router.post("/v1/apps", async (ctx)=>{
|
||||
ctx.body = await AuthHelpers.registerApp(ctx);
|
||||
});
|
||||
router.get("/v1/apps/verify_credentials", async (ctx)=>{
|
||||
ctx.body = await AuthHelpers.verifyAppCredentials(ctx);
|
||||
});
|
||||
router.post("/v1/iceshrimp/apps/info", MiAuth(true), async (ctx)=>{
|
||||
ctx.body = await AuthHelpers.getAppInfo(ctx);
|
||||
});
|
||||
router.post("/v1/iceshrimp/auth/code", MiAuth(true), async (ctx)=>{
|
||||
ctx.body = await AuthHelpers.getAuthCode(ctx);
|
||||
});
|
||||
}
|
||||
export function setupEndpointsAuthRoot(router) {
|
||||
router.post("/oauth/token", async (ctx)=>{
|
||||
ctx.body = await AuthHelpers.getAuthToken(ctx);
|
||||
});
|
||||
router.post("/oauth/revoke", async (ctx)=>{
|
||||
ctx.body = await AuthHelpers.revokeAuthToken(ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { MastoApiError } from "../middleware/catch-errors.js";
|
||||
export function setupEndpointsFilter(router) {
|
||||
router.get([
|
||||
"/v1/filters",
|
||||
"/v2/filters"
|
||||
], auth(true, [
|
||||
'read:filters'
|
||||
]), async (ctx)=>{
|
||||
ctx.body = [];
|
||||
});
|
||||
router.post([
|
||||
"/v1/filters",
|
||||
"/v2/filters"
|
||||
], auth(true, [
|
||||
'write:filters'
|
||||
]), async (ctx)=>{
|
||||
throw new MastoApiError(400, "Please change word mute settings in the web frontend settings.");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
|
||||
import { ListHelpers } from "../helpers/list.js";
|
||||
import { UserConverter } from "../converters/user.js";
|
||||
import { UserLists } from "../../../../models/index.js";
|
||||
import { getUser } from "../../common/getters.js";
|
||||
import { toArray } from "../../../../prelude/array.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { MastoApiError } from "../middleware/catch-errors.js";
|
||||
export function setupEndpointsList(router) {
|
||||
router.get("/v1/lists", auth(true, [
|
||||
'read:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
ctx.body = await ListHelpers.getLists(ctx);
|
||||
});
|
||||
router.get("/v1/lists/:id", auth(true, [
|
||||
'read:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
ctx.body = await ListHelpers.getListOr404(ctx.params.id, ctx);
|
||||
});
|
||||
router.post("/v1/lists", auth(true, [
|
||||
'write:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
const body = ctx.request.body;
|
||||
const title = (body.title ?? '').trim();
|
||||
ctx.body = await ListHelpers.createList(title, ctx);
|
||||
});
|
||||
router.put("/v1/lists/:id", auth(true, [
|
||||
'write:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
const list = await UserLists.findOneBy({
|
||||
userId: ctx.user.id,
|
||||
id: ctx.params.id
|
||||
});
|
||||
if (!list) throw new MastoApiError(404);
|
||||
const body = ctx.request.body;
|
||||
const title = (body.title ?? '').trim();
|
||||
const exclusive = body.exclusive ?? undefined;
|
||||
ctx.body = await ListHelpers.updateList(list, title, exclusive, ctx);
|
||||
});
|
||||
router.delete("/v1/lists/:id", auth(true, [
|
||||
'write:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
const list = await UserLists.findOneBy({
|
||||
userId: ctx.user.id,
|
||||
id: ctx.params.id
|
||||
});
|
||||
if (!list) throw new MastoApiError(404);
|
||||
await ListHelpers.deleteList(list, ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
router.get("/v1/lists/:id/accounts", auth(true, [
|
||||
'read:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await ListHelpers.getListUsers(ctx.params.id, args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.post("/v1/lists/:id/accounts", auth(true, [
|
||||
'write:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
const list = await UserLists.findOneBy({
|
||||
userId: ctx.user.id,
|
||||
id: ctx.params.id
|
||||
});
|
||||
if (!list) throw new MastoApiError(404);
|
||||
const body = ctx.request.body;
|
||||
if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field");
|
||||
const ids = toArray(body['account_ids']);
|
||||
const targets = await Promise.all(ids.map((p)=>getUser(p)));
|
||||
await ListHelpers.addToList(list, targets, ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
router.delete("/v1/lists/:id/accounts", auth(true, [
|
||||
'write:lists'
|
||||
]), async (ctx, reply)=>{
|
||||
const list = await UserLists.findOneBy({
|
||||
userId: ctx.user.id,
|
||||
id: ctx.params.id
|
||||
});
|
||||
if (!list) throw new MastoApiError(404);
|
||||
const body = ctx.request.body;
|
||||
if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field");
|
||||
const ids = toArray(body['account_ids']);
|
||||
const targets = await Promise.all(ids.map((p)=>getUser(p)));
|
||||
await ListHelpers.removeFromList(list, targets, ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MediaHelpers } from "../helpers/media.js";
|
||||
import { FileConverter } from "../converters/file.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
export function setupEndpointsMedia(router) {
|
||||
router.get("/v1/media/:id", auth(true, [
|
||||
'write:media'
|
||||
]), async (ctx)=>{
|
||||
const file = await MediaHelpers.getMediaPackedOr404(ctx.params.id, ctx);
|
||||
ctx.body = FileConverter.encode(file);
|
||||
});
|
||||
router.put("/v1/media/:id", auth(true, [
|
||||
'write:media'
|
||||
]), async (ctx)=>{
|
||||
const file = await MediaHelpers.getMediaOr404(ctx.params.id, ctx);
|
||||
ctx.body = await MediaHelpers.updateMedia(file, ctx).then((p)=>FileConverter.encode(p));
|
||||
});
|
||||
router.post([
|
||||
"/v2/media",
|
||||
"/v1/media"
|
||||
], auth(true, [
|
||||
'write:media'
|
||||
]), async (ctx)=>{
|
||||
ctx.body = await MediaHelpers.uploadMedia(ctx).then((p)=>FileConverter.encode(p));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { MiscHelpers } from "../helpers/misc.js";
|
||||
import { argsToBools, limitToInt } from "./timeline.js";
|
||||
import { Announcements } from "../../../../models/index.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { MastoApiError } from "../middleware/catch-errors.js";
|
||||
import { filterContext } from "../middleware/filter-context.js";
|
||||
export function setupEndpointsMisc(router) {
|
||||
router.get("/v1/custom_emojis", async (ctx)=>{
|
||||
ctx.body = await MiscHelpers.getCustomEmoji();
|
||||
});
|
||||
router.get("/v1/instance", async (ctx)=>{
|
||||
ctx.body = await MiscHelpers.getInstance(ctx);
|
||||
});
|
||||
router.get("/v1/announcements", auth(true), async (ctx)=>{
|
||||
const args = argsToBools(ctx.query, [
|
||||
'with_dismissed'
|
||||
]);
|
||||
ctx.body = await MiscHelpers.getAnnouncements(args['with_dismissed'], ctx);
|
||||
});
|
||||
router.post("/v1/announcements/:id/dismiss", auth(true, [
|
||||
'write:accounts'
|
||||
]), async (ctx)=>{
|
||||
const announcement = await Announcements.findOneBy({
|
||||
id: ctx.params.id
|
||||
});
|
||||
if (!announcement) throw new MastoApiError(404);
|
||||
await MiscHelpers.dismissAnnouncement(announcement, ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
//FIXME: add link pagination to trends (ref: https://mastodon.social/api/v1/trends/tags?offset=10&limit=1)
|
||||
router.get([
|
||||
"/v1/trends/tags",
|
||||
"/v1/trends"
|
||||
], async (ctx)=>{
|
||||
const args = limitToInt(ctx.query);
|
||||
ctx.body = await MiscHelpers.getTrendingHashtags(args.limit, args.offset);
|
||||
//FIXME: convert ids
|
||||
});
|
||||
router.get("/v1/trends/statuses", filterContext('public'), async (ctx)=>{
|
||||
const args = limitToInt(ctx.query);
|
||||
ctx.body = await MiscHelpers.getTrendingStatuses(args.limit, args.offset, ctx);
|
||||
});
|
||||
router.get("/v1/trends/links", async (ctx)=>{
|
||||
ctx.body = [];
|
||||
});
|
||||
router.get("/v1/preferences", auth(true, [
|
||||
'read:accounts'
|
||||
]), async (ctx)=>{
|
||||
ctx.body = await MiscHelpers.getPreferences(ctx);
|
||||
});
|
||||
router.get("/v2/suggestions", auth(true, [
|
||||
'read:accounts'
|
||||
]), async (ctx)=>{
|
||||
const args = limitToInt(ctx.query);
|
||||
ctx.body = await MiscHelpers.getFollowSuggestions(args.limit, ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
|
||||
import { NotificationHelpers } from "../helpers/notification.js";
|
||||
import { NotificationConverter } from "../converters/notification.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { filterContext } from "../middleware/filter-context.js";
|
||||
export function setupEndpointsNotifications(router) {
|
||||
router.get("/v1/notifications", auth(true, [
|
||||
'read:notifications'
|
||||
]), filterContext('notifications'), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query), [
|
||||
'types[]',
|
||||
'exclude_types[]'
|
||||
]);
|
||||
const res = await NotificationHelpers.getNotifications(args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id, ctx);
|
||||
ctx.body = await NotificationConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/notifications/:id", auth(true, [
|
||||
'read:notifications'
|
||||
]), filterContext('notifications'), async (ctx)=>{
|
||||
const notification = await NotificationHelpers.getNotificationOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NotificationConverter.encode(notification, ctx);
|
||||
});
|
||||
router.post("/v1/notifications/clear", auth(true, [
|
||||
'write:notifications'
|
||||
]), async (ctx)=>{
|
||||
await NotificationHelpers.clearAllNotifications(ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
router.post("/v1/notifications/:id/dismiss", auth(true, [
|
||||
'write:notifications'
|
||||
]), async (ctx)=>{
|
||||
const notification = await NotificationHelpers.getNotificationOr404(ctx.params.id, ctx);
|
||||
await NotificationHelpers.dismissNotification(notification.id, ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
router.post("/v1/conversations/:id/read", auth(true, [
|
||||
'write:conversations'
|
||||
]), async (ctx, reply)=>{
|
||||
await NotificationHelpers.markConversationAsRead(ctx.params.id, ctx);
|
||||
ctx.body = {};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js";
|
||||
import { SearchHelpers } from "../helpers/search.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
export function setupEndpointsSearch(router) {
|
||||
router.get([
|
||||
"/v1/search",
|
||||
"/v2/search"
|
||||
], auth(true, [
|
||||
'read:search'
|
||||
]), async (ctx)=>{
|
||||
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query), [
|
||||
'resolve',
|
||||
'following',
|
||||
'exclude_unreviewed'
|
||||
]));
|
||||
ctx.body = await SearchHelpers.search(args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, ctx);
|
||||
if (ctx.path === "/v1/search") {
|
||||
ctx.body = {
|
||||
...ctx.body,
|
||||
hashtags: ctx.body.hashtags.map((p)=>p.name)
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NoteConverter } from "../converters/note.js";
|
||||
import { NoteHelpers } from "../helpers/note.js";
|
||||
import { limitToInt, normalizeUrlQuery } from "./timeline.js";
|
||||
import { UserConverter } from "../converters/user.js";
|
||||
import { PollHelpers } from "../helpers/poll.js";
|
||||
import { toArray } from "../../../../prelude/array.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { MastoApiError } from "../middleware/catch-errors.js";
|
||||
import { filterContext } from "../middleware/filter-context.js";
|
||||
export function setupEndpointsStatus(router) {
|
||||
router.post("/v1/statuses", auth(true, [
|
||||
'write:statuses'
|
||||
]), async (ctx)=>{
|
||||
const key = NoteHelpers.getIdempotencyKey(ctx);
|
||||
if (key !== null) {
|
||||
const result = await NoteHelpers.getFromIdempotencyCache(key);
|
||||
if (result) {
|
||||
ctx.body = result;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let request = NoteHelpers.normalizeComposeOptions(ctx.request.body);
|
||||
ctx.body = await NoteHelpers.createNote(request, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
if (key !== null) NoteHelpers.postIdempotencyCache.set(key, {
|
||||
status: ctx.body
|
||||
});
|
||||
});
|
||||
router.put("/v1/statuses/:id", auth(true, [
|
||||
'write:statuses'
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
let request = NoteHelpers.normalizeEditOptions(ctx.request.body);
|
||||
ctx.body = await NoteHelpers.editNote(request, note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.get("/v1/statuses/:id", auth(false, [
|
||||
"read:statuses"
|
||||
]), filterContext('thread'), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteConverter.encode(note, ctx);
|
||||
});
|
||||
router.delete("/v1/statuses/:id", auth(true, [
|
||||
'write:statuses'
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.deleteNote(note, ctx);
|
||||
});
|
||||
router.get("/v1/statuses/:id/context", auth(false, [
|
||||
"read:statuses"
|
||||
]), filterContext('thread'), async (ctx)=>{
|
||||
//FIXME: determine final limits within helper functions instead of here
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
const ancestors = await NoteHelpers.getNoteAncestors(note, ctx.user ? 4096 : 60, ctx).then((n)=>NoteConverter.encodeMany(n, ctx));
|
||||
const descendants = await NoteHelpers.getNoteDescendants(note, ctx.user ? 4096 : 40, ctx.user ? 4096 : 20, ctx).then((n)=>NoteConverter.encodeMany(n, ctx));
|
||||
ctx.body = {
|
||||
ancestors,
|
||||
descendants
|
||||
};
|
||||
});
|
||||
router.get("/v1/statuses/:id/history", auth(false, [
|
||||
"read:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.getNoteEditHistory(note, ctx);
|
||||
});
|
||||
router.get("/v1/statuses/:id/source", auth(true, [
|
||||
"read:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = NoteHelpers.getNoteSource(note);
|
||||
});
|
||||
router.get("/v1/statuses/:id/reblogged_by", auth(false, [
|
||||
"read:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await NoteHelpers.getNoteRebloggedBy(note, args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/statuses/:id/favourited_by", auth(false, [
|
||||
"read:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await UserConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.post("/v1/statuses/:id/favourite", auth(true, [
|
||||
"write:favourites"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
const reaction = await NoteHelpers.getDefaultReaction();
|
||||
ctx.body = await NoteHelpers.reactToNote(note, reaction, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/unfavourite", auth(true, [
|
||||
"write:favourites"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/reblog", auth(true, [
|
||||
"write:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.reblogNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/unreblog", auth(true, [
|
||||
"write:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.unreblogNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/bookmark", auth(true, [
|
||||
"write:bookmarks"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.bookmarkNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/unbookmark", auth(true, [
|
||||
"write:bookmarks"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.unbookmarkNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/pin", auth(true, [
|
||||
"write:accounts"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.pinNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/unpin", auth(true, [
|
||||
"write:accounts"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.unpinNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/react/:name", auth(true, [
|
||||
"write:favourites"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.reactToNote(note, ctx.params.name, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.post("/v1/statuses/:id/unreact/:name", auth(true, [
|
||||
"write:favourites"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await NoteHelpers.removeReactFromNote(note, ctx).then((p)=>NoteConverter.encode(p, ctx));
|
||||
});
|
||||
router.get("/v1/polls/:id", auth(false, [
|
||||
"read:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
ctx.body = await PollHelpers.getPoll(note, ctx);
|
||||
});
|
||||
router.post("/v1/polls/:id/votes", auth(true, [
|
||||
"write:statuses"
|
||||
]), async (ctx)=>{
|
||||
const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx);
|
||||
const body = ctx.request.body;
|
||||
const choices = toArray(body.choices ?? []).map((p)=>parseInt(p));
|
||||
if (choices.length < 1) throw new MastoApiError(400, "Must vote for at least one option");
|
||||
ctx.body = await PollHelpers.voteInPoll(choices, note, ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export function setupEndpointsStreaming(router) {
|
||||
router.get("/v1/streaming/health", async (ctx)=>{
|
||||
ctx.body = "OK";
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { TimelineHelpers } from "../helpers/timeline.js";
|
||||
import { NoteConverter } from "../converters/note.js";
|
||||
import { UserLists } from "../../../../models/index.js";
|
||||
import { auth } from "../middleware/auth.js";
|
||||
import { MastoApiError } from "../middleware/catch-errors.js";
|
||||
import { filterContext } from "../middleware/filter-context.js";
|
||||
//TODO: Move helper functions to a helper class
|
||||
export function limitToInt(q, additional = []) {
|
||||
let object = q;
|
||||
if (q.limit) {
|
||||
if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10);
|
||||
}
|
||||
if (q.offset) {
|
||||
if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10);
|
||||
}
|
||||
for (const key of additional)if (typeof q[key] === "string") object[key] = parseInt(q[key], 10);
|
||||
return object;
|
||||
}
|
||||
export function argsToBools(q, additional = []) {
|
||||
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
|
||||
const toBoolean = (value)=>![
|
||||
"0",
|
||||
"f",
|
||||
"F",
|
||||
"false",
|
||||
"FALSE",
|
||||
"off",
|
||||
"OFF"
|
||||
].includes(value);
|
||||
// Keys taken from:
|
||||
// - https://docs.joinmastodon.org/methods/accounts/#statuses
|
||||
// - https://docs.joinmastodon.org/methods/timelines/#public
|
||||
// - https://docs.joinmastodon.org/methods/timelines/#tag
|
||||
let keys = [
|
||||
'only_media',
|
||||
'exclude_replies',
|
||||
'exclude_reblogs',
|
||||
'pinned',
|
||||
'local',
|
||||
'remote'
|
||||
].concat(additional);
|
||||
let object = q;
|
||||
for (const key of keys)if (q[key] && typeof q[key] === "string") object[key] = toBoolean(q[key]);
|
||||
return object;
|
||||
}
|
||||
export function normalizeUrlQuery(q, arrayKeys = []) {
|
||||
const dict = {};
|
||||
for(const k in q){
|
||||
if (arrayKeys.includes(k)) dict[k] = Array.isArray(q[k]) ? q[k] : [
|
||||
q[k]
|
||||
];
|
||||
else dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k];
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
export function setupEndpointsTimeline(router) {
|
||||
router.get("/v1/timelines/public", auth(true, [
|
||||
'read:statuses'
|
||||
]), filterContext('public'), async (ctx, reply)=>{
|
||||
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)));
|
||||
const res = await TimelineHelpers.getPublicTimeline(args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/timelines/tag/:hashtag", auth(false, [
|
||||
'read:statuses'
|
||||
]), filterContext('public'), async (ctx, reply)=>{
|
||||
const tag = (ctx.params.hashtag ?? '').trim().toLowerCase();
|
||||
const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)), [
|
||||
'any[]',
|
||||
'all[]',
|
||||
'none[]'
|
||||
]);
|
||||
const res = await TimelineHelpers.getTagTimeline(tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/timelines/home", auth(true, [
|
||||
'read:statuses'
|
||||
]), filterContext('home'), async (ctx, reply)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await TimelineHelpers.getHomeTimeline(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/timelines/list/:listId", auth(true, [
|
||||
'read:lists'
|
||||
]), filterContext('home'), async (ctx, reply)=>{
|
||||
const list = await UserLists.findOneBy({
|
||||
userId: ctx.user.id,
|
||||
id: ctx.params.listId
|
||||
});
|
||||
if (!list) throw new MastoApiError(404);
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
const res = await TimelineHelpers.getListTimeline(list, args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
ctx.body = await NoteConverter.encodeMany(res, ctx);
|
||||
});
|
||||
router.get("/v1/conversations", auth(true, [
|
||||
'read:statuses'
|
||||
]), async (ctx, reply)=>{
|
||||
const args = normalizeUrlQuery(limitToInt(ctx.query));
|
||||
ctx.body = await TimelineHelpers.getConversations(args.max_id, args.since_id, args.min_id, args.limit, ctx);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference path="emoji.ts" />
|
||||
/// <reference path="source.ts" />
|
||||
/// <reference path="field.ts" />
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference path="tag.ts" />
|
||||
/// <reference path="emoji.ts" />
|
||||
/// <reference path="reaction.ts" />
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference path="attachment.ts" />
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference path="status.ts" />
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference path="account.ts" />
|
||||
/// <reference path="status.ts" />
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference path="account.ts" />
|
||||
/// <reference path="urls.ts" />
|
||||
/// <reference path="stats.ts" />
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference path="account.ts" />
|
||||
/// <reference path="status.ts" />
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* OAuth
|
||||
* Response data when oauth request.
|
||||
**/ var OAuth;
|
||||
(function(OAuth) {
|
||||
class TokenData {
|
||||
access_token;
|
||||
token_type;
|
||||
created_at;
|
||||
expires_in;
|
||||
refresh_token;
|
||||
_scope;
|
||||
constructor(access_token, token_type, scope, created_at, expires_in = null, refresh_token = null){
|
||||
this.access_token = access_token;
|
||||
this.token_type = token_type;
|
||||
this.created_at = created_at;
|
||||
this.expires_in = expires_in;
|
||||
this.refresh_token = refresh_token;
|
||||
this._scope = scope;
|
||||
}
|
||||
/**
|
||||
* Serialize raw token data from server
|
||||
* @param raw from server
|
||||
*/ static from(raw) {
|
||||
return new this(raw.access_token, raw.token_type, raw.scope, raw.created_at, raw.expires_in, raw.refresh_token);
|
||||
}
|
||||
/**
|
||||
* OAuth Aceess Token
|
||||
*/ get accessToken() {
|
||||
return this.access_token;
|
||||
}
|
||||
get tokenType() {
|
||||
return this.token_type;
|
||||
}
|
||||
get scope() {
|
||||
return this._scope;
|
||||
}
|
||||
/**
|
||||
* Application ID
|
||||
*/ get createdAt() {
|
||||
return this.created_at;
|
||||
}
|
||||
get expiresIn() {
|
||||
return this.expires_in;
|
||||
}
|
||||
/**
|
||||
* OAuth Refresh Token
|
||||
*/ get refreshToken() {
|
||||
return this.refresh_token;
|
||||
}
|
||||
}
|
||||
OAuth.TokenData = TokenData;
|
||||
})(OAuth || (OAuth = {}));
|
||||
export default OAuth;
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference path="poll_option.ts" />
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference path="account.ts" />
|
||||
@@ -0,0 +1,3 @@
|
||||
/// <reference path="account.ts" />
|
||||
/// <reference path="status.ts" />
|
||||
/// <reference path="tag.ts" />
|
||||
@@ -0,0 +1,2 @@
|
||||
/// <reference path="attachment.ts" />
|
||||
/// <reference path="status_params.ts" />
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference path="field.ts" />
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference path="account.ts" />
|
||||
/// <reference path="application.ts" />
|
||||
/// <reference path="mention.ts" />
|
||||
/// <reference path="tag.ts" />
|
||||
/// <reference path="attachment.ts" />
|
||||
/// <reference path="emoji.ts" />
|
||||
/// <reference path="card.ts" />
|
||||
/// <reference path="poll.ts" />
|
||||
/// <reference path="reaction.ts" />
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <reference path="account.ts" />
|
||||
/// <reference path="application.ts" />
|
||||
/// <reference path="mention.ts" />
|
||||
/// <reference path="tag.ts" />
|
||||
/// <reference path="attachment.ts" />
|
||||
/// <reference path="emoji.ts" />
|
||||
/// <reference path="card.ts" />
|
||||
/// <reference path="poll.ts" />
|
||||
/// <reference path="reaction.ts" />
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference path="history.ts" />
|
||||
@@ -0,0 +1,37 @@
|
||||
/// <reference path="./entities/account.ts" />
|
||||
/// <reference path="./entities/activity.ts" />
|
||||
/// <reference path="./entities/announcement.ts" />
|
||||
/// <reference path="./entities/application.ts" />
|
||||
/// <reference path="./entities/async_attachment.ts" />
|
||||
/// <reference path="./entities/attachment.ts" />
|
||||
/// <reference path="./entities/card.ts" />
|
||||
/// <reference path="./entities/context.ts" />
|
||||
/// <reference path="./entities/conversation.ts" />
|
||||
/// <reference path="./entities/emoji.ts" />
|
||||
/// <reference path="./entities/featured_tag.ts" />
|
||||
/// <reference path="./entities/field.ts" />
|
||||
/// <reference path="./entities/filter.ts" />
|
||||
/// <reference path="./entities/history.ts" />
|
||||
/// <reference path="./entities/identity_proof.ts" />
|
||||
/// <reference path="./entities/instance.ts" />
|
||||
/// <reference path="./entities/list.ts" />
|
||||
/// <reference path="./entities/marker.ts" />
|
||||
/// <reference path="./entities/mention.ts" />
|
||||
/// <reference path="./entities/notification.ts" />
|
||||
/// <reference path="./entities/poll.ts" />
|
||||
/// <reference path="./entities/poll_option.ts" />
|
||||
/// <reference path="./entities/preferences.ts" />
|
||||
/// <reference path="./entities/push_subscription.ts" />
|
||||
/// <reference path="./entities/reaction.ts" />
|
||||
/// <reference path="./entities/relationship.ts" />
|
||||
/// <reference path="./entities/report.ts" />
|
||||
/// <reference path="./entities/results.ts" />
|
||||
/// <reference path="./entities/scheduled_status.ts" />
|
||||
/// <reference path="./entities/source.ts" />
|
||||
/// <reference path="./entities/stats.ts" />
|
||||
/// <reference path="./entities/status.ts" />
|
||||
/// <reference path="./entities/status_params.ts" />
|
||||
/// <reference path="./entities/tag.ts" />
|
||||
/// <reference path="./entities/token.ts" />
|
||||
/// <reference path="./entities/urls.ts" />
|
||||
export default MastodonEntity;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { setupEndpointsAuth } from "./endpoints/auth.js";
|
||||
import { setupEndpointsAccount } from "./endpoints/account.js";
|
||||
import { setupEndpointsStatus } from "./endpoints/status.js";
|
||||
import { setupEndpointsFilter } from "./endpoints/filter.js";
|
||||
import { setupEndpointsTimeline } from "./endpoints/timeline.js";
|
||||
import { setupEndpointsNotifications } from "./endpoints/notifications.js";
|
||||
import { setupEndpointsSearch } from "./endpoints/search.js";
|
||||
import { setupEndpointsMedia } from "./endpoints/media.js";
|
||||
import { setupEndpointsMisc } from "./endpoints/misc.js";
|
||||
import { setupEndpointsList } from "./endpoints/list.js";
|
||||
import { AuthMiddleware } from "./middleware/auth.js";
|
||||
import { CatchErrorsMiddleware } from "./middleware/catch-errors.js";
|
||||
import { apiLogger } from "../logger.js";
|
||||
import { CacheMiddleware } from "./middleware/cache.js";
|
||||
import { KoaBodyMiddleware } from "./middleware/koa-body.js";
|
||||
import { NormalizeQueryMiddleware } from "./middleware/normalize-query.js";
|
||||
import { PaginationMiddleware } from "./middleware/pagination.js";
|
||||
import { SetHeadersMiddleware } from "./middleware/set-headers.js";
|
||||
import { UserHelpers } from "./helpers/user.js";
|
||||
import { setupEndpointsStreaming } from "./endpoints/streaming.js";
|
||||
export const logger = apiLogger.createSubLogger("mastodon");
|
||||
export function setupMastodonApi(router) {
|
||||
setupMiddleware(router);
|
||||
setupEndpointsAuth(router);
|
||||
setupEndpointsAccount(router);
|
||||
setupEndpointsStatus(router);
|
||||
setupEndpointsFilter(router);
|
||||
setupEndpointsTimeline(router);
|
||||
setupEndpointsNotifications(router);
|
||||
setupEndpointsStreaming(router);
|
||||
setupEndpointsSearch(router);
|
||||
setupEndpointsMedia(router);
|
||||
setupEndpointsList(router);
|
||||
setupEndpointsMisc(router);
|
||||
}
|
||||
function setupMiddleware(router) {
|
||||
router.use(KoaBodyMiddleware());
|
||||
router.use(SetHeadersMiddleware);
|
||||
router.use(CatchErrorsMiddleware);
|
||||
router.use(NormalizeQueryMiddleware);
|
||||
router.use(PaginationMiddleware);
|
||||
router.use(AuthMiddleware);
|
||||
router.use(CacheMiddleware);
|
||||
}
|
||||
export function getStubMastoContext(user, filterContext) {
|
||||
return {
|
||||
user: user ?? null,
|
||||
cache: UserHelpers.getFreshAccountCache(),
|
||||
filterContext: filterContext
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { MastoApiError } from "./catch-errors.js";
|
||||
import { OAuthTokens } from "../../../../models/index.js";
|
||||
import authenticate from "../../authenticate.js";
|
||||
import { AuthHelpers } from "../helpers/auth.js";
|
||||
export async function AuthMiddleware(ctx, next) {
|
||||
const token = await getTokenFromOAuth(ctx.headers.authorization);
|
||||
ctx.appId = token?.appId;
|
||||
ctx.user = token?.user ?? null;
|
||||
ctx.scopes = token?.scopes ?? [];
|
||||
await next();
|
||||
}
|
||||
export async function getTokenFromOAuth(authorization) {
|
||||
if (authorization == null) return null;
|
||||
if (authorization.substring(0, 7).toLowerCase() === "bearer ") authorization = authorization.substring(7);
|
||||
return OAuthTokens.findOne({
|
||||
where: {
|
||||
token: authorization,
|
||||
active: true
|
||||
},
|
||||
relations: [
|
||||
'user'
|
||||
]
|
||||
}).then((token)=>{
|
||||
if (!token) return null;
|
||||
return {
|
||||
...token,
|
||||
scopes: AuthHelpers.expandScopes(token.scopes)
|
||||
};
|
||||
});
|
||||
}
|
||||
export function auth(required, scopes = []) {
|
||||
return async function auth(ctx, next) {
|
||||
if (required && !ctx.user) throw new MastoApiError(401, "This method requires an authenticated user");
|
||||
if (!scopes.every((p)=>ctx.scopes.includes(p))) {
|
||||
if (required) throw new MastoApiError(403, "This action is outside the authorized scopes");
|
||||
ctx.user = null;
|
||||
ctx.scopes = [];
|
||||
}
|
||||
await next();
|
||||
};
|
||||
}
|
||||
export function MiAuth(required) {
|
||||
return async function MiAuth(ctx, next) {
|
||||
ctx.miauth = await authenticate(ctx.headers.authorization, null, true).catch((_)=>[
|
||||
null,
|
||||
null
|
||||
]);
|
||||
if (required && !ctx.miauth[0]) throw new MastoApiError(401, "Unauthorized");
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UserHelpers } from "../helpers/user.js";
|
||||
export async function CacheMiddleware(ctx, next) {
|
||||
ctx.cache = UserHelpers.getFreshAccountCache();
|
||||
await next();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { logger } from "../index.js";
|
||||
import { IdentifiableError } from "../../../../misc/identifiable-error.js";
|
||||
import { ApiError } from "../../error.js";
|
||||
export class MastoApiError extends Error {
|
||||
statusCode;
|
||||
errorDescription;
|
||||
constructor(statusCode, message, description){
|
||||
if (message == null) {
|
||||
switch(statusCode){
|
||||
case 404:
|
||||
message = 'Record not found';
|
||||
break;
|
||||
default:
|
||||
message = 'Unknown error occurred';
|
||||
break;
|
||||
}
|
||||
}
|
||||
super(message);
|
||||
this.errorDescription = description;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
export async function CatchErrorsMiddleware(ctx, next) {
|
||||
try {
|
||||
await next();
|
||||
} catch (e) {
|
||||
if (e instanceof MastoApiError) {
|
||||
ctx.status = e.statusCode;
|
||||
ctx.body = {
|
||||
error: e.message,
|
||||
error_description: e.errorDescription
|
||||
};
|
||||
return;
|
||||
} else if (e instanceof IdentifiableError) {
|
||||
if (e.message.length < 1) e.message = e.id;
|
||||
ctx.status = 400;
|
||||
} else if (e instanceof ApiError) {
|
||||
ctx.status = e.httpStatusCode ?? 500;
|
||||
} else {
|
||||
logger.error(`Error occured in ${ctx.method} ${ctx.path}:`);
|
||||
if (e instanceof Error) {
|
||||
if (e.stack) logger.error(e.stack);
|
||||
else logger.error(`${e.name}: ${e.message}`);
|
||||
} else {
|
||||
logger.error(e);
|
||||
}
|
||||
ctx.status = 500;
|
||||
}
|
||||
ctx.body = {
|
||||
error: e.message ?? e
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function filterContext(context) {
|
||||
return async function filterContext(ctx, next) {
|
||||
ctx.filterContext = context;
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { HttpMethodEnum, koaBody } from "koa-body";
|
||||
export function KoaBodyMiddleware() {
|
||||
const options = {
|
||||
multipart: true,
|
||||
urlencoded: true,
|
||||
parsedMethods: [
|
||||
HttpMethodEnum.POST,
|
||||
HttpMethodEnum.PUT,
|
||||
HttpMethodEnum.PATCH,
|
||||
HttpMethodEnum.DELETE
|
||||
] // dear god mastodon why
|
||||
};
|
||||
return koaBody(options);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export async function NormalizeQueryMiddleware(ctx, next) {
|
||||
if (ctx.request.query) {
|
||||
if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) {
|
||||
ctx.request.body = ctx.request.query;
|
||||
} else {
|
||||
ctx.request.body = {
|
||||
...ctx.request.body,
|
||||
...ctx.request.query
|
||||
};
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import config from "../../../../config/index.js";
|
||||
export async function PaginationMiddleware(ctx, next) {
|
||||
await next();
|
||||
if (!ctx.pagination) return;
|
||||
const link = [];
|
||||
const limit = ctx.pagination.limit;
|
||||
if (ctx.pagination.maxId) {
|
||||
const l = `<${config.url}/api${ctx.path}?limit=${limit}&max_id=${ctx.pagination.maxId}>; rel="next"`;
|
||||
link.push(l);
|
||||
}
|
||||
if (ctx.pagination.minId) {
|
||||
const l = `<${config.url}/api${ctx.path}?limit=${limit}&min_id=${ctx.pagination.minId}>; rel="prev"`;
|
||||
link.push(l);
|
||||
}
|
||||
if (link.length > 0) {
|
||||
ctx.response.append('Link', link.join(', '));
|
||||
}
|
||||
}
|
||||
export function generatePaginationData(ids, limit) {
|
||||
if (ids.length < 1) return undefined;
|
||||
return {
|
||||
limit: limit,
|
||||
maxId: ids.length < limit ? undefined : ids.at(-1),
|
||||
minId: ids.at(0)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const headers = {
|
||||
"Access-Control-Expose-Headers": "Link,Connection,Sec-Websocket-Accept,Upgrade"
|
||||
};
|
||||
export async function SetHeadersMiddleware(ctx, next) {
|
||||
ctx.set(headers);
|
||||
await next();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
export class MastodonStream {
|
||||
connection;
|
||||
chName;
|
||||
static shouldShare;
|
||||
static requireCredential;
|
||||
static requiredScopes = [];
|
||||
get user() {
|
||||
return this.connection.user;
|
||||
}
|
||||
get userProfile() {
|
||||
return this.connection.userProfile;
|
||||
}
|
||||
get following() {
|
||||
return this.connection.following;
|
||||
}
|
||||
get muting() {
|
||||
return this.connection.muting;
|
||||
}
|
||||
get renoteMuting() {
|
||||
return this.connection.renoteMuting;
|
||||
}
|
||||
get blocking() {
|
||||
return this.connection.blocking;
|
||||
}
|
||||
get hidden() {
|
||||
return this.connection.hidden;
|
||||
}
|
||||
get subscriber() {
|
||||
return this.connection.subscriber;
|
||||
}
|
||||
constructor(connection, name){
|
||||
this.chName = name;
|
||||
this.connection = connection;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { MastodonStream } from "../channel.js";
|
||||
import { NoteConverter } from "../../converters/note.js";
|
||||
import { NoteHelpers } from "../../helpers/note.js";
|
||||
export class MastodonStreamDirect extends MastodonStream {
|
||||
static shouldShare = true;
|
||||
static requireCredential = true;
|
||||
static requiredScopes = [
|
||||
'read:statuses'
|
||||
];
|
||||
constructor(connection, name){
|
||||
super(connection, name);
|
||||
this.onNote = this.onNote.bind(this);
|
||||
this.onNoteEvent = this.onNoteEvent.bind(this);
|
||||
}
|
||||
get user() {
|
||||
return this.connection.user;
|
||||
}
|
||||
async init() {
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
this.subscriber.on("noteUpdatesStream", this.onNoteEvent);
|
||||
}
|
||||
async onNote(note) {
|
||||
if (!this.shouldProcessNote(note)) return;
|
||||
NoteConverter.encodeEvent(note, this.user).then((encoded)=>{
|
||||
this.connection.send(this.chName, "update", encoded);
|
||||
});
|
||||
NoteHelpers.getConversationFromEvent(note.id, this.user).then((conversation)=>{
|
||||
this.connection.send(this.chName, "conversation", conversation);
|
||||
});
|
||||
}
|
||||
async onNoteEvent(data) {
|
||||
const note = data.body;
|
||||
if (!this.shouldProcessNote(note)) return;
|
||||
NoteHelpers.getConversationFromEvent(note.id, this.user).then((conversation)=>{
|
||||
this.connection.send(this.chName, "conversation", conversation);
|
||||
});
|
||||
switch(data.type){
|
||||
case "updated":
|
||||
NoteConverter.encodeEvent(note, this.user).then((encoded)=>{
|
||||
this.connection.send(this.chName, "status.update", encoded);
|
||||
});
|
||||
break;
|
||||
case "deleted":
|
||||
this.connection.send(this.chName, "delete", note.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
shouldProcessNote(note) {
|
||||
if (note.visibility !== "specified") return false;
|
||||
if (note.userId !== this.user.id && !note.visibleUserIds?.includes(this.user.id)) return false;
|
||||
return true;
|
||||
}
|
||||
dispose() {
|
||||
this.subscriber.off("notesStream", this.onNote);
|
||||
this.subscriber.off("noteUpdatesStream", this.onNoteEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MastodonStream } from "../channel.js";
|
||||
import { NoteConverter } from "../../converters/note.js";
|
||||
import { UserListJoinings } from "../../../../../models/index.js";
|
||||
export class MastodonStreamList extends MastodonStream {
|
||||
static shouldShare = false;
|
||||
static requireCredential = true;
|
||||
static requiredScopes = [
|
||||
'read:statuses'
|
||||
];
|
||||
listId;
|
||||
listUsers = [];
|
||||
listUsersClock;
|
||||
constructor(connection, name, list){
|
||||
super(connection, name);
|
||||
this.listId = list;
|
||||
this.onNote = this.onNote.bind(this);
|
||||
this.onNoteEvent = this.onNoteEvent.bind(this);
|
||||
this.updateListUsers = this.updateListUsers.bind(this);
|
||||
}
|
||||
get user() {
|
||||
return this.connection.user;
|
||||
}
|
||||
async init() {
|
||||
if (!this.listId) return;
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
this.subscriber.on("noteUpdatesStream", this.onNoteEvent);
|
||||
this.updateListUsers();
|
||||
this.listUsersClock = setInterval(this.updateListUsers, 5000);
|
||||
}
|
||||
async updateListUsers() {
|
||||
const users = await UserListJoinings.find({
|
||||
where: {
|
||||
userListId: this.listId
|
||||
},
|
||||
select: [
|
||||
"userId"
|
||||
]
|
||||
});
|
||||
this.listUsers = users.map((x)=>x.userId);
|
||||
}
|
||||
async onNote(note) {
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'home');
|
||||
this.connection.send(this.chName, "update", encoded);
|
||||
}
|
||||
async onNoteEvent(data) {
|
||||
const note = data.body;
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
switch(data.type){
|
||||
case "updated":
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'home');
|
||||
this.connection.send(this.chName, "status.update", encoded);
|
||||
break;
|
||||
case "deleted":
|
||||
this.connection.send(this.chName, "delete", note.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async shouldProcessNote(note) {
|
||||
if (!this.listUsers.includes(note.userId)) return false;
|
||||
if (note.channelId) return false;
|
||||
if (note.renoteId !== null && !note.text && this.renoteMuting.has(note.userId)) return false;
|
||||
if (note.visibility === "specified") return !!note.visibleUserIds?.includes(this.user.id);
|
||||
if (note.visibility === "followers") return this.following.has(note.userId);
|
||||
return true;
|
||||
}
|
||||
dispose() {
|
||||
this.subscriber.off("notesStream", this.onNote);
|
||||
this.subscriber.off("noteUpdatesStream", this.onNoteEvent);
|
||||
clearInterval(this.listUsersClock);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { MastodonStream } from "../channel.js";
|
||||
import { isUserRelated } from "../../../../../misc/is-user-related.js";
|
||||
import { isInstanceMuted } from "../../../../../misc/is-instance-muted.js";
|
||||
import { NoteConverter } from "../../converters/note.js";
|
||||
import { fetchMeta } from "../../../../../misc/fetch-meta.js";
|
||||
import isQuote from "../../../../../misc/is-quote.js";
|
||||
export class MastodonStreamPublic extends MastodonStream {
|
||||
static shouldShare = true;
|
||||
static requireCredential = false;
|
||||
mediaOnly;
|
||||
localOnly;
|
||||
remoteOnly;
|
||||
allowLocalOnly;
|
||||
constructor(connection, name){
|
||||
super(connection, name);
|
||||
this.mediaOnly = name.endsWith(":media");
|
||||
this.localOnly = name.startsWith("public:local");
|
||||
this.remoteOnly = name.startsWith("public:remote");
|
||||
this.allowLocalOnly = name.startsWith("public:allow_local_only");
|
||||
this.onNote = this.onNote.bind(this);
|
||||
this.onNoteEvent = this.onNoteEvent.bind(this);
|
||||
}
|
||||
async init() {
|
||||
const meta = await fetchMeta();
|
||||
if (meta.disableGlobalTimeline) {
|
||||
if (this.user == null || !(this.user.isAdmin || this.user.isModerator)) return;
|
||||
}
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
this.subscriber.on("noteUpdatesStream", this.onNoteEvent);
|
||||
}
|
||||
async onNote(note) {
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'public');
|
||||
this.connection.send(this.chName, "update", encoded);
|
||||
}
|
||||
async onNoteEvent(data) {
|
||||
const note = data.body;
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
switch(data.type){
|
||||
case "updated":
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'public');
|
||||
this.connection.send(this.chName, "status.update", encoded);
|
||||
break;
|
||||
case "deleted":
|
||||
this.connection.send(this.chName, "delete", note.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async shouldProcessNote(note) {
|
||||
if (note.visibility !== "public") return false;
|
||||
if (note.channelId != null) return false;
|
||||
if (this.mediaOnly && note.fileIds.length < 1) return false;
|
||||
if (this.localOnly && note.userHost !== null) return false;
|
||||
if (this.remoteOnly && note.userHost === null) return false;
|
||||
if (note.localOnly && !this.allowLocalOnly && !this.localOnly) return false;
|
||||
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false;
|
||||
if (isUserRelated(note, this.muting)) return false;
|
||||
if (isUserRelated(note, this.blocking)) return false;
|
||||
if (note.renoteId !== null && !isQuote(note) && this.renoteMuting.has(note.userId)) return false;
|
||||
return true;
|
||||
}
|
||||
dispose() {
|
||||
this.subscriber.off("notesStream", this.onNote);
|
||||
this.subscriber.off("noteUpdatesStream", this.onNoteEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { MastodonStream } from "../channel.js";
|
||||
import { isUserRelated } from "../../../../../misc/is-user-related.js";
|
||||
import { isInstanceMuted } from "../../../../../misc/is-instance-muted.js";
|
||||
import { NoteConverter } from "../../converters/note.js";
|
||||
import isQuote from "../../../../../misc/is-quote.js";
|
||||
export class MastodonStreamTag extends MastodonStream {
|
||||
static shouldShare = false;
|
||||
static requireCredential = false;
|
||||
localOnly;
|
||||
tag;
|
||||
constructor(connection, name, tag){
|
||||
super(connection, name);
|
||||
this.tag = tag;
|
||||
this.localOnly = name.startsWith("hashtag:local");
|
||||
this.onNote = this.onNote.bind(this);
|
||||
this.onNoteEvent = this.onNoteEvent.bind(this);
|
||||
}
|
||||
get user() {
|
||||
return this.connection.user;
|
||||
}
|
||||
async init() {
|
||||
if (!this.tag) return;
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
this.subscriber.on("noteUpdatesStream", this.onNoteEvent);
|
||||
}
|
||||
async onNote(note) {
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'public');
|
||||
this.connection.send(this.chName, "update", encoded);
|
||||
}
|
||||
async onNoteEvent(data) {
|
||||
const note = data.body;
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
switch(data.type){
|
||||
case "updated":
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'public');
|
||||
this.connection.send(this.chName, "status.update", encoded);
|
||||
break;
|
||||
case "deleted":
|
||||
this.connection.send(this.chName, "delete", note.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async shouldProcessNote(note) {
|
||||
if (note.visibility !== "public") return false;
|
||||
if (note.channelId != null) return false;
|
||||
if (this.localOnly && note.userHost !== null) return false;
|
||||
if (!note.tags?.includes(this.tag)) return false;
|
||||
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false;
|
||||
if (isUserRelated(note, this.muting)) return false;
|
||||
if (isUserRelated(note, this.blocking)) return false;
|
||||
if (note.renoteId !== null && !isQuote(note) && this.renoteMuting.has(note.userId)) return false;
|
||||
return true;
|
||||
}
|
||||
dispose() {
|
||||
this.subscriber.off("notesStream", this.onNote);
|
||||
this.subscriber.off("noteUpdatesStream", this.onNoteEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { MastodonStream } from "../channel.js";
|
||||
import { isUserRelated } from "../../../../../misc/is-user-related.js";
|
||||
import { isInstanceMuted } from "../../../../../misc/is-instance-muted.js";
|
||||
import { NoteConverter } from "../../converters/note.js";
|
||||
import { NotificationConverter } from "../../converters/notification.js";
|
||||
import { AnnouncementConverter } from "../../converters/announcement.js";
|
||||
import isQuote from "../../../../../misc/is-quote.js";
|
||||
export class MastodonStreamUser extends MastodonStream {
|
||||
static shouldShare = true;
|
||||
static requireCredential = true;
|
||||
static requiredScopes = [
|
||||
'read:statuses',
|
||||
'read:notifications'
|
||||
];
|
||||
notificationsOnly;
|
||||
constructor(connection, name){
|
||||
super(connection, name);
|
||||
this.notificationsOnly = name === "user:notification";
|
||||
this.onNote = this.onNote.bind(this);
|
||||
this.onNoteEvent = this.onNoteEvent.bind(this);
|
||||
this.onUserEvent = this.onUserEvent.bind(this);
|
||||
this.onBroadcastEvent = this.onBroadcastEvent.bind(this);
|
||||
}
|
||||
get user() {
|
||||
return this.connection.user;
|
||||
}
|
||||
async init() {
|
||||
this.subscriber.on(`mainStream:${this.user.id}`, this.onUserEvent);
|
||||
if (!this.notificationsOnly) {
|
||||
this.subscriber.on("notesStream", this.onNote);
|
||||
this.subscriber.on("noteUpdatesStream", this.onNoteEvent);
|
||||
this.subscriber.on("broadcast", this.onBroadcastEvent);
|
||||
}
|
||||
}
|
||||
async onNote(note) {
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'home');
|
||||
this.connection.send(this.chName, "update", encoded);
|
||||
}
|
||||
async onNoteEvent(data) {
|
||||
const note = data.body;
|
||||
if (!await this.shouldProcessNote(note)) return;
|
||||
switch(data.type){
|
||||
case "updated":
|
||||
const encoded = await NoteConverter.encodeEvent(note, this.user, 'home');
|
||||
this.connection.send(this.chName, "status.update", encoded);
|
||||
break;
|
||||
case "deleted":
|
||||
this.connection.send(this.chName, "delete", note.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async onUserEvent(data) {
|
||||
switch(data.type){
|
||||
case "notification":
|
||||
const encoded = await NotificationConverter.encodeEvent(data.body.id, this.user, 'notifications');
|
||||
if (encoded) this.connection.send(this.chName, "notification", encoded);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async onBroadcastEvent(data) {
|
||||
switch(data.type){
|
||||
case "announcementAdded":
|
||||
// This shouldn't be necessary but is for some reason
|
||||
data.body.createdAt = new Date(data.body.createdAt);
|
||||
this.connection.send(this.chName, "announcement", await AnnouncementConverter.encode(data.body, false));
|
||||
break;
|
||||
case "announcementDeleted":
|
||||
this.connection.send(this.chName, "announcement.delete", data.body);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async shouldProcessNote(note) {
|
||||
if (note.visibility === "hidden") return false;
|
||||
if (note.userId === this.user.id) return true;
|
||||
if (note.visibility === "specified") return note.visibleUserIds?.includes(this.user.id);
|
||||
if (note.channelId) return false;
|
||||
if (this.user.id !== note.userId && !this.following.has(note.userId)) return false;
|
||||
if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false;
|
||||
if (isUserRelated(note, this.muting)) return false;
|
||||
if (isUserRelated(note, this.blocking)) return false;
|
||||
if (isUserRelated(note, this.hidden)) return false;
|
||||
if (note.renoteId !== null && !isQuote(note) && this.renoteMuting.has(note.userId)) return false;
|
||||
return true;
|
||||
}
|
||||
dispose() {
|
||||
this.subscriber.off(`mainStream:${this.user.id}`, this.onUserEvent);
|
||||
if (!this.notificationsOnly) {
|
||||
this.subscriber.off("notesStream", this.onNote);
|
||||
this.subscriber.off("noteUpdatesStream", this.onNoteEvent);
|
||||
this.subscriber.off("broadcast", this.onBroadcastEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Blockings, Followings, Mutings, RenoteMutings, UserListJoinings, UserProfiles } from "../../../../models/index.js";
|
||||
import { apiLogger } from "../../logger.js";
|
||||
import { MastodonStreamUser } from "./channels/user.js";
|
||||
import { MastodonStreamDirect } from "./channels/direct.js";
|
||||
import { MastodonStreamPublic } from "./channels/public.js";
|
||||
import { MastodonStreamList } from "./channels/list.js";
|
||||
import { toSingleLast } from "../../../../prelude/array.js";
|
||||
import { MastodonStreamTag } from "./channels/tag.js";
|
||||
const logger = apiLogger.createSubLogger("streaming").createSubLogger("mastodon");
|
||||
const channels = {
|
||||
"user": MastodonStreamUser,
|
||||
"user:notification": MastodonStreamUser,
|
||||
"direct": MastodonStreamDirect,
|
||||
"list": MastodonStreamList,
|
||||
"public": MastodonStreamPublic,
|
||||
"public:media": MastodonStreamPublic,
|
||||
"public:local": MastodonStreamPublic,
|
||||
"public:local:media": MastodonStreamPublic,
|
||||
"public:remote": MastodonStreamPublic,
|
||||
"public:remote:media": MastodonStreamPublic,
|
||||
"public:allow_local_only": MastodonStreamPublic,
|
||||
"public:allow_local_only:media": MastodonStreamPublic,
|
||||
"hashtag": MastodonStreamTag,
|
||||
"hashtag:local": MastodonStreamTag
|
||||
};
|
||||
export class MastodonStreamingConnection {
|
||||
user;
|
||||
userProfile;
|
||||
following = new Set();
|
||||
muting = new Set();
|
||||
renoteMuting = new Set();
|
||||
blocking = new Set();
|
||||
hidden = new Set();
|
||||
token;
|
||||
wsConnection;
|
||||
channels = [];
|
||||
subscriber;
|
||||
constructor(wsConnection, subscriber, user, token, query){
|
||||
const channel = toSingleLast(query.stream);
|
||||
logger.debug(`New connection on channel: ${channel}`);
|
||||
this.wsConnection = wsConnection;
|
||||
this.subscriber = subscriber;
|
||||
if (user) this.user = user;
|
||||
if (token) this.token = token;
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onUserEvent = this.onUserEvent.bind(this);
|
||||
this.wsConnection.on("message", this.onMessage);
|
||||
if (this.user) {
|
||||
this.updateFollowing();
|
||||
this.updateMuting();
|
||||
this.updateRenoteMuting();
|
||||
this.updateBlocking();
|
||||
this.updateHidden();
|
||||
this.updateUserProfile();
|
||||
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
|
||||
}
|
||||
if (channel) {
|
||||
const list = toSingleLast(query.list);
|
||||
const tag = toSingleLast(query.tag);
|
||||
this.onMessage({
|
||||
type: "utf8",
|
||||
utf8Data: JSON.stringify({
|
||||
stream: channel,
|
||||
type: "subscribe",
|
||||
list,
|
||||
tag
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
onUserEvent(data) {
|
||||
switch(data.type){
|
||||
case "follow":
|
||||
this.following.add(data.body.id);
|
||||
break;
|
||||
case "unfollow":
|
||||
this.following.delete(data.body.id);
|
||||
break;
|
||||
case "mute":
|
||||
this.muting.add(data.body.id);
|
||||
break;
|
||||
case "unmute":
|
||||
this.muting.delete(data.body.id);
|
||||
break;
|
||||
case "userHidden":
|
||||
this.hidden.add(data.body);
|
||||
break;
|
||||
case "userUnhidden":
|
||||
this.hidden.delete(data.body);
|
||||
break;
|
||||
// TODO: renote mute events
|
||||
// TODO: block events
|
||||
case "updateUserProfile":
|
||||
this.userProfile = data.body;
|
||||
break;
|
||||
case "terminate":
|
||||
this.closeConnection();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
async onMessage(data) {
|
||||
if (data.type !== "utf8") return;
|
||||
if (data.utf8Data == null) return;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data.utf8Data);
|
||||
} catch (e) {
|
||||
logger.error("Failed to parse json data, ignoring");
|
||||
return;
|
||||
}
|
||||
const { stream, type, list, tag } = message;
|
||||
if (!message.stream || !message.type) {
|
||||
logger.error("Invalid message received, ignoring");
|
||||
return;
|
||||
}
|
||||
if (list ?? tag) logger.info(`${type}: ${stream} ${list ?? tag}`);
|
||||
else logger.info(`${type}: ${stream}`);
|
||||
switch(type){
|
||||
case "subscribe":
|
||||
this.connectChannel(stream, list, tag);
|
||||
break;
|
||||
case "unsubscribe":
|
||||
this.disconnectChannel(stream);
|
||||
break;
|
||||
}
|
||||
}
|
||||
send(stream, event, payload) {
|
||||
const json = JSON.stringify({
|
||||
stream: [
|
||||
stream
|
||||
],
|
||||
event: event,
|
||||
payload: typeof payload === "string" ? payload : JSON.stringify(payload)
|
||||
});
|
||||
this.wsConnection.send(json);
|
||||
}
|
||||
connectChannel(channel, list, tag) {
|
||||
if (!channels[channel]) {
|
||||
logger.info(`Ignoring connection to unknown channel ${channel}`);
|
||||
return;
|
||||
}
|
||||
if (channels[channel].requireCredential) {
|
||||
if (this.user == null) {
|
||||
logger.info(`Refusing connection to channel ${channel} without authentication, terminating connection`);
|
||||
this.closeConnection();
|
||||
return;
|
||||
} else if (!channels[channel].requiredScopes.every((p)=>this.token?.scopes?.includes(p))) {
|
||||
logger.info(`Refusing connection to channel ${channel} without required OAuth scopes, terminating connection`);
|
||||
this.closeConnection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (channels[channel].shouldShare && this.channels.some((c)=>c.chName === channel)) {
|
||||
return;
|
||||
}
|
||||
let ch;
|
||||
if (channel === "list") {
|
||||
ch = new channels[channel](this, channel, list);
|
||||
} else if (channel.startsWith("hashtag")) ch = new channels[channel](this, channel, tag);
|
||||
else ch = new channels[channel](this, channel);
|
||||
this.channels.push(ch);
|
||||
ch.init(null);
|
||||
}
|
||||
disconnectChannel(channelName) {
|
||||
const channel = this.channels.find((c)=>c.chName === channelName);
|
||||
if (channel) {
|
||||
if (channel.dispose) channel.dispose();
|
||||
this.channels = this.channels.filter((c)=>c.chName !== channelName);
|
||||
}
|
||||
}
|
||||
async updateFollowing() {
|
||||
const followings = await Followings.find({
|
||||
where: {
|
||||
followerId: this.user.id
|
||||
},
|
||||
select: [
|
||||
"followeeId"
|
||||
]
|
||||
});
|
||||
this.following = new Set(followings.map((x)=>x.followeeId));
|
||||
}
|
||||
async updateMuting() {
|
||||
const mutings = await Mutings.find({
|
||||
where: {
|
||||
muterId: this.user.id
|
||||
},
|
||||
select: [
|
||||
"muteeId"
|
||||
]
|
||||
});
|
||||
this.muting = new Set(mutings.map((x)=>x.muteeId));
|
||||
}
|
||||
async updateRenoteMuting() {
|
||||
const renoteMutings = await RenoteMutings.find({
|
||||
where: {
|
||||
muterId: this.user.id
|
||||
},
|
||||
select: [
|
||||
"muteeId"
|
||||
]
|
||||
});
|
||||
this.renoteMuting = new Set(renoteMutings.map((x)=>x.muteeId));
|
||||
}
|
||||
async updateBlocking() {
|
||||
const blockings = await Blockings.find({
|
||||
where: {
|
||||
blockeeId: this.user.id
|
||||
},
|
||||
select: [
|
||||
"blockerId"
|
||||
]
|
||||
});
|
||||
this.blocking = new Set(blockings.map((x)=>x.blockerId));
|
||||
}
|
||||
async updateHidden() {
|
||||
const hidden = await UserListJoinings.find({
|
||||
where: {
|
||||
userList: {
|
||||
userId: this.user.id,
|
||||
hideFromHomeTl: true
|
||||
}
|
||||
},
|
||||
select: [
|
||||
"userId"
|
||||
]
|
||||
});
|
||||
this.hidden = new Set(hidden.map((x)=>x.userId));
|
||||
}
|
||||
async updateUserProfile() {
|
||||
this.userProfile = await UserProfiles.findOneBy({
|
||||
userId: this.user.id
|
||||
});
|
||||
}
|
||||
closeConnection() {
|
||||
this.wsConnection.close();
|
||||
this.dispose();
|
||||
}
|
||||
dispose() {
|
||||
for (const c of this.channels.filter((c)=>c.dispose)){
|
||||
if (c.dispose) c.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user