[mastodon-client] Code cleanup & reformat

This commit is contained in:
Laura Hausmann 2023-10-01 17:52:59 +02:00
parent 2e7ac53c20
commit 4559b135cb
Signed by: zotan
GPG key ID: D044E84C5BE01605
68 changed files with 4128 additions and 4139 deletions

View file

@ -2,104 +2,111 @@ import { Entity } from "megalodon";
import { convertId, IdType } from "../index.js";
function simpleConvert(data: any) {
// copy the object to bypass weird pass by reference bugs
const result = Object.assign({}, data);
result.id = convertId(data.id, IdType.MastodonId);
return result;
// copy the object to bypass weird pass by reference bugs
const result = Object.assign({}, data);
result.id = convertId(data.id, IdType.MastodonId);
return result;
}
export function convertAccount(account: Entity.Account | MastodonEntity.MutedAccount) {
return simpleConvert(account);
return simpleConvert(account);
}
export function convertAnnouncement(announcement: Entity.Announcement) {
return simpleConvert(announcement);
return simpleConvert(announcement);
}
export function convertAttachment(attachment: Entity.Attachment) {
return simpleConvert(attachment);
return simpleConvert(attachment);
}
export function convertFilter(filter: Entity.Filter) {
return simpleConvert(filter);
return simpleConvert(filter);
}
export function convertList(list: MastodonEntity.List) {
return simpleConvert(list);
return simpleConvert(list);
}
export function convertFeaturedTag(tag: Entity.FeaturedTag) {
return simpleConvert(tag);
return simpleConvert(tag);
}
export function convertNotification(notification: MastodonEntity.Notification) {
notification.account = convertAccount(notification.account);
notification.id = convertId(notification.id, IdType.MastodonId);
if (notification.status)
notification.status = convertStatus(notification.status);
if (notification.reaction)
notification.reaction = convertReaction(notification.reaction);
return notification;
notification.account = convertAccount(notification.account);
notification.id = convertId(notification.id, IdType.MastodonId);
if (notification.status)
notification.status = convertStatus(notification.status);
if (notification.reaction)
notification.reaction = convertReaction(notification.reaction);
return notification;
}
export function convertPoll(poll: Entity.Poll) {
return simpleConvert(poll);
return simpleConvert(poll);
}
export function convertReaction(reaction: MastodonEntity.Reaction) {
if (reaction.accounts) {
reaction.accounts = reaction.accounts.map(convertAccount);
}
return reaction;
if (reaction.accounts) {
reaction.accounts = reaction.accounts.map(convertAccount);
}
return reaction;
}
export function convertRelationship(relationship: Entity.Relationship) {
return simpleConvert(relationship);
return simpleConvert(relationship);
}
export function convertSearch(search: MastodonEntity.Search) {
search.accounts = search.accounts.map(p => convertAccount(p));
search.statuses = search.statuses.map(p => convertStatus(p));
return search;
search.accounts = search.accounts.map(p => convertAccount(p));
search.statuses = search.statuses.map(p => convertStatus(p));
return search;
}
export function convertStatusSource(statusSource: MastodonEntity.StatusSource) {
return simpleConvert(statusSource);
return simpleConvert(statusSource);
}
export function convertStatus(status: MastodonEntity.Status) {
status.account = convertAccount(status.account);
status.id = convertId(status.id, IdType.MastodonId);
if (status.in_reply_to_account_id)
status.in_reply_to_account_id = convertId(
status.in_reply_to_account_id,
IdType.MastodonId,
);
if (status.in_reply_to_id)
status.in_reply_to_id = convertId(status.in_reply_to_id, IdType.MastodonId);
status.media_attachments = status.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
status.mentions = status.mentions.map((mention) => ({
...mention,
id: convertId(mention.id, IdType.MastodonId),
}));
if (status.poll) status.poll = convertPoll(status.poll);
if (status.reblog) status.reblog = convertStatus(status.reblog);
if (status.quote) status.quote = convertStatus(status.quote);
status.reactions = status.reactions.map(convertReaction);
status.account = convertAccount(status.account);
status.id = convertId(status.id, IdType.MastodonId);
if (status.in_reply_to_account_id)
status.in_reply_to_account_id = convertId(
status.in_reply_to_account_id,
IdType.MastodonId,
);
if (status.in_reply_to_id)
status.in_reply_to_id = convertId(status.in_reply_to_id, IdType.MastodonId);
status.media_attachments = status.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
status.mentions = status.mentions.map((mention) => ({
...mention,
id: convertId(mention.id, IdType.MastodonId),
}));
if (status.poll) status.poll = convertPoll(status.poll);
if (status.reblog) status.reblog = convertStatus(status.reblog);
if (status.quote) status.quote = convertStatus(status.quote);
status.reactions = status.reactions.map(convertReaction);
return status;
return status;
}
export function convertStatusEdit(edit: MastodonEntity.StatusEdit) {
edit.account = convertAccount(edit.account);
edit.media_attachments = edit.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
if (edit.poll) edit.poll = convertPoll(edit.poll);
return edit;
edit.account = convertAccount(edit.account);
edit.media_attachments = edit.media_attachments.map((attachment) =>
convertAttachment(attachment),
);
if (edit.poll) edit.poll = convertPoll(edit.poll);
return edit;
}
export function convertConversation(conversation: MastodonEntity.Conversation) {
conversation.id = convertId(conversation.id, IdType.MastodonId);
conversation.accounts = conversation.accounts.map(convertAccount);
if (conversation.last_status) {
conversation.last_status = convertStatus(conversation.last_status);
}
conversation.id = convertId(conversation.id, IdType.MastodonId);
conversation.accounts = conversation.accounts.map(convertAccount);
if (conversation.last_status) {
conversation.last_status = convertStatus(conversation.last_status);
}
return conversation;
return conversation;
}

View file

@ -1,36 +1,36 @@
import { Packed } from "@/misc/schema.js";
export class FileConverter {
public static encode(f: Packed<"DriveFile">): MastodonEntity.Attachment {
return {
id: f.id,
type: this.encodefileType(f.type),
url: f.url ?? "",
remote_url: f.url,
preview_url: f.thumbnailUrl,
text_url: f.url,
meta: {
width: f.properties.width,
height: f.properties.height,
},
description: f.comment,
blurhash: f.blurhash,
};
}
public static encode(f: Packed<"DriveFile">): MastodonEntity.Attachment {
return {
id: f.id,
type: this.encodefileType(f.type),
url: f.url ?? "",
remote_url: f.url,
preview_url: f.thumbnailUrl,
text_url: f.url,
meta: {
width: f.properties.width,
height: f.properties.height,
},
description: f.comment,
blurhash: f.blurhash,
};
}
private static encodefileType(s: string): "unknown" | "image" | "gifv" | "video" | "audio" {
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";
};
private static encodefileType(s: string): "unknown" | "image" | "gifv" | "video" | "audio" {
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";
};
}

View file

@ -3,21 +3,21 @@ import config from "@/config/index.js";
import { IMentionedRemoteUsers } from "@/models/entities/note.js";
export class MentionConverter {
public static encode(u: User, m: IMentionedRemoteUsers): MastodonEntity.Mention {
let acct = u.username;
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
let url: string | null = 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,
};
}
public static encode(u: User, m: IMentionedRemoteUsers): MastodonEntity.Mention {
let acct = u.username;
let acctUrl = `https://${u.host || config.host}/@${u.username}`;
let url: string | null = 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,
};
}
}

View file

@ -1,5 +1,5 @@
import { ILocalUser } from "@/models/entities/user.js";
import {getNote, getUser} from "@/server/api/common/getters.js";
import { getNote } from "@/server/api/common/getters.js";
import { Note } from "@/models/entities/note.js";
import config from "@/config/index.js";
import mfm from "mfm-js";
@ -23,75 +23,75 @@ export class NoteConverter {
public static async encode(note: Note, user: ILocalUser | null, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Status> {
const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, cache);
if (!await Notes.isVisibleForMe(note, user?.id ?? null))
throw new Error('Cannot encode note not visible for user');
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 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 reactionEmojiNames = Object.keys(note.reactions)
.filter((x) => x?.startsWith(":"))
.map((x) => decodeReaction(x).reaction)
.map((x) => x.replace(/:/g, ""));
const noteEmoji = host.then(async host => populateEmojis(
note.emojis.concat(reactionEmojiNames),
host,
));
const noteEmoji = host.then(async host => populateEmojis(
note.emojis.concat(reactionEmojiNames),
host,
));
const reactionCount = NoteReactions.countBy({noteId: note.id});
const reactionCount = NoteReactions.countBy({noteId: note.id});
const reaction = user ? NoteReactions.findOneBy({
userId: user.id,
noteId: note.id,
}) : null;
const reaction = user ? NoteReactions.findOneBy({
userId: user.id,
noteId: note.id,
}) : null;
const isFavorited = Promise.resolve(reaction).then(p => !!p);
const isFavorited = Promise.resolve(reaction).then(p => !!p);
const isReblogged = user ? Notes.exist({
where: {
userId: user.id,
renoteId: note.id,
text: IsNull(),
}
}) : null;
const isReblogged = user ? Notes.exist({
where: {
userId: user.id,
renoteId: note.id,
text: IsNull(),
}
}) : null;
const renote = note.renote ?? (note.renoteId ? getNote(note.renoteId, user) : null);
const renote = note.renote ?? (note.renoteId ? getNote(note.renoteId, user) : null);
const isBookmarked = user ? NoteFavorites.exist({
where: {
userId: user.id,
noteId: note.id,
},
take: 1,
}) : false;
const isBookmarked = user ? NoteFavorites.exist({
where: {
userId: user.id,
noteId: note.id,
},
take: 1,
}) : false;
const isMuted = user ? NoteThreadMutings.exist({
where: {
userId: user.id,
threadId: note.threadId || note.id,
}
}) : false;
const isMuted = user ? NoteThreadMutings.exist({
where: {
userId: user.id,
threadId: note.threadId || note.id,
}
}) : false;
const files = DriveFiles.packMany(note.fileIds);
const files = DriveFiles.packMany(note.fileIds);
const mentions = Promise.all(note.mentions.map(p =>
UserHelpers.getUserCached(p, cache)
.then(u => MentionConverter.encode(u, JSON.parse(note.mentionedRemoteUsers)))
.catch(() => null)))
.then(p => p.filter(m => m)) as Promise<MastodonEntity.Mention[]>;
const mentions = Promise.all(note.mentions.map(p =>
UserHelpers.getUserCached(p, cache)
.then(u => MentionConverter.encode(u, JSON.parse(note.mentionedRemoteUsers)))
.catch(() => null)))
.then(p => p.filter(m => m)) as Promise<MastodonEntity.Mention[]>;
const text = Promise.resolve(renote).then(renote => {
return renote && note.text !== null
? note.text + `\n\nRE: ${renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`}`
: note.text;
});
const text = Promise.resolve(renote).then(renote => {
return renote && note.text !== null
? note.text + `\n\nRE: ${renote.uri ? renote.uri : `${config.url}/notes/${renote.id}`}`
: note.text;
});
const isPinned = user && note.userId === user.id
? UserNotePinings.exist({ where: {userId: user.id, noteId: note.id } })
: undefined;
const isPinned = user && note.userId === user.id
? UserNotePinings.exist({where: {userId: user.id, noteId: note.id}})
: undefined;
// noinspection ES6MissingAwait
return await awaitAll({
return await awaitAll({
id: note.id,
uri: note.uri ? note.uri : `https://${config.host}/notes/${note.id}`,
url: note.uri ? note.uri : `https://${config.host}/notes/${note.id}`,
@ -104,9 +104,9 @@ export class NoteConverter {
created_at: note.createdAt.toISOString(),
// Remove reaction emojis with names containing @ from the emojis list.
emojis: noteEmoji
.then(noteEmoji => noteEmoji
.filter((e) => e.name.indexOf("@") === -1)
.map((e) => EmojiConverter.encode(e))),
.then(noteEmoji => noteEmoji
.filter((e) => e.name.indexOf("@") === -1)
.map((e) => EmojiConverter.encode(e))),
replies_count: note.repliesCount,
reblogs_count: note.renoteCount,
favourites_count: reactionCount,
@ -128,12 +128,12 @@ export class NoteConverter {
reactions: [], //FIXME: this.mapReactions(n.emojis, n.reactions, n.myReaction),
bookmarked: isBookmarked,
quote: Promise.resolve(renote).then(renote => renote && note.text !== null ? this.encode(renote, user, cache) : null),
edited_at: note.updatedAt?.toISOString()
edited_at: note.updatedAt?.toISOString()
});
}
public static async encodeMany(notes: Note[], user: ILocalUser | null, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Status[]> {
const encoded = notes.map(n => this.encode(n, user, cache));
return Promise.all(encoded);
}
public static async encodeMany(notes: Note[], user: ILocalUser | null, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Status[]> {
const encoded = notes.map(n => this.encode(n, user, cache));
return Promise.all(encoded);
}
}

View file

@ -10,71 +10,71 @@ import { getNote } from "@/server/api/common/getters.js";
type NotificationType = typeof notificationTypes[number];
export class NotificationConverter {
public static async encode(notification: Notification, localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Notification> {
if (notification.notifieeId !== localUser.id) throw new Error('User is not recipient of notification');
public static async encode(notification: Notification, localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Notification> {
if (notification.notifieeId !== localUser.id) throw new Error('User is not recipient of notification');
//TODO: Test this (poll ended etc)
const account = notification.notifierId
? UserHelpers.getUserCached(notification.notifierId, cache).then(p => UserConverter.encode(p))
: UserConverter.encode(localUser);
//TODO: Test this (poll ended etc)
const account = notification.notifierId
? UserHelpers.getUserCached(notification.notifierId, cache).then(p => UserConverter.encode(p))
: UserConverter.encode(localUser);
let result = {
id: notification.id,
account: account,
created_at: notification.createdAt.toISOString(),
type: this.encodeNotificationType(notification.type),
};
let result = {
id: notification.id,
account: account,
created_at: notification.createdAt.toISOString(),
type: this.encodeNotificationType(notification.type),
};
if (notification.note) {
const isPureRenote = notification.note.renoteId !== null && notification.note.text === null;
const encodedNote = isPureRenote
? getNote(notification.note.renoteId!, localUser).then(note => NoteConverter.encode(note, localUser, cache))
: NoteConverter.encode(notification.note, localUser, cache);
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);
}
if (notification.note) {
const isPureRenote = notification.note.renoteId !== null && notification.note.text === null;
const encodedNote = isPureRenote
? getNote(notification.note.renoteId!, localUser).then(note => NoteConverter.encode(note, localUser, cache))
: NoteConverter.encode(notification.note, localUser, cache);
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);
}
public static async encodeMany(notifications: Notification[], localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Notification[]> {
const encoded = notifications.map(u => this.encode(u, localUser, cache));
return Promise.all(encoded)
.then(p => p.filter(n => n !== null) as MastodonEntity.Notification[]);
}
public static async encodeMany(notifications: Notification[], localUser: ILocalUser, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Notification[]> {
const encoded = notifications.map(u => this.encode(u, localUser, cache));
return Promise.all(encoded)
.then(p => p.filter(n => n !== null) as MastodonEntity.Notification[]);
}
private static encodeNotificationType(t: NotificationType): MastodonEntity.NotificationType {
//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`);
}
}
private static encodeNotificationType(t: NotificationType): MastodonEntity.NotificationType {
//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`);
}
}
}

View file

@ -1,37 +1,37 @@
type Choice = {
text: string
votes: number
isVoted: boolean
text: string
votes: number
isVoted: boolean
}
type Poll = {
multiple: boolean
expiresAt: Date | null
choices: Array<Choice>
multiple: boolean
expiresAt: Date | null
choices: Array<Choice>
}
export class PollConverter {
public static encode(p: Poll, noteId: string): MastodonEntity.Poll {
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)),
voted: p.choices.some((c) => c.isVoted),
own_votes: p.choices
.filter((c) => c.isVoted)
.map((c) => p.choices.indexOf(c)),
};
}
public static encode(p: Poll, noteId: string): MastodonEntity.Poll {
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)),
voted: p.choices.some((c) => c.isVoted),
own_votes: p.choices
.filter((c) => c.isVoted)
.map((c) => p.choices.indexOf(c)),
};
}
private static encodeChoice(c: Choice): MastodonEntity.PollOption {
return {
title: c.text,
votes_count: c.votes,
};
}
private static encodeChoice(c: Choice): MastodonEntity.PollOption {
return {
title: c.text,
votes_count: c.votes,
};
}
}

