Fixed 267U.pre2

This commit is contained in:
2026-07-26 18:25:37 +09:00
parent 50bfaeafdf
commit 317d00a284
1286 changed files with 80222 additions and 1 deletions
@@ -0,0 +1,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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/`/g, "&#x60;").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();
}
}
}