View file

@ -10,72 +10,72 @@ import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
type Field = {
name: string;
value: string;
verified?: boolean;
name: string;
value: string;
verified?: boolean;
};
export class UserConverter {
public static async encode(u: User, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Account> {
return cache.locks.acquire(u.id, async () => {
const cacheHit = cache.accounts.find(p => p.id == u.id);
if (cacheHit) return cacheHit;
public static async encode(u: User, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Account> {
return cache.locks.acquire(u.id, async () => {
const cacheHit = cache.accounts.find(p => p.id == u.id);
if (cacheHit) return cacheHit;
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 profile = UserProfiles.findOneBy({userId: u.id});
const bio = profile.then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? "")) ?? escapeMFM(profile?.description ?? ""));
const avatar = u.avatarId
? (DriveFiles.findOneBy({ id: u.avatarId }))
.then(p => p?.url ?? Users.getIdenticonUrl(u.id))
: Users.getIdenticonUrl(u.id);
const banner = u.bannerId
? (DriveFiles.findOneBy({ id: u.bannerId }))
.then(p => p?.url ?? `${config.url}/static-assets/transparent.png`)
: `${config.url}/static-assets/transparent.png`;
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 profile = UserProfiles.findOneBy({userId: u.id});
const bio = profile.then(profile => MfmHelpers.toHtml(mfm.parse(profile?.description ?? "")) ?? escapeMFM(profile?.description ?? ""));
const avatar = u.avatarId
? (DriveFiles.findOneBy({id: u.avatarId}))
.then(p => p?.url ?? Users.getIdenticonUrl(u.id))
: Users.getIdenticonUrl(u.id);
const banner = u.bannerId
? (DriveFiles.findOneBy({id: u.bannerId}))
.then(p => p?.url ?? `${config.url}/static-assets/transparent.png`)
: `${config.url}/static-assets/transparent.png`;
return awaitAll({
id: u.id,
username: u.username,
acct: acct,
display_name: u.name || u.username,
locked: u.isLocked,
created_at: u.createdAt.toISOString(),
followers_count: u.followersCount,
following_count: u.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, //FIXME
fields: profile.then(profile => profile?.fields.map(p => this.encodeField(p)) ?? []),
bot: u.isBot,
discoverable: u.isExplorable
}).then(p => {
cache.accounts.push(p);
return p;
});
});
}
return awaitAll({
id: u.id,
username: u.username,
acct: acct,
display_name: u.name || u.username,
locked: u.isLocked,
created_at: u.createdAt.toISOString(),
followers_count: u.followersCount,
following_count: u.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, //FIXME
fields: profile.then(profile => profile?.fields.map(p => this.encodeField(p)) ?? []),
bot: u.isBot,
discoverable: u.isExplorable
}).then(p => {
cache.accounts.push(p);
return p;
});
});
}
public static async encodeMany(users: User[], cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Account[]> {
const encoded = users.map(u => this.encode(u, cache));
return Promise.all(encoded);
}
public static async encodeMany(users: User[], cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Account[]> {
const encoded = users.map(u => this.encode(u, cache));
return Promise.all(encoded);
}
private static encodeField(f: Field): MastodonEntity.Field {
return {
name: f.name,
value: MfmHelpers.toHtml(mfm.parse(f.value)) ?? escapeMFM(f.value),
verified_at: f.verified ? (new Date()).toISOString() : null,
}
}
private static encodeField(f: Field): MastodonEntity.Field {
return {
name: f.name,
value: MfmHelpers.toHtml(mfm.parse(f.value)) ?? escapeMFM(f.value),
verified_at: f.verified ? (new Date()).toISOString() : null,
}
}
}

View file

@ -2,7 +2,7 @@ type IceshrimpVisibility = "public" | "home" | "followers" | "specified" | "hidd
type MastodonVisibility = "public" | "unlisted" | "private" | "direct";
export class VisibilityConverter {
public static encode (v: IceshrimpVisibility): MastodonVisibility {
public static encode(v: IceshrimpVisibility): MastodonVisibility {
switch (v) {
case "public":
return v;

File diff suppressed because it is too large Load diff

View file

@ -3,70 +3,70 @@ import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js";
import { convertId, IdType } from "@/misc/convert-id.js";
const readScope = [
"read:account",
"read:drive",
"read:blocks",
"read:favorites",
"read:following",
"read:messaging",
"read:mutes",
"read:notifications",
"read:reactions",
"read:pages",
"read:page-likes",
"read:user-groups",
"read:channels",
"read:gallery",
"read:gallery-likes",
"read:account",
"read:drive",
"read:blocks",
"read:favorites",
"read:following",
"read:messaging",
"read:mutes",
"read:notifications",
"read:reactions",
"read:pages",
"read:page-likes",
"read:user-groups",
"read:channels",
"read:gallery",
"read:gallery-likes",
];
const writeScope = [
"write:account",
"write:drive",
"write:blocks",
"write:favorites",
"write:following",
"write:messaging",
"write:mutes",
"write:notes",
"write:notifications",
"write:reactions",
"write:votes",
"write:pages",
"write:page-likes",
"write:user-groups",
"write:channels",
"write:gallery",
"write:gallery-likes",
"write:account",
"write:drive",
"write:blocks",
"write:favorites",
"write:following",
"write:messaging",
"write:mutes",
"write:notes",
"write:notifications",
"write:reactions",
"write:votes",
"write:pages",
"write:page-likes",
"write:user-groups",
"write:channels",
"write:gallery",
"write:gallery-likes",
];
export function setupEndpointsAuth(router: Router): void {
router.post("/v1/apps", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query;
try {
let scope = body.scopes;
if (typeof scope === "string") scope = scope.split(" ");
const pushScope = new Set<string>();
for (const s of scope) {
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
}
const scopeArr = Array.from(pushScope);
router.post("/v1/apps", async (ctx) => {
const body: any = ctx.request.body || ctx.request.query;
try {
let scope = body.scopes;
if (typeof scope === "string") scope = scope.split(" ");
const pushScope = new Set<string>();
for (const s of scope) {
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
}
const scopeArr = Array.from(pushScope);
const red = body.redirect_uris;
const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']);
const returns = {
id: convertId(appData.id, IdType.MastodonId),
name: appData.name,
website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url ?? "").toString("base64"),
client_secret: appData.clientSecret,
};
ctx.body = returns;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
const red = body.redirect_uris;
const appData = await AuthHelpers.registerApp(body['client_name'], scopeArr, red, body['website']);
const returns = {
id: convertId(appData.id, IdType.MastodonId),
name: appData.name,
website: body.website,
redirect_uri: red,
client_id: Buffer.from(appData.url ?? "").toString("base64"),
client_secret: appData.clientSecret,
};
ctx.body = returns;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -1,90 +1,89 @@
import megalodon, { MegalodonInterface } from "megalodon";
import Router from "@koa/router";
import { getClient } from "../index.js";
import { IdType, convertId } from "../../index.js";
import { convertId, IdType } from "../../index.js";
import { convertFilter } from "../converters.js";
export function setupEndpointsFilter(router: Router): void {
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilters();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilters();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilter(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilter(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.createFilter(body.phrase, body.context, body);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.createFilter(body.phrase, body.context, body);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.updateFilter(
convertId(ctx.params.id, IdType.IceshrimpId),
body.phrase,
body.context,
);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.updateFilter(
convertId(ctx.params.id, IdType.IceshrimpId),
body.phrase,
body.context,
);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.delete("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.deleteFilter(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.delete("/v1/filters/:id", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.deleteFilter(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -1,181 +1,172 @@
import Router from "@koa/router";
import { getClient } from "../index.js";
import { ParsedUrlQuery } from "querystring";
import {
convertAccount,
convertConversation,
convertList,
convertStatus,
} from "../converters.js";
import { convertAccount, convertList, } from "../converters.js";
import { convertId, IdType } from "../../index.js";
import authenticate from "@/server/api/authenticate.js";
import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js";
import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js";
import { ListHelpers } from "@/server/api/mastodon/helpers/list.js";
import { UserConverter } from "@/server/api/mastodon/converters/user.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
export function setupEndpointsList(router: Router): void {
router.get("/v1/lists", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
router.get("/v1/lists", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
ctx.body = await ListHelpers.getLists(user)
.then(p => p.map(list => convertList(list)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
ctx.body = await ListHelpers.getLists(user)
.then(p => p.map(list => convertList(list)));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const id = convertId(ctx.params.id, IdType.IceshrimpId);
ctx.body = await ListHelpers.getList(user, id)
.then(p => convertList(p));
} catch (e: any) {
ctx.status = 404;
}
},
);
router.post("/v1/lists", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createList((ctx.request.body as any).title);
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateList(
convertId(ctx.params.id, IdType.IceshrimpId),
(ctx.request.body as any).title,
);
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteList(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
ctx.body = await ListHelpers.getList(user, id)
.then(p => convertList(p));
} catch (e: any) {
ctx.status = 404;
}
},
);
router.post("/v1/lists", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createList((ctx.request.body as any).title);
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateList(
convertId(ctx.params.id, IdType.IceshrimpId),
(ctx.request.body as any).title,
);
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteList(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const res = await ListHelpers.getListUsers(user, id, args.max_id, args.since_id, args.min_id, args.limit);
const accounts = await UserConverter.encodeMany(res.data);
ctx.body = accounts.map(account => convertAccount(account));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res);
} catch (e: any) {
ctx.status = 404;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.addAccountsToList(
convertId(ctx.params.id, IdType.IceshrimpId),
(ctx.query.account_ids as string[]).map((id) =>
convertId(id, IdType.IceshrimpId),
),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteAccountsFromList(
convertId(ctx.params.id, IdType.IceshrimpId),
(ctx.query.account_ids as string[]).map((id) =>
convertId(id, IdType.IceshrimpId),
),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const res = await ListHelpers.getListUsers(user, id, args.max_id, args.since_id, args.min_id, args.limit);
const accounts = await UserConverter.encodeMany(res.data);
ctx.body = accounts.map(account => convertAccount(account));
PaginationHelpers.appendLinkPaginationHeader(args, ctx, res);
} catch (e: any) {
ctx.status = 404;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.addAccountsToList(
convertId(ctx.params.id, IdType.IceshrimpId),
(ctx.query.account_ids as string[]).map((id) =>
convertId(id, IdType.IceshrimpId),
),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.delete<{ Params: { id: string } }>(
"/v1/lists/:id/accounts",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteAccountsFromList(
convertId(ctx.params.id, IdType.IceshrimpId),
(ctx.query.account_ids as string[]).map((id) =>
convertId(id, IdType.IceshrimpId),
),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
}

View file

@ -7,85 +7,85 @@ import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js";
import { FileConverter } from "@/server/api/mastodon/converters/file.js";
export function setupEndpointsMedia(router: Router, fileRouter: Router, upload: multer.Instance): void {
router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
router.get<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const file = await MediaHelpers.getMediaPacked(user, id);
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const file = await MediaHelpers.getMediaPacked(user, id);
if (!file) {
ctx.status = 404;
ctx.body = { error: "File not found" };
return;
}
if (!file) {
ctx.status = 404;
ctx.body = {error: "File not found"};
return;
}
const attachment = FileConverter.encode(file);
ctx.body = convertAttachment(attachment);
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
const attachment = FileConverter.encode(file);
ctx.body = convertAttachment(attachment);
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = e.response.data;
}
});
router.put<{ Params: { id: string } }>("/v1/media/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const file = await MediaHelpers.getMedia(user, id);
const id = convertId(ctx.params.id, IdType.IceshrimpId);
const file = await MediaHelpers.getMedia(user, id);
if (!file) {
ctx.status = 404;
ctx.body = { error: "File not found" };
return;
}
if (!file) {
ctx.status = 404;
ctx.body = {error: "File not found"};
return;
}
const result = await MediaHelpers.updateMedia(user, file, ctx.request.body)
.then(p => FileConverter.encode(p));
ctx.body = convertAttachment(result);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
const result = await MediaHelpers.updateMedia(user, file, ctx.request.body)
.then(p => FileConverter.encode(p));
ctx.body = convertAttachment(result);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
fileRouter.post(["/v2/media", "/v1/media"], upload.single("file"), async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
fileRouter.post(["/v2/media", "/v1/media"], upload.single("file"), async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const file = await ctx.file;
if (!file) {
ctx.body = { error: "No image" };
ctx.status = 400;
return;
}
const result = await MediaHelpers.uploadMedia(user, file, ctx.request.body)
.then(p => FileConverter.encode(p));
ctx.body = convertAttachment(result);
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = { error: e.message };
}
});
const file = await ctx.file;
if (!file) {
ctx.body = {error: "No image"};
ctx.status = 400;
return;
}
const result = await MediaHelpers.uploadMedia(user, file, ctx.request.body)
.then(p => FileConverter.encode(p));
ctx.body = convertAttachment(result);
} catch (e: any) {
console.error(e);
ctx.status = 500;
ctx.body = {error: e.message};
}
});
}

View file

@ -1,70 +1,70 @@
import { Entity } from "megalodon";
import config from "@/config/index.js";
import { fetchMeta } from "@/misc/fetch-meta.js";
import { Users, Notes } from "@/models/index.js";
import { Notes, Users } from "@/models/index.js";
import { IsNull } from "typeorm";
import { MAX_NOTE_TEXT_LENGTH, FILE_TYPE_BROWSERSAFE } from "@/const.js";
import { FILE_TYPE_BROWSERSAFE, MAX_NOTE_TEXT_LENGTH } from "@/const.js";
// TODO: add iceshrimp features
export async function getInstance(
response: Entity.Instance,
contact: Entity.Account,
response: Entity.Instance,
contact: Entity.Account,
) {
const [meta, totalUsers, totalStatuses] = await Promise.all([
fetchMeta(true),
Users.count({ where: { host: IsNull() } }),
Notes.count({ where: { userHost: IsNull() } }),
]);
const [meta, totalUsers, totalStatuses] = await Promise.all([
fetchMeta(true),
Users.count({where: {host: IsNull()}}),
Notes.count({where: {userHost: IsNull()}}),
]);
return {
uri: response.uri,
title: response.title || "Iceshrimp",
short_description:
response.description?.substring(0, 50) || "See real server website",
description:
response.description ||
"This is a vanilla Iceshrimp Instance. It doesn't seem to have a description.",
email: response.email || "",
version: `4.1.0 (compatible; Iceshrimp ${config.version})`,
urls: response.urls,
stats: {
user_count: await totalUsers,
status_count: await totalStatuses,
domain_count: response.stats.domain_count,
},
thumbnail: response.thumbnail || "/static-assets/transparent.png",
languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations,
approval_required: !response.registrations,
invites_enabled: response.registrations,
configuration: {
accounts: {
max_featured_tags: 20,
},
statuses: {
max_characters: MAX_NOTE_TEXT_LENGTH,
max_media_attachments: 16,
characters_reserved_per_url: response.uri.length,
},
media_attachments: {
supported_mime_types: FILE_TYPE_BROWSERSAFE,
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_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,
},
},
contact_account: contact,
rules: [],
};
return {
uri: response.uri,
title: response.title || "Iceshrimp",
short_description:
response.description?.substring(0, 50) || "See real server website",
description:
response.description ||
"This is a vanilla Iceshrimp Instance. It doesn't seem to have a description.",
email: response.email || "",
version: `4.1.0 (compatible; Iceshrimp ${config.version})`,
urls: response.urls,
stats: {
user_count: await totalUsers,
status_count: await totalStatuses,
domain_count: response.stats.domain_count,
},
thumbnail: response.thumbnail || "/static-assets/transparent.png",
languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations,
approval_required: !response.registrations,
invites_enabled: response.registrations,
configuration: {
accounts: {
max_featured_tags: 20,
},
statuses: {
max_characters: MAX_NOTE_TEXT_LENGTH,
max_media_attachments: 16,
characters_reserved_per_url: response.uri.length,
},
media_attachments: {
supported_mime_types: FILE_TYPE_BROWSERSAFE,
image_size_limit: 10485760,
image_matrix_limit: 16777216,
video_size_limit: 41943040,
video_frame_rate_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,
},
},
contact_account: contact,
rules: [],
};
}

View file

@ -1,136 +1,131 @@
import Router from "@koa/router";
import { getClient } from "@/server/api/mastodon/index.js";
import { convertId, IdType } from "@/misc/convert-id.js";
import {
convertAccount,
convertAnnouncement,
convertAttachment,
convertFilter
} from "@/server/api/mastodon/converters.js";
import { convertAccount, convertAnnouncement, convertFilter } from "@/server/api/mastodon/converters.js";
import { Users } from "@/models/index.js";
import { getInstance } from "@/server/api/mastodon/endpoints/meta.js";
import { IsNull } from "typeorm";
export function setupEndpointsMisc(router: Router): void {
router.get("/v1/custom_emojis", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/custom_emojis", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceCustomEmojis();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/instance", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstance();
const admin = await Users.findOne({
where: {
host: IsNull(),
isAdmin: true,
isDeleted: false,
isSuspended: false,
},
order: { id: "ASC" },
});
const contact =
admin == null
? null
: convertAccount((await client.getAccount(admin.id)).data);
ctx.body = await getInstance(data.data, contact);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/instance", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstance();
const admin = await Users.findOne({
where: {
host: IsNull(),
isAdmin: true,
isDeleted: false,
isSuspended: false,
},
order: {id: "ASC"},
});
const contact =
admin == null
? null
: convertAccount((await client.getAccount(admin.id)).data);
ctx.body = await getInstance(data.data, contact);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/announcements", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
ctx.body = data.data.map((announcement) =>
convertAnnouncement(announcement),
);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/announcements", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
ctx.body = data.data.map((announcement) =>
convertAnnouncement(announcement),
);
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post<{ Params: { id: string } }>(
"/v1/announcements/:id/dismiss",
async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.dismissInstanceAnnouncement(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/announcements/:id/dismiss",
async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.dismissInstanceAnnouncement(
convertId(ctx.params.id, IdType.IceshrimpId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getFilters();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/filters", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getFilters();
ctx.body = data.data.map((filter) => convertFilter(filter));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/trends", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstanceTrends();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/trends", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getInstanceTrends();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/preferences", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getPreferences();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/preferences", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
// displayed without being logged in
try {
const data = await client.getPreferences();
ctx.body = data.data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -8,95 +8,95 @@ import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.
import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js";
export function setupEndpointsNotifications(router: Router): void {
router.get("/v1/notifications", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
router.get("/v1/notifications", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)), ['types[]', 'exclude_types[]']);
const data = NotificationHelpers.getNotifications(user, args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id)
.then(p => NotificationConverter.encodeMany(p, user, cache))
.then(p => p.map(n => convertNotification(n)));
const cache = UserHelpers.getFreshAccountCache();
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)), ['types[]', 'exclude_types[]']);
const data = NotificationHelpers.getNotifications(user, args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id)
.then(p => NotificationConverter.encodeMany(p, user, cache))
.then(p => p.map(n => convertNotification(n)));
ctx.body = await data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
ctx.body = await data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v1/notifications/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
router.get("/v1/notifications/:id", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const notification = await NotificationHelpers.getNotification(convertId(ctx.params.id, IdType.IceshrimpId), user);
if (notification === null) {
ctx.status = 404;
return;
}
const notification = await NotificationHelpers.getNotification(convertId(ctx.params.id, IdType.IceshrimpId), user);
if (notification === null) {
ctx.status = 404;
return;
}
ctx.body = convertNotification(await NotificationConverter.encode(notification, user));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
ctx.body = convertNotification(await NotificationConverter.encode(notification, user));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/notifications/clear", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
router.post("/v1/notifications/clear", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
await NotificationHelpers.clearAllNotifications(user);
ctx.body = {};
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
await NotificationHelpers.clearAllNotifications(user);
ctx.body = {};
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.post("/v1/notifications/:id/dismiss", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
router.post("/v1/notifications/:id/dismiss", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? null;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const notification = await NotificationHelpers.getNotification(convertId(ctx.params.id, IdType.IceshrimpId), user);
if (notification === null) {
ctx.status = 404;
return;
}
const notification = await NotificationHelpers.getNotification(convertId(ctx.params.id, IdType.IceshrimpId), user);
if (notification === null) {
ctx.status = 404;
return;
}
await NotificationHelpers.dismissNotification(notification.id, user);
ctx.body = {};
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
await NotificationHelpers.dismissNotification(notification.id, user);
ctx.body = {};
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -1,8 +1,6 @@
import megalodon, { MegalodonInterface } from "megalodon";
import Router from "@koa/router";
import { getClient } from "../index.js";
import axios from "axios";
import { Converter } from "megalodon";
import Router from "@koa/router";
import axios from "axios";
import { argsToBools, convertPaginationArgsIds, limitToInt, normalizeUrlQuery } from "./timeline.js";
import { convertAccount, convertSearch, convertStatus } from "../converters.js";
import authenticate from "@/server/api/authenticate.js";
@ -10,137 +8,139 @@ import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
import { SearchHelpers } from "@/server/api/mastodon/helpers/search.js";
export function setupEndpointsSearch(router: Router): void {
router.get("/v1/search", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
router.get("/v1/search", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed'])));
const cache = UserHelpers.getFreshAccountCache();
const result = await SearchHelpers.search(user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache);
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed'])));
const cache = UserHelpers.getFreshAccountCache();
const result = await SearchHelpers.search(user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache);
ctx.body = {
...convertSearch(result),
hashtags: result.hashtags.map(p => p.name),
};
} catch (e: any) {
console.error(e);
ctx.status = 400;
ctx.body = { error: e.message };
}
});
router.get("/v2/search", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
ctx.body = {
...convertSearch(result),
hashtags: result.hashtags.map(p => p.name),
};
} catch (e: any) {
console.error(e);
ctx.status = 400;
ctx.body = {error: e.message};
}
});
router.get("/v2/search", async (ctx) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
if (!user) {
ctx.status = 401;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed'])));
const cache = UserHelpers.getFreshAccountCache();
const result = await SearchHelpers.search(user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache);
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed'])));
const cache = UserHelpers.getFreshAccountCache();
const result = await SearchHelpers.search(user, args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, cache);
ctx.body = convertSearch(result);
} catch (e: any) {
console.error(e);
ctx.status = 400;
ctx.body = { error: e.message };
}
});
router.get("/v1/trends/statuses", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const data = await getHighlight(
BASE_URL,
ctx.request.hostname,
accessTokens,
);
ctx.body = data.map((status) => convertStatus(status));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v2/suggestions", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const query: any = ctx.query;
let data = await getFeaturedUser(
BASE_URL,
ctx.request.hostname,
accessTokens,
query.limit || 20,
);
data = data.map((suggestion) => {
suggestion.account = convertAccount(suggestion.account);
return suggestion;
});
console.log(data);
ctx.body = data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
ctx.body = convertSearch(result);
} catch (e: any) {
console.error(e);
ctx.status = 400;
ctx.body = {error: e.message};
}
});
router.get("/v1/trends/statuses", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const data = await getHighlight(
BASE_URL,
ctx.request.hostname,
accessTokens,
);
ctx.body = data.map((status) => convertStatus(status));
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get("/v2/suggestions", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.headers.authorization;
try {
const query: any = ctx.query;
let data = await getFeaturedUser(
BASE_URL,
ctx.request.hostname,
accessTokens,
query.limit || 20,
);
data = data.map((suggestion) => {
suggestion.account = convertAccount(suggestion.account);
return suggestion;
});
console.log(data);
ctx.body = data;
} catch (e: any) {
console.error(e);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}
async function getHighlight(
BASE_URL: string,
domain: string,
accessTokens: string | undefined,
BASE_URL: string,
domain: string,
accessTokens: string | undefined,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/notes/featured`, {
i: accessToken,
});
const data: MisskeyEntity.Note[] = api.data;
return data.map((note) => new Converter(BASE_URL).note(note, domain));
} catch (e: any) {
console.log(e);
console.log(e.response.data);
return [];
}
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/notes/featured`, {
i: accessToken,
});
const data: MisskeyEntity.Note[] = api.data;
return data.map((note) => new Converter(BASE_URL).note(note, domain));
} catch (e: any) {
console.log(e);
console.log(e.response.data);
return [];
}
}
async function getFeaturedUser(
BASE_URL: string,
host: string,
accessTokens: string | undefined,
limit: number,
BASE_URL: string,
host: string,
accessTokens: string | undefined,
limit: number,
) {
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/users`, {
i: accessToken,
limit,
origin: "local",
sort: "+follower",
state: "alive",
});
const data: MisskeyEntity.UserDetail[] = api.data;
console.log(data);
return data.map((u) => {
return {
source: "past_interactions",
account: new Converter(BASE_URL).userDetail(u, host),
};
});
} catch (e: any) {
console.log(e);
console.log(e.response.data);
return [];
}
const accessTokenArr = accessTokens?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
try {
const api = await axios.post(`${BASE_URL}/api/users`, {
i: accessToken,
limit,
origin: "local",
sort: "+follower",
state: "alive",
});
const data: MisskeyEntity.UserDetail[] = api.data;
console.log(data);
return data.map((u) => {
return {
source: "past_interactions",
account: new Converter(BASE_URL).userDetail(u, host),
};
});
} catch (e: any) {
console.log(e);
console.log(e.response.data);
return [];
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,12 +1,7 @@
import Router from "@koa/router";
import { getClient } from "../index.js";
import { ParsedUrlQuery } from "querystring";
import {
convertAccount,
convertConversation,
convertList,
convertStatus,
} from "../converters.js";
import { convertConversation, convertStatus, } from "../converters.js";
import { convertId, IdType } from "../../index.js";
import authenticate from "@/server/api/authenticate.js";
import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js";
@ -14,161 +9,161 @@ import { NoteConverter } from "@/server/api/mastodon/converters/note.js";
import { UserHelpers } from "@/server/api/mastodon/helpers/user.js";
export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) {
let object: any = 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(<string>q[key], 10);
return object;
let object: any = 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(<string>q[key], 10);
return object;
}
export function argsToBools(q: ParsedUrlQuery, additional: string[] = []) {
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
const toBoolean = (value: string) =>
!["0", "f", "F", "false", "FALSE", "off", "OFF"].includes(value);
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
const toBoolean = (value: string) =>
!["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']
let object: any = q;
// 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']
let object: any = q;
for (const key of keys)
if (q[key] && typeof q[key] === "string")
object[key] = toBoolean(<string>q[key]);
for (const key of keys)
if (q[key] && typeof q[key] === "string")
object[key] = toBoolean(<string>q[key]);
return object;
return object;
}
export function convertPaginationArgsIds(q: ParsedUrlQuery) {
if (typeof q.min_id === "string")
q.min_id = convertId(q.min_id, IdType.IceshrimpId);
if (typeof q.max_id === "string")
q.max_id = convertId(q.max_id, IdType.IceshrimpId);
if (typeof q.since_id === "string")
q.since_id = convertId(q.since_id, IdType.IceshrimpId);
return q;
if (typeof q.min_id === "string")
q.min_id = convertId(q.min_id, IdType.IceshrimpId);
if (typeof q.max_id === "string")
q.max_id = convertId(q.max_id, IdType.IceshrimpId);
if (typeof q.since_id === "string")
q.since_id = convertId(q.since_id, IdType.IceshrimpId);
return q;
}
export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []): any {
const dict: any = {};
const dict: any = {};
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];
}
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;
return dict;
}
export function setupEndpointsTimeline(router: Router): void {
router.get("/v1/timelines/public", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
router.get("/v1/timelines/public", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
if (!user) {
ctx.status = 401;
return;
}
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getPublicTimeline(user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote)
.then(n => NoteConverter.encodeMany(n, user, cache));
if (!user) {
ctx.status = 401;
return;
}
ctx.body = tl.map(s => convertStatus(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { hashtag: string } }>(
"/v1/timelines/tag/:hashtag",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getTagTimeline(
ctx.params.hashtag,
convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/timelines/home", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getPublicTimeline(user, args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote)
.then(n => NoteConverter.encodeMany(n, user, cache));
if (!user) {
ctx.status = 401;
return;
}
ctx.body = tl.map(s => convertStatus(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { hashtag: string } }>(
"/v1/timelines/tag/:hashtag",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getTagTimeline(
ctx.params.hashtag,
convertPaginationArgsIds(argsToBools(limitToInt(ctx.query))),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/timelines/home", async (ctx, reply) => {
try {
const auth = await authenticate(ctx.headers.authorization, null);
const user = auth[0] ?? undefined;
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getHomeTimeline(user, args.max_id, args.since_id, args.min_id, args.limit)
.then(n => NoteConverter.encodeMany(n, user, cache));
if (!user) {
ctx.status = 401;
return;
}
ctx.body = tl.map(s => convertStatus(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { listId: string } }>(
"/v1/timelines/list/:listId",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getListTimeline(
convertId(ctx.params.listId, IdType.IceshrimpId),
convertPaginationArgsIds(limitToInt(ctx.query)),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/conversations", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getConversationTimeline(
convertPaginationArgsIds(limitToInt(ctx.query)),
);
ctx.body = data.data.map((conversation) =>
convertConversation(conversation),
);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query)));
const cache = UserHelpers.getFreshAccountCache();
const tl = await TimelineHelpers.getHomeTimeline(user, args.max_id, args.since_id, args.min_id, args.limit)
.then(n => NoteConverter.encodeMany(n, user, cache));
ctx.body = tl.map(s => convertStatus(s));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { listId: string } }>(
"/v1/timelines/list/:listId",
async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getListTimeline(
convertId(ctx.params.listId, IdType.IceshrimpId),
convertPaginationArgsIds(limitToInt(ctx.query)),
);
ctx.body = data.data.map((status) => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/conversations", async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getConversationTimeline(
convertPaginationArgsIds(limitToInt(ctx.query)),
);
ctx.body = data.data.map((conversation) =>
convertConversation(conversation),
);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
}

View file

@ -2,31 +2,31 @@
/// <reference path="source.ts" />
/// <reference path="field.ts" />
namespace MastodonEntity {
export type Account = {
id: string;
username: string;
acct: string;
display_name: string;
locked: boolean;
created_at: string;
followers_count: number;
following_count: number;
statuses_count: number;
note: string;
url: string;
avatar: string;
avatar_static: string;
header: string;
header_static: string;
emojis: Array<Emoji>;
moved: Account | null;
fields: Array<Field>;
bot: boolean | null;
discoverable: boolean;
source?: Source;
};
export type Account = {
id: string;
username: string;
acct: string;
display_name: string;
locked: boolean;
created_at: string;
followers_count: number;
following_count: number;
statuses_count: number;
note: string;
url: string;
avatar: string;
avatar_static: string;
header: string;
header_static: string;
emojis: Array<Emoji>;
moved: Account | null;
fields: Array<Field>;
bot: boolean | null;
discoverable: boolean;
source?: Source;
};
export type MutedAccount = Account | {
mute_expires_at: string | null;
}
export type MutedAccount = Account | {
mute_expires_at: string | null;
}
}

View file

@ -1,8 +1,8 @@
namespace MastodonEntity {
export type Activity = {
week: string;
statuses: string;
logins: string;
registrations: string;
};
export type Activity = {
week: string;
statuses: string;
logins: string;
registrations: string;
};
}

View file

@ -3,32 +3,32 @@
/// <reference path="reaction.ts" />
namespace MastodonEntity {
export type Announcement = {
id: string;
content: string;
starts_at: string | null;
ends_at: string | null;
published: boolean;
all_day: boolean;
published_at: string;
updated_at: string;
read?: boolean;
mentions: Array<AnnouncementAccount>;
statuses: Array<AnnouncementStatus>;
tags: Array<Tag>;
emojis: Array<Emoji>;
reactions: Array<Reaction>;
};
export type Announcement = {
id: string;
content: string;
starts_at: string | null;
ends_at: string | null;
published: boolean;
all_day: boolean;
published_at: string;
updated_at: string;
read?: boolean;
mentions: Array<AnnouncementAccount>;
statuses: Array<AnnouncementStatus>;
tags: Array<Tag>;
emojis: Array<Emoji>;
reactions: Array<Reaction>;
};
export type AnnouncementAccount = {
id: string;
username: string;
url: string;
acct: string;
};
export type AnnouncementAccount = {
id: string;
username: string;
url: string;
acct: string;
};
export type AnnouncementStatus = {
id: string;
url: string;
};
export type AnnouncementStatus = {
id: string;
url: string;
};
}

View file

@ -1,7 +1,7 @@
namespace MastodonEntity {
export type Application = {
name: string;
website?: string | null;
vapid_key?: string | null;
};
export type Application = {
name: string;
website?: string | null;
vapid_key?: string | null;
};
}

View file

@ -1,14 +1,14 @@
/// <reference path="attachment.ts" />
namespace MastodonEntity {
export type AsyncAttachment = {
id: string;
type: "unknown" | "image" | "gifv" | "video" | "audio";
url: string | null;
remote_url: string | null;
preview_url: string;
text_url: string | null;
meta: Meta | null;
description: string | null;
blurhash: string | null;
};
export type AsyncAttachment = {
id: string;
type: "unknown" | "image" | "gifv" | "video" | "audio";
url: string | null;
remote_url: string | null;
preview_url: string;
text_url: string | null;
meta: Meta | null;
description: string | null;
blurhash: string | null;
};
}

View file

@ -1,49 +1,49 @@
namespace MastodonEntity {
export type Sub = {
// For Image, Gifv, and Video
width?: number;
height?: number;
size?: string;
aspect?: number;
export type Sub = {
// For Image, Gifv, and Video
width?: number;
height?: number;
size?: string;
aspect?: number;
// For Gifv and Video
frame_rate?: string;
// For Gifv and Video
frame_rate?: string;
// For Audio, Gifv, and Video
duration?: number;
bitrate?: number;
};
// For Audio, Gifv, and Video
duration?: number;
bitrate?: number;
};
export type Focus = {
x: number;
y: number;
};
export type Focus = {
x: number;
y: number;
};
export type Meta = {
original?: Sub;
small?: Sub;
focus?: Focus;
length?: string;
duration?: number;
fps?: number;
size?: string;
width?: number;
height?: number;
aspect?: number;
audio_encode?: string;
audio_bitrate?: string;
audio_channel?: string;
};
export type Meta = {
original?: Sub;
small?: Sub;
focus?: Focus;
length?: string;
duration?: number;
fps?: number;
size?: string;
width?: number;
height?: number;
aspect?: number;
audio_encode?: string;
audio_bitrate?: string;
audio_channel?: string;
};
export type Attachment = {
id: string;
type: "unknown" | "image" | "gifv" | "video" | "audio";
url: string;
remote_url: string | null;
preview_url: string | null;
text_url: string | null;
meta: Meta | null;
description: string | null;
blurhash: string | null;
};
export type Attachment = {
id: string;
type: "unknown" | "image" | "gifv" | "video" | "audio";
url: string;
remote_url: string | null;
preview_url: string | null;
text_url: string | null;
meta: Meta | null;
description: string | null;
blurhash: string | null;
};
}

View file

@ -1,16 +1,16 @@
namespace MastodonEntity {
export type Card = {
url: string;
title: string;
description: string;
type: "link" | "photo" | "video" | "rich";
image?: string;
author_name?: string;
author_url?: string;
provider_name?: string;
provider_url?: string;
html?: string;
width?: number;
height?: number;
};
export type Card = {
url: string;
title: string;
description: string;
type: "link" | "photo" | "video" | "rich";
image?: string;
author_name?: string;
author_url?: string;
provider_name?: string;
provider_url?: string;
html?: string;
width?: number;
height?: number;
};
}

View file

@ -1,8 +1,8 @@
/// <reference path="status.ts" />
namespace MastodonEntity {
export type Context = {
ancestors: Array<Status>;
descendants: Array<Status>;
};
export type Context = {
ancestors: Array<Status>;
descendants: Array<Status>;
};
}

View file

@ -2,10 +2,10 @@
/// <reference path="status.ts" />
namespace MastodonEntity {
export type Conversation = {
id: string;
accounts: Array<Account>;
last_status: Status | null;
unread: boolean;
};
export type Conversation = {
id: string;
accounts: Array<Account>;
last_status: Status | null;
unread: boolean;
};
}

View file

@ -1,9 +1,9 @@
namespace MastodonEntity {
export type Emoji = {
shortcode: string;
static_url: string;
url: string;
visible_in_picker: boolean;
category: string;
};
export type Emoji = {
shortcode: string;
static_url: string;
url: string;
visible_in_picker: boolean;
category: string;
};
}

View file

@ -1,8 +1,8 @@
namespace MastodonEntity {
export type FeaturedTag = {
id: string;
name: string;
statuses_count: number;
last_status_at: string;
};
export type FeaturedTag = {
id: string;
name: string;
statuses_count: number;
last_status_at: string;
};
}

View file

@ -1,7 +1,7 @@
namespace MastodonEntity {
export type Field = {
name: string;
value: string;
verified_at: string | null;
};
export type Field = {
name: string;
value: string;
verified_at: string | null;
};
}

View file

@ -1,12 +1,12 @@
namespace MastodonEntity {
export type Filter = {
id: string;
phrase: string;
context: Array<FilterContext>;
expires_at: string | null;
irreversible: boolean;
whole_word: boolean;
};
export type Filter = {
id: string;
phrase: string;
context: Array<FilterContext>;
expires_at: string | null;
irreversible: boolean;
whole_word: boolean;
};
export type FilterContext = string;
export type FilterContext = string;
}

View file

@ -1,7 +1,7 @@
namespace MastodonEntity {
export type History = {
day: string;
uses: number;
accounts: number;
};
export type History = {
day: string;
uses: number;
accounts: number;
};
}

View file

@ -1,9 +1,9 @@
namespace MastodonEntity {
export type IdentityProof = {
provider: string;
provider_username: string;
updated_at: string;
proof_url: string;
profile_url: string;
};
export type IdentityProof = {
provider: string;
provider_username: string;
updated_at: string;
proof_url: string;
profile_url: string;
};
}

View file

@ -3,39 +3,39 @@
/// <reference path="stats.ts" />
namespace MastodonEntity {
export type Instance = {
uri: string;
title: string;
description: string;
email: string;
version: string;
thumbnail: string | null;
urls: URLs;
stats: Stats;
languages: Array<string>;
contact_account: Account | null;
max_toot_chars?: number;
registrations?: boolean;
configuration?: {
statuses: {
max_characters: number;
max_media_attachments: number;
characters_reserved_per_url: number;
};
media_attachments: {
supported_mime_types: Array<string>;
image_size_limit: number;
image_matrix_limit: number;
video_size_limit: number;
video_frame_limit: number;
video_matrix_limit: number;
};
polls: {
max_options: number;
max_characters_per_option: number;
min_expiration: number;
max_expiration: number;
};
};
};
export type Instance = {
uri: string;
title: string;
description: string;
email: string;
version: string;
thumbnail: string | null;
urls: URLs;
stats: Stats;
languages: Array<string>;
contact_account: Account | null;
max_toot_chars?: number;
registrations?: boolean;
configuration?: {
statuses: {
max_characters: number;
max_media_attachments: number;
characters_reserved_per_url: number;
};
media_attachments: {
supported_mime_types: Array<string>;
image_size_limit: number;
image_matrix_limit: number;
video_size_limit: number;
video_frame_limit: number;
video_matrix_limit: number;
};
polls: {
max_options: number;
max_characters_per_option: number;
min_expiration: number;
max_expiration: number;
};
};
};
}

View file

@ -1,6 +1,6 @@
namespace MastodonEntity {
export type List = {
id: string;
title: string;
};
export type List = {
id: string;
title: string;
};
}

View file

@ -1,15 +1,15 @@
namespace MastodonEntity {
export type Marker = {
home?: {
last_read_id: string;
version: number;
updated_at: string;
};
notifications?: {
last_read_id: string;
version: number;
updated_at: string;
unread_count?: number;
};
};
export type Marker = {
home?: {
last_read_id: string;
version: number;
updated_at: string;
};
notifications?: {
last_read_id: string;
version: number;
updated_at: string;
unread_count?: number;
};
};
}

View file

@ -1,8 +1,8 @@
namespace MastodonEntity {
export type Mention = {
id: string;
username: string;
url: string;
acct: string;
};
export type Mention = {
id: string;
username: string;
url: string;
acct: string;
};
}

View file

@ -2,14 +2,22 @@
/// <reference path="status.ts" />
namespace MastodonEntity {
export type Notification = {
account: Account;
created_at: string;
id: string;
status?: Status;
reaction?: Reaction;
type: NotificationType;
};
export type Notification = {
account: Account;
created_at: string;
id: string;
status?: Status;
reaction?: Reaction;
type: NotificationType;
};
export type NotificationType = 'follow' | 'favourite' | 'reblog' | 'mention' | 'reaction' | 'follow_request' | 'status' | 'poll';
export type NotificationType =
'follow'
| 'favourite'
| 'reblog'
| 'mention'
| 'reaction'
| 'follow_request'
| 'status'
| 'poll';
}

View file

@ -24,6 +24,7 @@ namespace OAuth {
export class AppData {
public url: string | null;
public session_token: string | null;
constructor(
public id: string,
public name: string,
@ -54,9 +55,11 @@ namespace OAuth {
get redirectUri() {
return this.redirect_uri;
}
get clientId() {
return this.client_id;
}
get clientSecret() {
return this.client_secret;
}
@ -64,6 +67,7 @@ namespace OAuth {
export class TokenData {
public _scope: string;
constructor(
public access_token: string,
public token_type: string,
@ -96,21 +100,26 @@ namespace OAuth {
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
*/

View file

@ -1,14 +1,14 @@
/// <reference path="poll_option.ts" />
namespace MastodonEntity {
export type Poll = {
id: string;
expires_at: string | null;
expired: boolean;
multiple: boolean;
votes_count: number;
options: Array<PollOption>;
voted: boolean;
own_votes: Array<number>;
};
export type Poll = {
id: string;
expires_at: string | null;
expired: boolean;
multiple: boolean;
votes_count: number;
options: Array<PollOption>;
voted: boolean;
own_votes: Array<number>;
};
}

View file

@ -1,6 +1,6 @@
namespace MastodonEntity {
export type PollOption = {
title: string;
votes_count: number | null;
};
export type PollOption = {
title: string;
votes_count: number | null;
};
}

View file

@ -1,9 +1,9 @@
namespace MastodonEntity {
export type Preferences = {
"posting:default:visibility": "public" | "unlisted" | "private" | "direct";
"posting:default:sensitive": boolean;
"posting:default:language": string | null;
"reading:expand:media": "default" | "show_all" | "hide_all";
"reading:expand:spoilers": boolean;
};
export type Preferences = {
"posting:default:visibility": "public" | "unlisted" | "private" | "direct";
"posting:default:sensitive": boolean;
"posting:default:language": string | null;
"reading:expand:media": "default" | "show_all" | "hide_all";
"reading:expand:spoilers": boolean;
};
}

View file

@ -1,16 +1,16 @@
namespace MastodonEntity {
export type Alerts = {
follow: boolean;
favourite: boolean;
mention: boolean;
reblog: boolean;
poll: boolean;
};
export type Alerts = {
follow: boolean;
favourite: boolean;
mention: boolean;
reblog: boolean;
poll: boolean;
};
export type PushSubscription = {
id: string;
endpoint: string;
server_key: string;
alerts: Alerts;
};
export type PushSubscription = {
id: string;
endpoint: string;
server_key: string;
alerts: Alerts;
};
}

View file

@ -1,12 +1,12 @@
/// <reference path="account.ts" />
namespace MastodonEntity {
export type Reaction = {
count: number;
me: boolean;
name: string;
url?: string;
static_url?: string;
accounts?: Array<Account>;
};
export type Reaction = {
count: number;
me: boolean;
name: string;
url?: string;
static_url?: string;
accounts?: Array<Account>;
};
}

View file

@ -1,17 +1,17 @@
namespace MastodonEntity {
export type Relationship = {
id: string;
following: boolean;
followed_by: boolean;
blocking: boolean;
blocked_by: boolean;
muting: boolean;
muting_notifications: boolean;
requested: boolean;
domain_blocking: boolean;
showing_reblogs: boolean;
endorsed: boolean;
notifying: boolean;
note: string;
};
export type Relationship = {
id: string;
following: boolean;
followed_by: boolean;
blocking: boolean;
blocked_by: boolean;
muting: boolean;
muting_notifications: boolean;
requested: boolean;
domain_blocking: boolean;
showing_reblogs: boolean;
endorsed: boolean;
notifying: boolean;
note: string;
};
}

View file

@ -1,9 +1,9 @@
namespace MastodonEntity {
export type Report = {
id: string;
action_taken: string;
comment: string;
account_id: string;
status_ids: Array<string>;
};
export type Report = {
id: string;
action_taken: string;
comment: string;
account_id: string;
status_ids: Array<string>;
};
}

View file

@ -3,9 +3,9 @@
/// <reference path="tag.ts" />
namespace MastodonEntity {
export type Search = {
accounts: Array<Account>;
statuses: Array<Status>;
hashtags: Array<Tag>;
};
export type Search = {
accounts: Array<Account>;
statuses: Array<Status>;
hashtags: Array<Tag>;
};
}

View file

@ -1,10 +1,10 @@
/// <reference path="attachment.ts" />
/// <reference path="status_params.ts" />
namespace MastodonEntity {
export type ScheduledStatus = {
id: string;
scheduled_at: string;
params: StatusParams;
media_attachments: Array<Attachment>;
};
export type ScheduledStatus = {
id: string;
scheduled_at: string;
params: StatusParams;
media_attachments: Array<Attachment>;
};
}

View file

@ -1,10 +1,10 @@
/// <reference path="field.ts" />
namespace MastodonEntity {
export type Source = {
privacy: string | null;
sensitive: boolean | null;
language: string | null;
note: string;
fields: Array<Field>;
};
export type Source = {
privacy: string | null;
sensitive: boolean | null;
language: string | null;
note: string;
fields: Array<Field>;
};
}

View file

@ -1,7 +1,7 @@
namespace MastodonEntity {
export type Stats = {
user_count: number;
status_count: number;
domain_count: number;
};
export type Stats = {
user_count: number;
status_count: number;
domain_count: number;
};
}

View file

@ -9,67 +9,67 @@
/// <reference path="reaction.ts" />
namespace MastodonEntity {
export type Status = {
id: string;
uri: string;
url: string;
account: Account;
in_reply_to_id: string | null;
in_reply_to_account_id: string | null;
reblog: Status | null;
content: string | undefined;
text: string | null | undefined;
created_at: string;
emojis: Emoji[];
replies_count: number;
reblogs_count: number;
favourites_count: number;
reblogged: boolean | null;
favourited: boolean | null;
muted: boolean | null;
sensitive: boolean;
spoiler_text: string;
visibility: "public" | "unlisted" | "private" | "direct";
media_attachments: Array<Attachment>;
mentions: Array<Mention>;
tags: Array<Tag>;
card: Card | null;
poll: Poll | null;
application: Application | null;
language: string | null;
pinned: boolean | undefined;
reactions: Array<Reaction>;
quote: Status | null;
bookmarked: boolean;
edited_at: string | null;
};
export type Status = {
id: string;
uri: string;
url: string;
account: Account;
in_reply_to_id: string | null;
in_reply_to_account_id: string | null;
reblog: Status | null;
content: string | undefined;
text: string | null | undefined;
created_at: string;
emojis: Emoji[];
replies_count: number;
reblogs_count: number;
favourites_count: number;
reblogged: boolean | null;
favourited: boolean | null;
muted: boolean | null;
sensitive: boolean;
spoiler_text: string;
visibility: "public" | "unlisted" | "private" | "direct";
media_attachments: Array<Attachment>;
mentions: Array<Mention>;
tags: Array<Tag>;
card: Card | null;
poll: Poll | null;
application: Application | null;
language: string | null;
pinned: boolean | undefined;
reactions: Array<Reaction>;
quote: Status | null;
bookmarked: boolean;
edited_at: string | null;
};
export type StatusCreationRequest = {
text?: string,
media_ids?: string[],
poll?: {
options: string[],
expires_in: number,
multiple: boolean
},
in_reply_to_id?: string,
sensitive?: boolean,
spoiler_text?: string,
visibility?: string,
language?: string,
scheduled_at?: Date
}
export type StatusCreationRequest = {
text?: string,
media_ids?: string[],
poll?: {
options: string[],
expires_in: number,
multiple: boolean
},
in_reply_to_id?: string,
sensitive?: boolean,
spoiler_text?: string,
visibility?: string,
language?: string,
scheduled_at?: Date
}
export type StatusEditRequest = {
text?: string,
media_ids?: string[],
poll?: {
options: string[],
expires_in: number,
multiple: boolean
},
sensitive?: boolean,
spoiler_text?: string,
language?: string
}
export type StatusEditRequest = {
text?: string,
media_ids?: string[],
poll?: {
options: string[],
expires_in: number,
multiple: boolean
},
sensitive?: boolean,
spoiler_text?: string,
language?: string
}
}

View file

@ -9,14 +9,14 @@
/// <reference path="reaction.ts" />
namespace MastodonEntity {
export type StatusEdit = {
account: Account;
content: string;
created_at: string;
emojis: Emoji[];
sensitive: boolean;
spoiler_text: string;
media_attachments: Array<Attachment>;
poll: Poll | null;
};
export type StatusEdit = {
account: Account;
content: string;
created_at: string;
emojis: Emoji[];
sensitive: boolean;
spoiler_text: string;
media_attachments: Array<Attachment>;
poll: Poll | null;
};
}

View file

@ -1,12 +1,12 @@
namespace MastodonEntity {
export type StatusParams = {
text: string;
in_reply_to_id: string | null;
media_ids: Array<string> | null;
sensitive: boolean | null;
spoiler_text: string | null;
visibility: "public" | "unlisted" | "private" | "direct";
scheduled_at: string | null;
application_id: string;
};
export type StatusParams = {
text: string;
in_reply_to_id: string | null;
media_ids: Array<string> | null;
sensitive: boolean | null;
spoiler_text: string | null;
visibility: "public" | "unlisted" | "private" | "direct";
scheduled_at: string | null;
application_id: string;
};
}

View file

@ -1,7 +1,7 @@
namespace MastodonEntity {
export type StatusSource = {
id: string;
text: string;
spoiler_text: string;
};
export type StatusSource = {
id: string;
text: string;
spoiler_text: string;
};
}

View file

@ -1,10 +1,10 @@
/// <reference path="history.ts" />
namespace MastodonEntity {
export type Tag = {
name: string;
url: string;
history: Array<History> | null;
following?: boolean;
};
export type Tag = {
name: string;
url: string;
history: Array<History> | null;
following?: boolean;
};
}

View file

@ -1,8 +1,8 @@
namespace MastodonEntity {
export type Token = {
access_token: string;
token_type: string;
scope: string;
created_at: number;
};
export type Token = {
access_token: string;
token_type: string;
scope: string;
created_at: number;
};
}

View file

@ -1,5 +1,5 @@
namespace MastodonEntity {
export type URLs = {
streaming_api: string;
};
export type URLs = {
streaming_api: string;
};
}

View file

@ -4,47 +4,47 @@ import { LinkPaginationObject } from "@/server/api/mastodon/helpers/user.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
export class ListHelpers {
public static async getLists(user: ILocalUser): Promise<MastodonEntity.List[]> {
return UserLists.findBy({userId: user.id}).then(p => p.map(list => {
return {
id: list.id,
title: list.name
}
}));
}
public static async getLists(user: ILocalUser): Promise<MastodonEntity.List[]> {
return UserLists.findBy({userId: user.id}).then(p => p.map(list => {
return {
id: list.id,
title: list.name
}
}));
}
public static async getList(user: ILocalUser, id: string): Promise<MastodonEntity.List> {
return UserLists.findOneByOrFail({userId: user.id, id: id}).then(list => {
return {
id: list.id,
title: list.name
}
});
}
public static async getList(user: ILocalUser, id: string): Promise<MastodonEntity.List> {
return UserLists.findOneByOrFail({userId: user.id, id: id}).then(list => {
return {
id: list.id,
title: list.name
}
});
}
public static async getListUsers(user: ILocalUser, id: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const list = await UserLists.findOneByOrFail({userId: user.id, id: id});
const query = PaginationHelpers.makePaginationQuery(
UserListJoinings.createQueryBuilder('member'),
sinceId,
maxId,
minId
)
.andWhere("member.userListId = :listId", {listId: id})
.innerJoinAndSelect("member.user", "user");
public static async getListUsers(user: ILocalUser, id: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const list = await UserLists.findOneByOrFail({userId: user.id, id: id});
const query = PaginationHelpers.makePaginationQuery(
UserListJoinings.createQueryBuilder('member'),
sinceId,
maxId,
minId
)
.andWhere("member.userListId = :listId", {listId: id})
.innerJoinAndSelect("member.user", "user");
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.user)
.filter(p => p) as User[];
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.user)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
}

View file

@ -6,32 +6,32 @@ import { Packed } from "@/misc/schema.js";
import { DriveFile } from "@/models/entities/drive-file.js";
export class MediaHelpers {
public static async uploadMedia(user: ILocalUser, file: multer.File, body: any): Promise<Packed<"DriveFile">> {
return await addFile({
user: user,
path: file.path,
name: file.originalname !== null && file.originalname !== 'file' ? file.originalname : undefined,
comment: body?.description ?? undefined,
sensitive: false, //FIXME: this needs to be updated on from composing a post with the media attached
})
.then(p => DriveFiles.pack(p));
}
public static async uploadMedia(user: ILocalUser, file: multer.File, body: any): Promise<Packed<"DriveFile">> {
return await addFile({
user: user,
path: file.path,
name: file.originalname !== null && file.originalname !== 'file' ? file.originalname : undefined,
comment: body?.description ?? undefined,
sensitive: false, //FIXME: this needs to be updated on from composing a post with the media attached
})
.then(p => DriveFiles.pack(p));
}
public static async updateMedia(user: ILocalUser, file: DriveFile, body: any): Promise<Packed<"DriveFile">> {
await DriveFiles.update(file.id, {
comment: body?.description ?? undefined
});
public static async updateMedia(user: ILocalUser, file: DriveFile, body: any): Promise<Packed<"DriveFile">> {
await DriveFiles.update(file.id, {
comment: body?.description ?? undefined
});
return DriveFiles.findOneByOrFail({id: file.id, userId: user.id})
.then(p => DriveFiles.pack(p));
}
return DriveFiles.findOneByOrFail({id: file.id, userId: user.id})
.then(p => DriveFiles.pack(p));
}
public static async getMediaPacked(user: ILocalUser, id: string): Promise<Packed<"DriveFile"> | null> {
return this.getMedia(user, id)
.then(p => p ? DriveFiles.pack(p) : null);
}
public static async getMediaPacked(user: ILocalUser, id: string): Promise<Packed<"DriveFile"> | null> {
return this.getMedia(user, id)
.then(p => p ? DriveFiles.pack(p) : null);
}
public static async getMedia(user: ILocalUser, id: string): Promise<DriveFile | null> {
return DriveFiles.findOneBy({id: id, userId: user.id});
}
public static async getMedia(user: ILocalUser, id: string): Promise<DriveFile | null> {
return DriveFiles.findOneBy({id: id, userId: user.id});
}
}

View file

@ -1,12 +1,5 @@
import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js";
import {
DriveFiles,
Metas, NoteEdits,
NoteFavorites,
NoteReactions,
Notes,
UserNotePinings
} from "@/models/index.js";
import { DriveFiles, Metas, NoteEdits, NoteFavorites, NoteReactions, Notes, UserNotePinings } from "@/models/index.js";
import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js";
import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js";
import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js";
@ -32,358 +25,358 @@ import { FileConverter } from "@/server/api/mastodon/converters/file.js";
import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js";
export class NoteHelpers {
public static async getDefaultReaction(): Promise<string> {
return Metas.createQueryBuilder()
.select('"defaultReaction"')
.execute()
.then(p => p[0].defaultReaction);
}
public static async getDefaultReaction(): Promise<string> {
return Metas.createQueryBuilder()
.select('"defaultReaction"')
.execute()
.then(p => p[0].defaultReaction);
}
public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise<Note> {
await createReaction(user, note, reaction);
return getNote(note.id, user);
}
public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise<Note> {
await createReaction(user, note, reaction);
return getNote(note.id, user);
}
public static async removeReactFromNote(note: Note, user: ILocalUser): Promise<Note> {
await deleteReaction(user, note);
return getNote(note.id, user);
}
public static async removeReactFromNote(note: Note, user: ILocalUser): Promise<Note> {
await deleteReaction(user, note);
return getNote(note.id, user);
}
public static async reblogNote(note: Note, user: ILocalUser): Promise<Note> {
const data = {
createdAt: new Date(),
files: [],
renote: note
};
return await createNote(user, data);
}
public static async reblogNote(note: Note, user: ILocalUser): Promise<Note> {
const data = {
createdAt: new Date(),
files: [],
renote: note
};
return await createNote(user, data);
}
public static async unreblogNote(note: Note, user: ILocalUser): Promise<Note> {
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));
}
public static async unreblogNote(note: Note, user: ILocalUser): Promise<Note> {
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));
}
public static async bookmarkNote(note: Note, user: ILocalUser): Promise<Note> {
const bookmarked = await NoteFavorites.exist({
where: {
noteId: note.id,
userId: user.id,
},
});
public static async bookmarkNote(note: Note, user: ILocalUser): Promise<Note> {
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,
});
}
if (!bookmarked) {
await NoteFavorites.insert({
id: genId(),
createdAt: new Date(),
noteId: note.id,
userId: user.id,
});
}
return note;
}
return note;
}
public static async unbookmarkNote(note: Note, user: ILocalUser): Promise<Note> {
return NoteFavorites.findOneBy({
noteId: note.id,
userId: user.id,
})
.then(p => p !== null ? NoteFavorites.delete(p.id) : null)
.then(_ => note);
}
public static async unbookmarkNote(note: Note, user: ILocalUser): Promise<Note> {
return NoteFavorites.findOneBy({
noteId: note.id,
userId: user.id,
})
.then(p => p !== null ? NoteFavorites.delete(p.id) : null)
.then(_ => note);
}
public static async pinNote(note: Note, user: ILocalUser): Promise<Note> {
const pinned = await UserNotePinings.exist({
where: {
userId: user.id,
noteId: note.id
}
});
public static async pinNote(note: Note, user: ILocalUser): Promise<Note> {
const pinned = await UserNotePinings.exist({
where: {
userId: user.id,
noteId: note.id
}
});
if (!pinned) {
await addPinned(user, note.id);
}
if (!pinned) {
await addPinned(user, note.id);
}
return note;
}
return note;
}
public static async unpinNote(note: Note, user: ILocalUser): Promise<Note> {
const pinned = await UserNotePinings.exist({
where: {
userId: user.id,
noteId: note.id
}
});
public static async unpinNote(note: Note, user: ILocalUser): Promise<Note> {
const pinned = await UserNotePinings.exist({
where: {
userId: user.id,
noteId: note.id
}
});
if (pinned) {
await removePinned(user, note.id);
}
if (pinned) {
await removePinned(user, note.id);
}
return note;
}
return note;
}
public static async deleteNote(note: Note, user: ILocalUser): Promise<MastodonEntity.Status> {
if (user.id !== note.userId) throw new Error("Can't delete someone elses note");
const status = await NoteConverter.encode(note, user);
await deleteNote(user, note);
status.content = undefined;
return status;
}
public static async deleteNote(note: Note, user: ILocalUser): Promise<MastodonEntity.Status> {
if (user.id !== note.userId) throw new Error("Can't delete someone elses note");
const status = await NoteConverter.encode(note, user);
await deleteNote(user, note);
status.content = undefined;
return status;
}
public static async getNoteFavoritedBy(note: Note, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
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");
public static async getNoteFavoritedBy(note: Note, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
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 query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.user)
.filter(p => p) as User[];
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.user)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getNoteEditHistory(note: Note): Promise<MastodonEntity.StatusEdit[]> {
if (!note.updatedAt) return [];
const cache = UserHelpers.getFreshAccountCache();
const account = Promise.resolve(note.user ?? await UserHelpers.getUserCached(note.userId, cache))
.then(p => UserConverter.encode(p, cache));
const edits = await NoteEdits.find({where: {noteId: note.id}, order: {id: "ASC"}});
const history: Promise<MastodonEntity.StatusEdit>[] = [];
if (edits.length < 1) return [];
public static async getNoteEditHistory(note: Note): Promise<MastodonEntity.StatusEdit[]> {
if (!note.updatedAt) return [];
const cache = UserHelpers.getFreshAccountCache();
const account = Promise.resolve(note.user ?? await UserHelpers.getUserCached(note.userId, cache))
.then(p => UserConverter.encode(p, cache));
const edits = await NoteEdits.find({where: {noteId: note.id}, order: {id: "ASC"}});
const history: Promise<MastodonEntity.StatusEdit>[] = [];
if (edits.length < 1) return [];
const curr = {
id: note.id,
noteId: note.id,
note: note,
text: note.text,
cw: note.cw,
fileIds: note.fileIds,
updatedAt: note.updatedAt
}
const curr = {
id: note.id,
noteId: note.id,
note: note,
text: note.text,
cw: note.cw,
fileIds: note.fileIds,
updatedAt: note.updatedAt
}
edits.push(curr);
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)) ?? '',
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.unshift(awaitAll(item));
}
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)) ?? '',
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.unshift(awaitAll(item));
}
return Promise.all(history);
}
return Promise.all(history);
}
public static getNoteSource(note: Note): MastodonEntity.StatusSource {
return {
id: note.id,
text: note.text ?? '',
spoiler_text: note.cw ?? ''
}
}
public static getNoteSource(note: Note): MastodonEntity.StatusSource {
return {
id: note.id,
text: note.text ?? '',
spoiler_text: note.cw ?? ''
}
}
public static async getNoteRebloggedBy(note: Note, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
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
.innerJoinAndSelect("note.user", "user");
public static async getNoteRebloggedBy(note: Note, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
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
.innerJoinAndSelect("note.user", "user");
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.user)
.filter(p => p) as User[];
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.user)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getNoteDescendants(note: Note | string, user: ILocalUser | null, limit: number = 10, depth: number = 2): Promise<Note[]> {
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},
);
public static async getNoteDescendants(note: Note | string, user: ILocalUser | null, limit: number = 10, depth: number = 2): Promise<Note[]> {
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);
}
generateVisibilityQuery(query, user);
if (user) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
return query.getMany().then(p => p.reverse());
}
return query.getMany().then(p => p.reverse());
}
public static async getNoteAncestors(rootNote: Note, user: ILocalUser | null, limit: number = 10): Promise<Note[]> {
const notes = new Array<Note>;
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;
}
public static async getNoteAncestors(rootNote: Note, user: ILocalUser | null, limit: number = 10): Promise<Note[]> {
const notes = new Array<Note>;
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();
}
return notes.reverse();
}
public static async createNote(request: MastodonEntity.StatusCreationRequest, user: ILocalUser): Promise<Note> {
const files = request.media_ids && request.media_ids.length > 0
? DriveFiles.findByIds(request.media_ids)
: [];
public static async createNote(request: MastodonEntity.StatusCreationRequest, user: ILocalUser): Promise<Note> {
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 visibility = request.visibility ?? UserHelpers.getDefaultNoteVisibility(user);
const reply = request.in_reply_to_id ? await getNote(request.in_reply_to_id, user) : undefined;
const visibility = request.visibility ?? UserHelpers.getDefaultNoteVisibility(user);
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,
cw: request.spoiler_text,
visibility: visibility,
visibleUsers: Promise.resolve(visibility).then(p => p === 'specified' ? this.extractMentions(request.text ?? '', user) : undefined)
}
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,
cw: request.spoiler_text,
visibility: visibility,
visibleUsers: Promise.resolve(visibility).then(p => p === 'specified' ? this.extractMentions(request.text ?? '', user) : undefined)
}
return createNote(user, await awaitAll(data));
}
return createNote(user, await awaitAll(data));
}
public static async editNote(request: MastodonEntity.StatusEditRequest, note: Note, user: ILocalUser): Promise<Note> {
const files = request.media_ids && request.media_ids.length > 0
? DriveFiles.findByIds(request.media_ids)
: [];
public static async editNote(request: MastodonEntity.StatusEditRequest, note: Note, user: ILocalUser): Promise<Note> {
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,
}
: undefined,
text: request.text,
cw: request.spoiler_text
}
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,
}
: undefined,
text: request.text,
cw: request.spoiler_text
}
return editNote(user, note, await awaitAll(data));
}
return editNote(user, note, await awaitAll(data));
}
public static async extractMentions(text: string, user: ILocalUser): Promise<User[]> {
return extractMentionedUsers(user, mfm.parse(text)!);
}
public static async extractMentions(text: string, user: ILocalUser): Promise<User[]> {
return extractMentionedUsers(user, mfm.parse(text)!);
}
public static normalizeComposeOptions(body: any): MastodonEntity.StatusCreationRequest {
const result: MastodonEntity.StatusCreationRequest = {};
public static normalizeComposeOptions(body: any): MastodonEntity.StatusCreationRequest {
const result: MastodonEntity.StatusCreationRequest = {};
if (body.status !== null)
result.text = body.status;
if (body.spoiler_text !== null)
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 = convertId(body.in_reply_to_id, IdType.IceshrimpId);
if (body.media_ids)
result.media_ids = body.media_ids && body.media_ids.length > 0
? this.normalizeToArray(body.media_ids)
.map(p => convertId(p, IdType.IceshrimpId))
: undefined;
if (body.status !== null)
result.text = body.status;
if (body.spoiler_text !== null)
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 = convertId(body.in_reply_to_id, IdType.IceshrimpId);
if (body.media_ids)
result.media_ids = body.media_ids && body.media_ids.length > 0
? this.normalizeToArray(body.media_ids)
.map(p => convertId(p, IdType.IceshrimpId))
: undefined;
if (body.poll) {
result.poll = {
expires_in: parseInt(body.poll.expires_in, 10),
options: body.poll.options,
multiple: !!body.poll.multiple,
}
}
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;
result.sensitive = !!body.sensitive;
return result;
}
return result;
}
public static normalizeEditOptions(body: any): MastodonEntity.StatusEditRequest {
const result: MastodonEntity.StatusEditRequest = {};
public static normalizeEditOptions(body: any): MastodonEntity.StatusEditRequest {
const result: MastodonEntity.StatusEditRequest = {};
if (body.status !== null)
result.text = body.status;
if (body.spoiler_text !== null)
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
? this.normalizeToArray(body.media_ids)
.map(p => convertId(p, IdType.IceshrimpId))
: undefined;
if (body.status !== null)
result.text = body.status;
if (body.spoiler_text !== null)
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
? this.normalizeToArray(body.media_ids)
.map(p => convertId(p, IdType.IceshrimpId))
: undefined;
if (body.poll) {
result.poll = {
expires_in: parseInt(body.poll.expires_in, 10),
options: body.poll.options,
multiple: !!body.poll.multiple,
}
}
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;
result.sensitive = !!body.sensitive;
return result;
}
return result;
}
public static normalizeToArray<T>(subject: T | T[]) {
return Array.isArray(subject) ? subject : [subject];
}
public static normalizeToArray<T>(subject: T | T[]) {
return Array.isArray(subject) ? subject : [subject];
}
}

View file

@ -2,19 +2,20 @@ import { ILocalUser } from "@/models/entities/user.js";
import { Notifications } from "@/models/index.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { Notification } from "@/models/entities/notification.js";
export class NotificationHelpers {
public static async getNotifications(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 15, types: string[] | undefined, excludeTypes: string[] | undefined, accountId: string | undefined): Promise<Notification[]> {
if (limit > 30) limit = 30;
if (types && excludeTypes) throw new Error("types and exclude_types can not be used simultaneously");
if (types && excludeTypes) throw new Error("types and exclude_types can not be used simultaneously");
let requestedTypes = types
? this.decodeTypes(types)
: ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest'];
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));
}
if (excludeTypes) {
const excludedTypes = this.decodeTypes(excludeTypes);
requestedTypes = requestedTypes.filter(p => !excludedTypes.includes(p));
}
const query = PaginationHelpers.makePaginationQuery(
Notifications.createQueryBuilder("notification"),
@ -22,30 +23,30 @@ export class NotificationHelpers {
maxId,
minId
)
.andWhere("notification.notifieeId = :userId", { userId: user.id })
.andWhere("notification.type IN (:...types)", { types: requestedTypes });
.andWhere("notification.notifieeId = :userId", {userId: user.id})
.andWhere("notification.type IN (:...types)", {types: requestedTypes});
if (accountId !== undefined)
query.andWhere("notification.notifierId = :notifierId", { notifierId: accountId });
if (accountId !== undefined)
query.andWhere("notification.notifierId = :notifierId", {notifierId: accountId});
query.leftJoinAndSelect("notification.note", "note");
query.leftJoinAndSelect("notification.note", "note");
return PaginationHelpers.execQuery(query, limit, minId !== undefined);
}
public static async getNotification(id: string, user: ILocalUser): Promise<Notification | null> {
return Notifications.findOneBy({id: id, notifieeId: user.id});
}
public static async getNotification(id: string, user: ILocalUser): Promise<Notification | null> {
return Notifications.findOneBy({id: id, notifieeId: user.id});
}
public static async dismissNotification(id: string, user: ILocalUser): Promise<void> {
const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true});
}
public static async dismissNotification(id: string, user: ILocalUser): Promise<void> {
const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true});
}
public static async clearAllNotifications(user: ILocalUser): Promise<void> {
await Notifications.update({notifieeId: user.id}, {isRead: true});
}
public static async clearAllNotifications(user: ILocalUser): Promise<void> {
await Notifications.update({notifieeId: user.id}, {isRead: true});
}
private static decodeTypes(types: string[]) {
private static decodeTypes(types: string[]) {
const result: string[] = [];
if (types.includes('follow')) result.push('follow');
if (types.includes('mention')) result.push('mention', 'reply');
@ -53,6 +54,6 @@ export class NotificationHelpers {
if (types.includes('favourite')) result.push('reaction');
if (types.includes('poll')) result.push('pollEnded');
if (types.includes('follow_request')) result.push('receiveFollowRequest');
return result;
}
return result;
}
}

View file

@ -3,81 +3,82 @@ import config from "@/config/index.js";
import { convertId, IdType } from "../../index.js";
export class PaginationHelpers {
public static makePaginationQuery<T extends ObjectLiteral>(
q: SelectQueryBuilder<T>,
sinceId?: string,
maxId?: string,
minId?: string,
idField: string = "id"
) {
if (sinceId && minId) throw new Error("Can't user both sinceId and minId params");
public static makePaginationQuery<T extends ObjectLiteral>(
q: SelectQueryBuilder<T>,
sinceId?: string,
maxId?: string,
minId?: string,
idField: string = "id"
) {
if (sinceId && minId) throw new Error("Can't user both sinceId and minId params");
if (sinceId && maxId) {
q.andWhere(`${q.alias}.${idField} > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.${idField} < :maxId`, { maxId: maxId });
q.orderBy(`${q.alias}.${idField}`, "DESC");
} if (minId && maxId) {
q.andWhere(`${q.alias}.${idField} > :minId`, { minId: minId });
q.andWhere(`${q.alias}.${idField} < :maxId`, { maxId: maxId });
q.orderBy(`${q.alias}.${idField}`, "ASC");
} else if (sinceId) {
q.andWhere(`${q.alias}.${idField} > :sinceId`, { sinceId: sinceId });
q.orderBy(`${q.alias}.${idField}`, "DESC");
} else if (minId) {
q.andWhere(`${q.alias}.${idField} > :minId`, { minId: minId });
q.orderBy(`${q.alias}.${idField}`, "ASC");
} else if (maxId) {
q.andWhere(`${q.alias}.${idField} < :maxId`, { maxId: maxId });
q.orderBy(`${q.alias}.${idField}`, "DESC");
} else {
q.orderBy(`${q.alias}.${idField}`, "DESC");
}
return q;
}
if (sinceId && maxId) {
q.andWhere(`${q.alias}.${idField} > :sinceId`, {sinceId: sinceId});
q.andWhere(`${q.alias}.${idField} < :maxId`, {maxId: maxId});
q.orderBy(`${q.alias}.${idField}`, "DESC");
}
if (minId && maxId) {
q.andWhere(`${q.alias}.${idField} > :minId`, {minId: minId});
q.andWhere(`${q.alias}.${idField} < :maxId`, {maxId: maxId});
q.orderBy(`${q.alias}.${idField}`, "ASC");
} else if (sinceId) {
q.andWhere(`${q.alias}.${idField} > :sinceId`, {sinceId: sinceId});
q.orderBy(`${q.alias}.${idField}`, "DESC");
} else if (minId) {
q.andWhere(`${q.alias}.${idField} > :minId`, {minId: minId});
q.orderBy(`${q.alias}.${idField}`, "ASC");
} else if (maxId) {
q.andWhere(`${q.alias}.${idField} < :maxId`, {maxId: maxId});
q.orderBy(`${q.alias}.${idField}`, "DESC");
} else {
q.orderBy(`${q.alias}.${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.
*/
public static async execQuery<T extends ObjectLiteral>(query: SelectQueryBuilder<T>, limit: number, reverse: boolean): Promise<T[]> {
// We fetch more than requested because some may be filtered out, and if there's less than
// requested, the pagination stops.
const found = [];
const take = Math.floor(limit * 1.5);
let skip = 0;
try {
while (found.length < limit) {
const notes = await query.take(take).skip(skip).getMany();
found.push(...notes);
skip += take;
if (notes.length < take) break;
}
} catch (error) {
return [];
}
/**
*
* @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.
*/
public static async execQuery<T extends ObjectLiteral>(query: SelectQueryBuilder<T>, limit: number, reverse: boolean): Promise<T[]> {
// We fetch more than requested because some may be filtered out, and if there's less than
// requested, the pagination stops.
const found = [];
const take = Math.floor(limit * 1.5);
let skip = 0;
try {
while (found.length < limit) {
const notes = await query.take(take).skip(skip).getMany();
found.push(...notes);
skip += take;
if (notes.length < take) break;
}
} catch (error) {
return [];
}
if (found.length > limit) {
found.length = limit;
}
if (found.length > limit) {
found.length = limit;
}
return reverse ? found.reverse() : found;
}
return reverse ? found.reverse() : found;
}
public static appendLinkPaginationHeader(args: any, ctx: any, res: any): void {
const link: string[] = [];
const limit = args.limit ?? 40;
if (res.maxId) {
const l = `<${config.url}/api${ctx.path}?limit=${limit}&max_id=${convertId(res.maxId, IdType.MastodonId)}>; rel="next"`;
link.push(l);
}
if (res.minId) {
const l = `<${config.url}/api${ctx.path}?limit=${limit}&min_id=${convertId(res.minId, IdType.MastodonId)}>; rel="prev"`;
link.push(l);
}
if (link.length > 0){
ctx.response.append('Link', link.join(', '));
}
}
public static appendLinkPaginationHeader(args: any, ctx: any, res: any): void {
const link: string[] = [];
const limit = args.limit ?? 40;
if (res.maxId) {
const l = `<${config.url}/api${ctx.path}?limit=${limit}&max_id=${convertId(res.maxId, IdType.MastodonId)}>; rel="next"`;
link.push(l);
}
if (res.minId) {
const l = `<${config.url}/api${ctx.path}?limit=${limit}&min_id=${convertId(res.minId, IdType.MastodonId)}>; rel="prev"`;
link.push(l);
}
if (link.length > 0) {
ctx.response.append('Link', link.join(', '));
}
}
}

View file

@ -2,8 +2,6 @@ import { Note } from "@/models/entities/note.js";
import { populatePoll } from "@/models/repositories/note.js";
import { PollConverter } from "@/server/api/mastodon/converters/poll.js";
import { ILocalUser, IRemoteUser } from "@/models/entities/user.js";
import { getNote } from "@/server/api/common/getters.js";
import { ApiError } from "@/server/api/error.js";
import { Blockings, NoteWatchings, Polls, PollVotes, Users } from "@/models/index.js";
import { genId } from "@/misc/gen-id.js";
import { publishNoteStream } from "@/services/stream.js";
@ -11,7 +9,6 @@ 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 { meta } from "@/server/api/endpoints/notes/polls/vote.js";
import { Not } from "typeorm";
export class PollHelpers {
@ -34,7 +31,7 @@ export class PollHelpers {
if (block) throw new Error('You are blocked by the poll author');
}
const poll = await Polls.findOneByOrFail({ noteId: note.id });
const poll = await Polls.findOneByOrFail({noteId: note.id});
if (poll.expiresAt && poll.expiresAt < createdAt) throw new Error('Poll is expired');

View file

@ -22,389 +22,384 @@ import { resolveUser } from "@/remote/resolve-user.js";
import { createNote } from "@/remote/activitypub/models/note.js";
import { getUser } from "@/server/api/common/getters.js";
import config from "@/config/index.js";
import { Hashtag } from "@/models/entities/hashtag.js";
export class SearchHelpers {
public static async search(user: ILocalUser, q: string | undefined, type: string | undefined, resolve: boolean = false, following: boolean = false, accountId: string | undefined, excludeUnreviewed: boolean = false, maxId: string | undefined, minId: string | undefined, limit: number = 20, offset: number | undefined, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Search> {
if (q === undefined || q.trim().length === 0) throw new Error('Search query cannot be empty');
if (limit > 40) limit = 40;
const notes = type === 'statuses' || !type ? this.searchNotes(user, q, resolve, following, accountId, maxId, minId, limit, offset) : [];
const users = type === 'accounts' || !type ? this.searchUsers(user, q, resolve, following, maxId, minId, limit, offset) : [];
const tags = type === 'hashtags' || !type ? this.searchTags(q, excludeUnreviewed, limit, offset) : [];
public static async search(user: ILocalUser, q: string | undefined, type: string | undefined, resolve: boolean = false, following: boolean = false, accountId: string | undefined, excludeUnreviewed: boolean = false, maxId: string | undefined, minId: string | undefined, limit: number = 20, offset: number | undefined, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<MastodonEntity.Search> {
if (q === undefined || q.trim().length === 0) throw new Error('Search query cannot be empty');
if (limit > 40) limit = 40;
const notes = type === 'statuses' || !type ? this.searchNotes(user, q, resolve, following, accountId, maxId, minId, limit, offset) : [];
const users = type === 'accounts' || !type ? this.searchUsers(user, q, resolve, following, maxId, minId, limit, offset) : [];
const tags = type === 'hashtags' || !type ? this.searchTags(q, excludeUnreviewed, limit, offset) : [];
const result = {
statuses: Promise.resolve(notes).then(p => NoteConverter.encodeMany(p, user, cache)),
accounts: Promise.resolve(users).then(p => UserConverter.encodeMany(p, cache)),
hashtags: Promise.resolve(tags)
};
const result = {
statuses: Promise.resolve(notes).then(p => NoteConverter.encodeMany(p, user, cache)),
accounts: Promise.resolve(users).then(p => UserConverter.encodeMany(p, cache)),
hashtags: Promise.resolve(tags)
};
return awaitAll(result);
}
return awaitAll(result);
}
private static async searchUsers(user: ILocalUser, q: string, resolve: boolean, following: boolean, maxId: string | undefined, minId: string | undefined, limit: number, offset: number | undefined): Promise<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];
private static async searchUsers(user: ILocalUser, q: string, resolve: boolean, following: boolean, maxId: string | undefined, minId: string | undefined, limit: number, offset: number | undefined): Promise<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];
// 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];
const result = await resolveUser(match.groups!.user.toLowerCase(), match.groups?.host ?? null);
if (result) return [result];
// no matches found
return [];
}
}
}
catch (e: any) {
console.log(`[mastodon-client] resolve user '${q}' failed: ${e.message}`);
return [];
}
}
// no matches found
return [];
}
}
} catch (e: any) {
console.log(`[mastodon-client] resolve user '${q}' failed: ${e.message}`);
return [];
}
}
const query = PaginationHelpers.makePaginationQuery(
Users.createQueryBuilder("user"),
undefined,
minId,
maxId,
);
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});
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.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("user.usernameLower ILIKE :q", { q: `%${sqlLikeEscape(q)}%` });
})
);
query.andWhere(
new Brackets((qb) => {
qb.where("user.name ILIKE :q", {q: `%${sqlLikeEscape(q)}%`});
qb.orWhere("user.usernameLower ILIKE :q", {q: `%${sqlLikeEscape(q)}%`});
})
);
query.orderBy({'user.notesCount': 'DESC'});
query.orderBy({'user.notesCount': 'DESC'});
return query.skip(offset ?? 0).take(limit).getMany().then(p => minId ? p.reverse() : p);
}
return query.skip(offset ?? 0).take(limit).getMany().then(p => minId ? p.reverse() : p);
}
private static async searchNotes(user: ILocalUser, q: string, resolve: boolean, following: boolean, accountId: string | undefined, maxId: string | undefined, minId: string | undefined, limit: number, offset: number | undefined): Promise<Note[]> {
if (accountId && following) throw new Error("The 'following' and 'accountId' parameters cannot be used simultaneously");
private static async searchNotes(user: ILocalUser, q: string, resolve: boolean, following: boolean, accountId: string | undefined, maxId: string | undefined, minId: string | undefined, limit: number, offset: number | undefined): Promise<Note[]> {
if (accountId && following) throw new Error("The 'following' and 'accountId' parameters cannot be used simultaneously");
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];
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];
}
// 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: any) {
console.log(`[mastodon-client] resolve note '${q}' failed: ${e.message}`);
return [];
}
}
return isPost(object) ? createNote(getApId(object), resolver.reset(), true).then(p => p ? [p] : []) : [];
}
} catch (e: any) {
console.log(`[mastodon-client] resolve note '${q}' failed: ${e.message}`);
return [];
}
}
// Try sonic search first, unless we have advanced filters
if (sonic && !accountId && !following) {
let start = offset ?? 0;
const chunkSize = 100;
// Try sonic search first, unless we have advanced filters
if (sonic && !accountId && !following) {
let start = offset ?? 0;
const chunkSize = 100;
// Use sonic to fetch and step through all search results that could match the requirements
const ids = [];
while (true) {
const results = await sonic.search.query(
sonic.collection,
sonic.bucket,
q,
{
limit: chunkSize,
offset: start,
},
);
// Use sonic to fetch and step through all search results that could match the requirements
const ids = [];
while (true) {
const results = await sonic.search.query(
sonic.collection,
sonic.bucket,
q,
{
limit: chunkSize,
offset: start,
},
);
start += chunkSize;
start += chunkSize;
if (results.length === 0) {
break;
}
if (results.length === 0) {
break;
}
const res = results
.map((k) => JSON.parse(k))
.filter((key) => {
if (minId && key.id < minId) return false;
if (maxId && key.id > maxId) return false;
return true;
})
.map((key) => key.id);
const res = results
.map((k) => JSON.parse(k))
.filter((key) => {
if (minId && key.id < minId) return false;
if (maxId && key.id > maxId) return false;
return true;
})
.map((key) => key.id);
ids.push(...res);
}
ids.push(...res);
}
// Sort all the results by note id DESC (newest first)
ids.sort((a, b) => b - a);
// Sort all the results by note id DESC (newest first)
ids.sort((a, b) => b - a);
// Fetch the notes from the database until we have enough to satisfy the limit
start = 0;
const found = [];
while (found.length < limit && start < ids.length) {
const chunk = ids.slice(start, start + chunkSize);
// Fetch the notes from the database until we have enough to satisfy the limit
start = 0;
const found = [];
while (found.length < limit && start < ids.length) {
const chunk = ids.slice(start, start + chunkSize);
const query = Notes.createQueryBuilder("note")
.where({id: In(chunk)})
.orderBy({id: "DESC"})
const query = Notes.createQueryBuilder("note")
.where({id: In(chunk)})
.orderBy({id: "DESC"})
generateVisibilityQuery(query, user);
generateVisibilityQuery(query, user);
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
if (following) {
const followingQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :followerId", {followerId: user.id});
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.andWhere(
new Brackets((qb) => {
qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, {meId: user.id});
}),
)
}
const notes: Note[] = await query.getMany();
const notes: Note[] = await query.getMany();
found.push(...notes);
start += chunkSize;
}
found.push(...notes);
start += chunkSize;
}
// If we have more results than the limit, trim them
if (found.length > limit) {
found.length = limit;
}
// If we have more results than the limit, trim them
if (found.length > limit) {
found.length = limit;
}
return found;
}
// Try meilisearch next
else if (meilisearch) {
let start = 0;
const chunkSize = 100;
return found;
}
// Try meilisearch next
else if (meilisearch) {
let start = 0;
const chunkSize = 100;
// Use meilisearch to fetch and step through all search results that could match the requirements
const ids = [];
if (accountId) {
const acc = await getUser(accountId);
const append = acc.host !== null ? `from:${acc.usernameLower}@${acc.host} ` : `from:${acc.usernameLower}`;
q = append + q;
}
if (following) {
q = `filter:following ${q}`;
}
while (true) {
const results = await meilisearch.search(q, chunkSize, start, user);
// Use meilisearch to fetch and step through all search results that could match the requirements
const ids = [];
if (accountId) {
const acc = await getUser(accountId);
const append = acc.host !== null ? `from:${acc.usernameLower}@${acc.host} ` : `from:${acc.usernameLower}`;
q = append + q;
}
if (following) {
q = `filter:following ${q}`;
}
while (true) {
const results = await meilisearch.search(q, chunkSize, start, user);
start += chunkSize;
start += chunkSize;
if (results.hits.length === 0) {
break;
}
if (results.hits.length === 0) {
break;
}
//TODO test this, it's the same logic the mk api uses but it seems, we need to make .hits already be a MeilisearchNote[] instead of forcing type checks to pass
const res = (results.hits as MeilisearchNote[])
.filter((key: MeilisearchNote) => {
if (accountId && key.userId !== accountId) return false;
if (minId && key.id < minId) return false;
if (maxId && key.id > maxId) return false;
return true;
})
.map((key) => key.id);
//TODO test this, it's the same logic the mk api uses but it seems, we need to make .hits already be a MeilisearchNote[] instead of forcing type checks to pass
const res = (results.hits as MeilisearchNote[])
.filter((key: MeilisearchNote) => {
if (accountId && key.userId !== accountId) return false;
if (minId && key.id < minId) return false;
if (maxId && key.id > maxId) return false;
return true;
})
.map((key) => key.id);
ids.push(...res);
}
ids.push(...res);
}
// Sort all the results by note id DESC (newest first)
//FIXME: fix this sort function (is it even necessary?)
//ids.sort((a, b) => b - a);
// Sort all the results by note id DESC (newest first)
//FIXME: fix this sort function (is it even necessary?)
//ids.sort((a, b) => b - a);
// Fetch the notes from the database until we have enough to satisfy the limit
start = 0;
const found = [];
while (found.length < limit && start < ids.length) {
const chunk = ids.slice(start, start + chunkSize);
// Fetch the notes from the database until we have enough to satisfy the limit
start = 0;
const found = [];
while (found.length < limit && start < ids.length) {
const chunk = ids.slice(start, start + chunkSize);
const query = Notes.createQueryBuilder("note")
.where({id: In(chunk)})
.orderBy({id: "DESC"})
const query = Notes.createQueryBuilder("note")
.where({id: In(chunk)})
.orderBy({id: "DESC"})
generateVisibilityQuery(query, user);
generateVisibilityQuery(query, user);
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
const notes: Note[] = await query.getMany();
const notes: Note[] = await query.getMany();
found.push(...notes);
start += chunkSize;
}
found.push(...notes);
start += chunkSize;
}
// If we have more results than the limit, trim them
if (found.length > limit) {
found.length = limit;
}
// If we have more results than the limit, trim them
if (found.length > limit) {
found.length = limit;
}
return found;
}
else if (es) {
const userQuery =
accountId != null
? [
{
term: {
userId: accountId,
},
},
]
: [];
return found;
} else if (es) {
const userQuery =
accountId != null
? [
{
term: {
userId: accountId,
},
},
]
: [];
const result = await es.search({
index: config.elasticsearch.index || "misskey_note",
body: {
size: limit,
from: offset,
query: {
bool: {
must: [
{
simple_query_string: {
fields: ["text"],
query: q.toLowerCase(),
default_operator: "and",
},
},
...userQuery,
],
},
},
sort: [
{
_doc: "desc",
},
],
},
});
const result = await es.search({
index: config.elasticsearch.index || "misskey_note",
body: {
size: limit,
from: offset,
query: {
bool: {
must: [
{
simple_query_string: {
fields: ["text"],
query: q.toLowerCase(),
default_operator: "and",
},
},
...userQuery,
],
},
},
sort: [
{
_doc: "desc",
},
],
},
});
const hits = result.body.hits.hits.map((hit: any) => hit._id);
const hits = result.body.hits.hits.map((hit: any) => hit._id);
if (hits.length === 0) return [];
if (hits.length === 0) return [];
// Fetch found notes
const notes = await Notes.find({
where: {
id: In(hits),
},
order: {
id: -1,
},
});
// Fetch found notes
const notes = await Notes.find({
where: {
id: In(hits),
},
order: {
id: -1,
},
});
//TODO: test this
//FIXME: implement pagination
return notes;
}
//TODO: test this
//FIXME: implement pagination
return notes;
}
// Fallback to database query
const query = PaginationHelpers.makePaginationQuery(
Notes.createQueryBuilder("note"),
undefined,
minId,
maxId,
);
// Fallback to database query
const query = PaginationHelpers.makePaginationQuery(
Notes.createQueryBuilder("note"),
undefined,
minId,
maxId,
);
if (accountId) {
query.andWhere("note.userId = :userId", { userId: accountId });
}
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});
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.andWhere(
new Brackets((qb) => {
qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, {meId: user.id});
}),
)
}
query
.andWhere("note.text ILIKE :q", { q: `%${sqlLikeEscape(q)}%` })
.leftJoinAndSelect("note.renote", "renote");
query
.andWhere("note.text ILIKE :q", {q: `%${sqlLikeEscape(q)}%`})
.leftJoinAndSelect("note.renote", "renote");
generateVisibilityQuery(query, user);
generateVisibilityQuery(query, user);
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
if (!accountId) {
generateMutedUserQuery(query, user);
generateBlockedUserQuery(query, user);
}
return query.skip(offset ?? 0).take(limit).getMany().then(p => minId ? p.reverse() : p);
}
return query.skip(offset ?? 0).take(limit).getMany().then(p => minId ? p.reverse() : p);
}
private static async searchTags(q: string, excludeUnreviewed: boolean, limit: number, offset: number | undefined): Promise<MastodonEntity.Tag[]> {
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();
private static async searchTags(q: string, excludeUnreviewed: boolean, limit: number, offset: number | undefined): Promise<MastodonEntity.Tag[]> {
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
};
}));
}
return tags.then(p => p.map(tag => {
return {
name: tag.name,
url: `${config.url}/tags/${tag.name}`,
history: null
};
}));
}
}

View file

@ -15,78 +15,78 @@ import { meta } from "@/server/api/endpoints/notes/global-timeline.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
export class TimelineHelpers {
public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<Note[]> {
if (limit > 40) limit = 40;
public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<Note[]> {
if (limit > 40) limit = 40;
const followingQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :followerId", {followerId: user.id});
const followingQuery = Followings.createQueryBuilder("following")
.select("following.followeeId")
.where("following.followerId = :followerId", {followerId: user.id});
const query = PaginationHelpers.makePaginationQuery(
Notes.createQueryBuilder("note"),
sinceId,
maxId,
minId
)
.andWhere(
new Brackets((qb) => {
qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, {meId: user.id});
}),
)
.leftJoinAndSelect("note.renote", "renote");
const query = PaginationHelpers.makePaginationQuery(
Notes.createQueryBuilder("note"),
sinceId,
maxId,
minId
)
.andWhere(
new Brackets((qb) => {
qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, {meId: user.id});
}),
)
.leftJoinAndSelect("note.renote", "renote");
generateChannelQuery(query, user);
generateRepliesQuery(query, true, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
generateChannelQuery(query, user);
generateRepliesQuery(query, true, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
query.andWhere("note.visibility != 'hidden'");
query.andWhere("note.visibility != 'specified'");
query.andWhere("note.visibility != 'hidden'");
query.andWhere("note.visibility != 'specified'");
return PaginationHelpers.execQuery(query, limit, minId !== undefined);
}
return PaginationHelpers.execQuery(query, limit, minId !== undefined);
}
public static async getPublicTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise<Note[]> {
if (limit > 40) limit = 40;
public static async getPublicTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, local: boolean = false, remote: boolean = false): Promise<Note[]> {
if (limit > 40) limit = 40;
const m = await fetchMeta();
if (m.disableGlobalTimeline) {
if (user == null || !(user.isAdmin || user.isModerator)) {
throw new ApiError(meta.errors.gtlDisabled);
}
}
const m = await fetchMeta();
if (m.disableGlobalTimeline) {
if (user == null || !(user.isAdmin || user.isModerator)) {
throw new ApiError(meta.errors.gtlDisabled);
}
}
if (local && remote) {
throw new Error("local and remote are mutually exclusive options");
}
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'");
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");
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.renote", "renote");
query.leftJoinAndSelect("note.renote", "renote");
generateRepliesQuery(query, true, user);
if (user) {
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
}
generateRepliesQuery(query, true, user);
if (user) {
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);
generateMutedUserRenotesQueryForNotes(query, user);
}
if (onlyMedia) query.andWhere("note.fileIds != '{}'");
if (onlyMedia) query.andWhere("note.fileIds != '{}'");
return PaginationHelpers.execQuery(query, limit, minId !== undefined);
}
return PaginationHelpers.execQuery(query, limit, minId !== undefined);
}
}

View file

@ -1,22 +1,22 @@
import { Note } from "@/models/entities/note.js";
import { ILocalUser, User } from "@/models/entities/user.js";
import {
Blockings,
Followings,
FollowRequests,
Mutings,
NoteFavorites,
NoteReactions,
Notes,
NoteWatchings, RegistryItems, UserNotePinings,
UserProfiles,
Users
Blockings,
Followings,
FollowRequests,
Mutings,
NoteFavorites,
NoteReactions,
Notes,
NoteWatchings,
RegistryItems,
UserNotePinings,
UserProfiles,
Users
} from "@/models/index.js";
import { generateRepliesQuery } from "@/server/api/common/generate-replies-query.js";
import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js";
import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js";
import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js";
import Entity from "megalodon/src/entity.js";
import AsyncLock from "async-lock";
import { getUser } from "@/server/api/common/getters.js";
import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
@ -34,459 +34,457 @@ import acceptFollowRequest from "@/services/following/requests/accept.js";
import { rejectFollowRequest } from "@/services/following/reject.js";
import { Brackets, IsNull } from "typeorm";
import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js";
import { UserProfile } from "@/models/entities/user-profile.js";
export type AccountCache = {
locks: AsyncLock;
accounts: MastodonEntity.Account[];
users: User[];
locks: AsyncLock;
accounts: MastodonEntity.Account[];
users: User[];
};
export type LinkPaginationObject<T> = {
data: T;
maxId?: string | undefined;
minId?: string | undefined;
data: T;
maxId?: string | undefined;
minId?: string | undefined;
}
export type updateCredsData = {
display_name: string;
note: string;
locked: boolean;
bot: boolean;
discoverable: boolean;
display_name: string;
note: string;
locked: boolean;
bot: boolean;
discoverable: boolean;
}
type RelationshipType = 'followers' | 'following';
export class UserHelpers {
public static async followUser(target: User, localUser: ILocalUser, reblogs: boolean, notify: boolean): Promise<MastodonEntity.Relationship> {
//FIXME: implement reblogs & notify params
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);
}
public static async unfollowUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async blockUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async unblockUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async muteUser(target: User, localUser: ILocalUser, notifications: boolean = true, duration: number = 0): Promise<MastodonEntity.Relationship> {
//FIXME: respect notifications parameter
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,
} as Muting);
publishUserEvent(localUser.id, "mute", target);
NoteWatchings.delete({
userId: localUser.id,
noteUserId: target.id,
});
}
return this.getUserRelationshipTo(target.id, localUser.id);
}
public static async unmuteUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async acceptFollowRequest(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async rejectFollowRequest(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async updateCredentials(user: ILocalUser, formData: updateCredsData): Promise<MastodonEntity.Account> {
//FIXME: Actually implement this
//FIXME: handle multipart avatar & header image upload
//FIXME: handle field attributes
const obj: any = {};
if (formData.display_name) obj.name = formData.display_name;
if (formData.note) obj.description = formData.note;
if (formData.locked) obj.isLocked = formData.locked;
if (formData.bot) obj.isBot = formData.bot;
if (formData.discoverable) obj.isExplorable = formData.discoverable;
return this.verifyCredentials(user);
}
public static async verifyCredentials(user: ILocalUser): Promise<MastodonEntity.Account> {
const acct = UserConverter.encode(user);
const profile = UserProfiles.findOneByOrFail({userId: user.id});
const privacy = this.getDefaultNoteVisibility(user);
return acct.then(acct => {
const source = {
note: acct.note,
fields: acct.fields,
privacy: privacy.then(p => VisibilityConverter.encode(p)),
sensitive: profile.then(p => p.alwaysMarkNsfw),
language: profile.then(p => p.lang ?? ''),
};
const result = {
...acct,
source: awaitAll(source)
};
return awaitAll(result);
});
}
public static async getUserFromAcct(acct: string): Promise<User | null> {
const split = acct.toLowerCase().split('@');
if (split.length > 2) throw new Error('Invalid acct');
return Users.findOneBy({usernameLower: split[0], host: split[1] ?? IsNull()});
}
public static async getUserMutes(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<LinkPaginationObject<MastodonEntity.MutedAccount[]>> {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(
Mutings.createQueryBuilder("muting"),
sinceId,
maxId,
minId
);
query.andWhere("muting.muterId = :userId", {userId: user.id})
.innerJoinAndSelect("muting.mutee", "mutee");
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.mutee)
.filter(p => p) as User[];
const result = await UserConverter.encodeMany(users, cache)
.then(res => res.map(m => {
const muting = p.find(acc => acc.muteeId === m.id);
return {
...m,
mute_expires_at: muting?.expiresAt?.toISOString() ?? null
} as MastodonEntity.MutedAccount
}));
return {
data: result,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserBlocks(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(
Blockings.createQueryBuilder("blocking"),
sinceId,
maxId,
minId
);
query.andWhere("blocking.blockerId = :userId", {userId: user.id})
.innerJoinAndSelect("blocking.blockee", "blockee");
return query.take(limit).getMany().then(p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.blockee)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserFollowRequests(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(
FollowRequests.createQueryBuilder("request"),
sinceId,
maxId,
minId
);
query.andWhere("request.followeeId = :userId", {userId: user.id})
.innerJoinAndSelect("request.follower", "follower");
return query.take(limit).getMany().then(p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.follower)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserStatuses(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, excludeReplies: boolean = false, excludeReblogs: boolean = false, pinned: boolean = false, tagged: string | undefined): Promise<Note[]> {
if (limit > 40) limit = 40;
if (tagged !== undefined) {
//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');
}));
}
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.execQuery(query, limit, minId !== undefined);
}
public static async getUserBookmarks(localUser: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<LinkPaginationObject<Note[]>> {
if (limit > 40) limit = 40;
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.execQuery(query, limit, minId !== undefined)
.then(res => {
return {
data: res.map(p => p.note as Note),
maxId: res.map(p => p.id).at(-1),
minId: res.map(p => p.id)[0],
};
});
}
public static async getUserFavorites(localUser: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<LinkPaginationObject<Note[]>> {
if (limit > 40) limit = 40;
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.execQuery(query, limit, minId !== undefined)
.then(res => {
return {
data: res.map(p => p.note as Note),
maxId: res.map(p => p.id).at(-1),
minId: res.map(p => p.id)[0],
};
});
}
private static async getUserRelationships(type: RelationshipType, user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
if (profile.ffVisibility === "private") {
if (!localUser || user.id !== localUser.id) return { data: [] };
}
else if (profile.ffVisibility === "followers") {
if (!localUser) return { data: [] };
if (user.id !== localUser.id) {
const isFollowed = await Followings.exist({
where: {
followeeId: user.id,
followerId: localUser.id,
},
});
if (!isFollowed) return { data: [] };
}
}
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 query.take(limit).getMany().then(p => {
if (minId !== undefined) p = p.reverse();
return {
data: p.map(p => type === "followers" ? p.follower : p.followee).filter(p => p) as User[],
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserFollowers(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
return this.getUserRelationships('followers', user, localUser, maxId, sinceId, minId, limit);
}
public static async getUserFollowing(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
return this.getUserRelationships('following', user, localUser, maxId, sinceId, minId, limit);
}
public static async getUserRelationhipToMany(targetIds: string[], localUserId: string): Promise<MastodonEntity.Relationship[]> {
return Promise.all(targetIds.map(targetId => this.getUserRelationshipTo(targetId, localUserId)));
}
public static async getUserRelationshipTo(targetId: string, localUserId: string): Promise<MastodonEntity.Relationship> {
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, //FIXME
showing_reblogs: !relation.isRenoteMuted,
endorsed: false,
notifying: false, //FIXME
note: '' //FIXME
}
return awaitAll(response);
}
public static async getUserCached(id: string, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<User> {
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;
});
});
}
public static getFreshAccountCache(): AccountCache {
return {
locks: new AsyncLock(),
accounts: [],
users: [],
};
}
public static getDefaultNoteVisibility(user: ILocalUser): Promise<string> {
return RegistryItems.findOneBy({domain: IsNull(), userId: user.id, key: 'defaultNoteVisibility', scope: '{client,base}'}).then(p => p?.value ?? 'public')
}
public static async followUser(target: User, localUser: ILocalUser, reblogs: boolean, notify: boolean): Promise<MastodonEntity.Relationship> {
//FIXME: implement reblogs & notify params
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);
}
public static async unfollowUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async blockUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async unblockUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async muteUser(target: User, localUser: ILocalUser, notifications: boolean = true, duration: number = 0): Promise<MastodonEntity.Relationship> {
//FIXME: respect notifications parameter
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,
} as Muting);
publishUserEvent(localUser.id, "mute", target);
NoteWatchings.delete({
userId: localUser.id,
noteUserId: target.id,
});
}
return this.getUserRelationshipTo(target.id, localUser.id);
}
public static async unmuteUser(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async acceptFollowRequest(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async rejectFollowRequest(target: User, localUser: ILocalUser): Promise<MastodonEntity.Relationship> {
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);
}
public static async updateCredentials(user: ILocalUser, formData: updateCredsData): Promise<MastodonEntity.Account> {
//FIXME: Actually implement this
//FIXME: handle multipart avatar & header image upload
//FIXME: handle field attributes
const obj: any = {};
if (formData.display_name) obj.name = formData.display_name;
if (formData.note) obj.description = formData.note;
if (formData.locked) obj.isLocked = formData.locked;
if (formData.bot) obj.isBot = formData.bot;
if (formData.discoverable) obj.isExplorable = formData.discoverable;
return this.verifyCredentials(user);
}
public static async verifyCredentials(user: ILocalUser): Promise<MastodonEntity.Account> {
const acct = UserConverter.encode(user);
const profile = UserProfiles.findOneByOrFail({userId: user.id});
const privacy = this.getDefaultNoteVisibility(user);
return acct.then(acct => {
const source = {
note: acct.note,
fields: acct.fields,
privacy: privacy.then(p => VisibilityConverter.encode(p)),
sensitive: profile.then(p => p.alwaysMarkNsfw),
language: profile.then(p => p.lang ?? ''),
};
const result = {
...acct,
source: awaitAll(source)
};
return awaitAll(result);
});
}
public static async getUserFromAcct(acct: string): Promise<User | null> {
const split = acct.toLowerCase().split('@');
if (split.length > 2) throw new Error('Invalid acct');
return Users.findOneBy({usernameLower: split[0], host: split[1] ?? IsNull()});
}
public static async getUserMutes(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<LinkPaginationObject<MastodonEntity.MutedAccount[]>> {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(
Mutings.createQueryBuilder("muting"),
sinceId,
maxId,
minId
);
query.andWhere("muting.muterId = :userId", {userId: user.id})
.innerJoinAndSelect("muting.mutee", "mutee");
return query.take(limit).getMany().then(async p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.mutee)
.filter(p => p) as User[];
const result = await UserConverter.encodeMany(users, cache)
.then(res => res.map(m => {
const muting = p.find(acc => acc.muteeId === m.id);
return {
...m,
mute_expires_at: muting?.expiresAt?.toISOString() ?? null
} as MastodonEntity.MutedAccount
}));
return {
data: result,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserBlocks(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(
Blockings.createQueryBuilder("blocking"),
sinceId,
maxId,
minId
);
query.andWhere("blocking.blockerId = :userId", {userId: user.id})
.innerJoinAndSelect("blocking.blockee", "blockee");
return query.take(limit).getMany().then(p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.blockee)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserFollowRequests(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const query = PaginationHelpers.makePaginationQuery(
FollowRequests.createQueryBuilder("request"),
sinceId,
maxId,
minId
);
query.andWhere("request.followeeId = :userId", {userId: user.id})
.innerJoinAndSelect("request.follower", "follower");
return query.take(limit).getMany().then(p => {
if (minId !== undefined) p = p.reverse();
const users = p
.map(p => p.follower)
.filter(p => p) as User[];
return {
data: users,
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserStatuses(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, excludeReplies: boolean = false, excludeReblogs: boolean = false, pinned: boolean = false, tagged: string | undefined): Promise<Note[]> {
if (limit > 40) limit = 40;
if (tagged !== undefined) {
//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');
}));
}
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.execQuery(query, limit, minId !== undefined);
}
public static async getUserBookmarks(localUser: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<LinkPaginationObject<Note[]>> {
if (limit > 40) limit = 40;
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.execQuery(query, limit, minId !== undefined)
.then(res => {
return {
data: res.map(p => p.note as Note),
maxId: res.map(p => p.id).at(-1),
minId: res.map(p => p.id)[0],
};
});
}
public static async getUserFavorites(localUser: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise<LinkPaginationObject<Note[]>> {
if (limit > 40) limit = 40;
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.execQuery(query, limit, minId !== undefined)
.then(res => {
return {
data: res.map(p => p.note as Note),
maxId: res.map(p => p.id).at(-1),
minId: res.map(p => p.id)[0],
};
});
}
private static async getUserRelationships(type: RelationshipType, user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
if (limit > 80) limit = 80;
const profile = await UserProfiles.findOneByOrFail({userId: user.id});
if (profile.ffVisibility === "private") {
if (!localUser || user.id !== localUser.id) return {data: []};
} else if (profile.ffVisibility === "followers") {
if (!localUser) return {data: []};
if (user.id !== localUser.id) {
const isFollowed = await Followings.exist({
where: {
followeeId: user.id,
followerId: localUser.id,
},
});
if (!isFollowed) return {data: []};
}
}
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 query.take(limit).getMany().then(p => {
if (minId !== undefined) p = p.reverse();
return {
data: p.map(p => type === "followers" ? p.follower : p.followee).filter(p => p) as User[],
maxId: p.map(p => p.id).at(-1),
minId: p.map(p => p.id)[0],
};
});
}
public static async getUserFollowers(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
return this.getUserRelationships('followers', user, localUser, maxId, sinceId, minId, limit);
}
public static async getUserFollowing(user: User, localUser: ILocalUser | null, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40): Promise<LinkPaginationObject<User[]>> {
return this.getUserRelationships('following', user, localUser, maxId, sinceId, minId, limit);
}
public static async getUserRelationhipToMany(targetIds: string[], localUserId: string): Promise<MastodonEntity.Relationship[]> {
return Promise.all(targetIds.map(targetId => this.getUserRelationshipTo(targetId, localUserId)));
}
public static async getUserRelationshipTo(targetId: string, localUserId: string): Promise<MastodonEntity.Relationship> {
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, //FIXME
showing_reblogs: !relation.isRenoteMuted,
endorsed: false,
notifying: false, //FIXME
note: '' //FIXME
}
return awaitAll(response);
}
public static async getUserCached(id: string, cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise<User> {
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;
});
});
}
public static getFreshAccountCache(): AccountCache {
return {
locks: new AsyncLock(),
accounts: [],
users: [],
};
}
public static getDefaultNoteVisibility(user: ILocalUser): Promise<string> {
return RegistryItems.findOneBy({domain: IsNull(), userId: user.id, key: 'defaultNoteVisibility', scope: '{client,base}'}).then(p => p?.value ?? 'public')
}
}

View file

@ -14,43 +14,43 @@ import multer from "@koa/multer";
import { setupEndpointsList } from "@/server/api/mastodon/endpoints/list.js";
export function getClient(
BASE_URL: string,
authorization: string | undefined,
BASE_URL: string,
authorization: string | undefined,
): MegalodonInterface {
const accessTokenArr = authorization?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator(BASE_URL, accessToken) as MegalodonInterface;
return client;
const accessTokenArr = authorization?.split(" ") ?? [null];
const accessToken = accessTokenArr[accessTokenArr.length - 1];
const generator = (megalodon as any).default;
const client = generator(BASE_URL, accessToken) as MegalodonInterface;
return client;
}
export function setupMastodonApi(router: Router, fileRouter: Router, upload: multer.Instance): void {
router.use(
koaBody({
multipart: true,
urlencoded: true,
}),
);
router.use(
koaBody({
multipart: true,
urlencoded: true,
}),
);
router.use(async (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();
});
router.use(async (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();
});
setupEndpointsAuth(router);
setupEndpointsAccount(router);
setupEndpointsStatus(router);
setupEndpointsFilter(router);
setupEndpointsTimeline(router);
setupEndpointsNotifications(router);
setupEndpointsSearch(router);
setupEndpointsMedia(router, fileRouter, upload);
setupEndpointsList(router);
setupEndpointsMisc(router);
setupEndpointsAuth(router);
setupEndpointsAccount(router);
setupEndpointsStatus(router);
setupEndpointsFilter(router);
setupEndpointsTimeline(router);
setupEndpointsNotifications(router);
setupEndpointsSearch(router);
setupEndpointsMedia(router, fileRouter, upload);
setupEndpointsList(router);
setupEndpointsMisc(router);
}