diff --git a/packages/backend/src/server/api/mastodon/converters.ts b/packages/backend/src/server/api/mastodon/converters.ts index ee86344a5..8a77bf363 100644 --- a/packages/backend/src/server/api/mastodon/converters.ts +++ b/packages/backend/src/server/api/mastodon/converters.ts @@ -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; } diff --git a/packages/backend/src/server/api/mastodon/converters/file.ts b/packages/backend/src/server/api/mastodon/converters/file.ts index eb4caf7cc..31a34b9ee 100644 --- a/packages/backend/src/server/api/mastodon/converters/file.ts +++ b/packages/backend/src/server/api/mastodon/converters/file.ts @@ -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"; + }; } diff --git a/packages/backend/src/server/api/mastodon/converters/mention.ts b/packages/backend/src/server/api/mastodon/converters/mention.ts index 9825ad237..d2f9cab9d 100644 --- a/packages/backend/src/server/api/mastodon/converters/mention.ts +++ b/packages/backend/src/server/api/mastodon/converters/mention.ts @@ -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, + }; + } } diff --git a/packages/backend/src/server/api/mastodon/converters/note.ts b/packages/backend/src/server/api/mastodon/converters/note.ts index 05cbe2f3a..58bede5df 100644 --- a/packages/backend/src/server/api/mastodon/converters/note.ts +++ b/packages/backend/src/server/api/mastodon/converters/note.ts @@ -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 { 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; + 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; - 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 { - 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 { + const encoded = notes.map(n => this.encode(n, user, cache)); + return Promise.all(encoded); + } } diff --git a/packages/backend/src/server/api/mastodon/converters/notification.ts b/packages/backend/src/server/api/mastodon/converters/notification.ts index 92aab58a6..f12aad3e1 100644 --- a/packages/backend/src/server/api/mastodon/converters/notification.ts +++ b/packages/backend/src/server/api/mastodon/converters/notification.ts @@ -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 { - 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 { + 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 { - 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 { + 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`); + } + } } diff --git a/packages/backend/src/server/api/mastodon/converters/poll.ts b/packages/backend/src/server/api/mastodon/converters/poll.ts index 4da0844a3..adaefd997 100644 --- a/packages/backend/src/server/api/mastodon/converters/poll.ts +++ b/packages/backend/src/server/api/mastodon/converters/poll.ts @@ -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 + multiple: boolean + expiresAt: Date | null + choices: Array } 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, + }; + } } diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts index f06fa8377..9271c0fab 100644 --- a/packages/backend/src/server/api/mastodon/converters/user.ts +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -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 { - 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 { + 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 { - const encoded = users.map(u => this.encode(u, cache)); - return Promise.all(encoded); - } + public static async encodeMany(users: User[], cache: AccountCache = UserHelpers.getFreshAccountCache()): Promise { + 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, + } + } } diff --git a/packages/backend/src/server/api/mastodon/converters/visibility.ts b/packages/backend/src/server/api/mastodon/converters/visibility.ts index cba1db656..37d91830b 100644 --- a/packages/backend/src/server/api/mastodon/converters/visibility.ts +++ b/packages/backend/src/server/api/mastodon/converters/visibility.ts @@ -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; diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 2175e21ee..95a4fa888 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -11,534 +11,534 @@ import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; export function setupEndpointsAccount(router: Router): void { - router.get("/v1/accounts/verify_credentials", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + router.get("/v1/accounts/verify_credentials", 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 acct = await UserHelpers.verifyCredentials(user); - ctx.body = convertAccount(acct); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.patch("/v1/accounts/update_credentials", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const acct = await UserHelpers.verifyCredentials(user); + ctx.body = convertAccount(acct); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.patch("/v1/accounts/update_credentials", 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 acct = await UserHelpers.updateCredentials(user, (ctx.request as any).body as any); - ctx.body = convertAccount(acct) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/accounts/lookup", async (ctx) => { - try { - const args = normalizeUrlQuery(ctx.query); - const user = await UserHelpers.getUserFromAcct(args.acct); - if (user === null) { - ctx.status = 404; - return; - } - const account = await UserConverter.encode(user); - ctx.body = convertAccount(account); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/accounts/relationships", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const acct = await UserHelpers.updateCredentials(user, (ctx.request as any).body as any); + ctx.body = convertAccount(acct) + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/accounts/lookup", async (ctx) => { + try { + const args = normalizeUrlQuery(ctx.query); + const user = await UserHelpers.getUserFromAcct(args.acct); + if (user === null) { + ctx.status = 404; + return; + } + const account = await UserConverter.encode(user); + ctx.body = convertAccount(account); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/accounts/relationships", 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 ids = (normalizeUrlQuery(ctx.query, ['id[]'])['id[]'] ?? []) - .map((id: string) => convertId(id, IdType.IceshrimpId)); - const result = await UserHelpers.getUserRelationhipToMany(ids, user.id); - ctx.body = result.map(rel => convertRelationship(rel)); - } 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/accounts/:id", async (ctx) => { - try { - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const account = await UserConverter.encode(await getUser(userId)); - ctx.body = convertAccount(account); - } 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/accounts/:id/statuses", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const ids = (normalizeUrlQuery(ctx.query, ['id[]'])['id[]'] ?? []) + .map((id: string) => convertId(id, IdType.IceshrimpId)); + const result = await UserHelpers.getUserRelationhipToMany(ids, user.id); + ctx.body = result.map(rel => convertRelationship(rel)); + } 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/accounts/:id", async (ctx) => { + try { + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const account = await UserConverter.encode(await getUser(userId)); + ctx.body = convertAccount(account); + } 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/accounts/:id/statuses", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const query = await UserHelpers.getUserCached(userId, cache); - const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query)))); - const tl = await UserHelpers.getUserStatuses(query, user, args.max_id, args.since_id, args.min_id, args.limit, args['only_media'], args['exclude_replies'], args['exclude_reblogs'], args.pinned, args.tagged) - .then(n => NoteConverter.encodeMany(n, user, cache)); + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const cache = UserHelpers.getFreshAccountCache(); + const query = await UserHelpers.getUserCached(userId, cache); + const args = normalizeUrlQuery(convertPaginationArgsIds(argsToBools(limitToInt(ctx.query)))); + const tl = await UserHelpers.getUserStatuses(query, user, args.max_id, args.since_id, args.min_id, args.limit, args['only_media'], args['exclude_replies'], args['exclude_reblogs'], args.pinned, args.tagged) + .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: { id: string } }>( - "/v1/accounts/:id/featured_tags", - async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getAccountFeaturedTags( - convertId(ctx.params.id, IdType.IceshrimpId), - ); - ctx.body = data.data.map((tag) => convertFeaturedTag(tag)); - } 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/accounts/:id/followers", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + 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: { id: string } }>( + "/v1/accounts/:id/featured_tags", + async (ctx) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountFeaturedTags( + convertId(ctx.params.id, IdType.IceshrimpId), + ); + ctx.body = data.data.map((tag) => convertFeaturedTag(tag)); + } 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/accounts/:id/followers", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const query = await UserHelpers.getUserCached(userId, cache); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const cache = UserHelpers.getFreshAccountCache(); + const query = await UserHelpers.getUserCached(userId, cache); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserFollowers(query, user, args.max_id, args.since_id, args.min_id, args.limit); - const followers = await UserConverter.encodeMany(res.data, cache); + const res = await UserHelpers.getUserFollowers(query, user, args.max_id, args.since_id, args.min_id, args.limit); + const followers = await UserConverter.encodeMany(res.data, cache); - ctx.body = followers.map((account) => convertAccount(account)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } 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/accounts/:id/following", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + ctx.body = followers.map((account) => convertAccount(account)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } 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/accounts/:id/following", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; - const userId = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const query = await UserHelpers.getUserCached(userId, cache); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const userId = convertId(ctx.params.id, IdType.IceshrimpId); + const cache = UserHelpers.getFreshAccountCache(); + const query = await UserHelpers.getUserCached(userId, cache); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await UserHelpers.getUserFollowing(query, user, args.max_id, args.since_id, args.min_id, args.limit); - const following = await UserConverter.encodeMany(res.data, cache); + const res = await UserHelpers.getUserFollowing(query, user, args.max_id, args.since_id, args.min_id, args.limit); + const following = await UserConverter.encodeMany(res.data, cache); - ctx.body = following.map((account) => convertAccount(account)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } 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/accounts/:id/lists", - async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getAccountLists( - convertId(ctx.params.id, IdType.IceshrimpId), - ); - ctx.body = data.data.map((list) => convertList(list)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/accounts/:id/follow", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + ctx.body = following.map((account) => convertAccount(account)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } 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/accounts/:id/lists", + async (ctx) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountLists( + convertId(ctx.params.id, IdType.IceshrimpId), + ); + ctx.body = data.data.map((list) => convertList(list)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/follow", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - //FIXME: Parse form data - const result = await UserHelpers.followUser(target, user, true, false); - ctx.body = convertRelationship(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/accounts/:id/unfollow", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + //FIXME: Parse form data + const result = await UserHelpers.followUser(target, user, true, false); + ctx.body = convertRelationship(result); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/unfollow", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.unfollowUser(target, user); - ctx.body = convertRelationship(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/accounts/:id/block", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.unfollowUser(target, user); + ctx.body = convertRelationship(result); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/block", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.blockUser(target, user); - ctx.body = convertRelationship(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/accounts/:id/unblock", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.blockUser(target, user); + ctx.body = convertRelationship(result); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/unblock", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.unblockUser(target, user); - ctx.body = convertRelationship(result) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/accounts/:id/mute", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.unblockUser(target, user); + ctx.body = convertRelationship(result) + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/mute", + 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; + } - //FIXME: parse form data - const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, ['duration']), ['notifications'])); - const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.muteUser(target, user, args.notifications, args.duration); - ctx.body = convertRelationship(result) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/accounts/:id/unmute", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + //FIXME: parse form data + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, ['duration']), ['notifications'])); + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.muteUser(target, user, args.notifications, args.duration); + ctx.body = convertRelationship(result) + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/unmute", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.unmuteUser(target, user); - ctx.body = convertRelationship(result) - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get("/v1/featured_tags", async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFeaturedTags(); - ctx.body = data.data.map((tag) => convertFeaturedTag(tag)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/followed_tags", async (ctx) => { - const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; - const accessTokens = ctx.headers.authorization; - const client = getClient(BASE_URL, accessTokens); - try { - const data = await client.getFollowedTags(); - 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("/v1/bookmarks", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.unmuteUser(target, user); + ctx.body = convertRelationship(result) + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get("/v1/featured_tags", async (ctx) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFeaturedTags(); + ctx.body = data.data.map((tag) => convertFeaturedTag(tag)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/followed_tags", async (ctx) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFollowedTags(); + 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("/v1/bookmarks", 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 as any))); - const res = await UserHelpers.getUserBookmarks(user, args.max_id, args.since_id, args.min_id, args.limit); - const bookmarks = await NoteConverter.encodeMany(res.data, user, cache); + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserBookmarks(user, args.max_id, args.since_id, args.min_id, args.limit); + const bookmarks = await NoteConverter.encodeMany(res.data, user, cache); - ctx.body = bookmarks.map(s => convertStatus(s)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/favourites", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + ctx.body = bookmarks.map(s => convertStatus(s)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/favourites", 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 as any))); - const res = await UserHelpers.getUserFavorites(user, args.max_id, args.since_id, args.min_id, args.limit); - const favorites = await NoteConverter.encodeMany(res.data, user, cache); + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserFavorites(user, args.max_id, args.since_id, args.min_id, args.limit); + const favorites = await NoteConverter.encodeMany(res.data, user, cache); - ctx.body = favorites.map(s => convertStatus(s)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/mutes", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + ctx.body = favorites.map(s => convertStatus(s)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/mutes", 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 as any))); - const res = await UserHelpers.getUserMutes(user, args.max_id, args.since_id, args.min_id, args.limit, cache); - ctx.body = res.data.map(m => convertAccount(m)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/blocks", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserMutes(user, args.max_id, args.since_id, args.min_id, args.limit, cache); + ctx.body = res.data.map(m => convertAccount(m)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/blocks", 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 as any))); - const res = await UserHelpers.getUserBlocks(user, args.max_id, args.since_id, args.min_id, args.limit); - const blocks = await UserConverter.encodeMany(res.data, cache); - ctx.body = blocks.map(b => convertAccount(b)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.get("/v1/follow_requests", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserBlocks(user, args.max_id, args.since_id, args.min_id, args.limit); + const blocks = await UserConverter.encodeMany(res.data, cache); + ctx.body = blocks.map(b => convertAccount(b)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.get("/v1/follow_requests", 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 as any))); - const res = await UserHelpers.getUserFollowRequests(user, args.max_id, args.since_id, args.min_id, args.limit); - const requests = await UserConverter.encodeMany(res.data, cache); - ctx.body = requests.map(b => convertAccount(b)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.post<{ Params: { id: string } }>( - "/v1/follow_requests/:id/authorize", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await UserHelpers.getUserFollowRequests(user, args.max_id, args.since_id, args.min_id, args.limit); + const requests = await UserConverter.encodeMany(res.data, cache); + ctx.body = requests.map(b => convertAccount(b)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.post<{ Params: { id: string } }>( + "/v1/follow_requests/:id/authorize", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.acceptFollowRequest(target, user); - ctx.body = convertRelationship(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/follow_requests/:id/reject", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.acceptFollowRequest(target, user); + ctx.body = convertRelationship(result); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/follow_requests/:id/reject", + 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 target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); - const result = await UserHelpers.rejectFollowRequest(target, user); - ctx.body = convertRelationship(result); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); + const target = await UserHelpers.getUserCached(convertId(ctx.params.id, IdType.IceshrimpId)); + const result = await UserHelpers.rejectFollowRequest(target, user); + ctx.body = convertRelationship(result); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index be31edcc6..373cc2285 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -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(); - 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(); + 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; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts index 3659a8bba..f4700f9e3 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/filter.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -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; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/list.ts b/packages/backend/src/server/api/mastodon/endpoints/list.ts index 52b83c18e..82cb4d929 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/list.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/list.ts @@ -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; + } + }, + ); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/media.ts b/packages/backend/src/server/api/mastodon/endpoints/media.ts index cd1c9433f..08d528001 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/media.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/media.ts @@ -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}; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index d09afd7b3..6115fef47 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -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: [], + }; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/misc.ts b/packages/backend/src/server/api/mastodon/endpoints/misc.ts index 694c60771..93583bc56 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/misc.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/misc.ts @@ -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; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts index 2281efa1f..69d2976eb 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -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; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index a1e8fb4f3..f230d0429 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -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 []; + } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index f3b196cb6..98bb967c2 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -14,670 +14,670 @@ import AsyncLock from "async-lock"; import { ILocalUser } from "@/models/entities/user.js"; import { PollHelpers } from "@/server/api/mastodon/helpers/poll.js"; -const postIdempotencyCache = new Cache<{status?: MastodonEntity.Status}>('postIdempotencyCache', 60 * 60); +const postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60); const postIdempotencyLocks = new AsyncLock(); export function setupEndpointsStatus(router: Router): void { - router.post("/v1/statuses", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const key = getIdempotencyKey(ctx.headers, user); - if (key !== null) { - const result = await getFromIdempotencyCache(key); - - if (result) { - ctx.body = result; - return; - } - } - - let request = NoteHelpers.normalizeComposeOptions(ctx.request.body); - ctx.body = await NoteHelpers.createNote(request, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - - if (key !== null) postIdempotencyCache.set(key, {status: ctx.body}); - } catch (e: any) { - console.error(e); - ctx.status = 500; - ctx.body = { error: e.message }; - } - }); - router.put("/v1/statuses/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const noteId = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); - if (!note) { - if (!note) { - ctx.status = 404; - ctx.body = { - error: "Note not found" - }; - return; - } - } - - let request = NoteHelpers.normalizeEditOptions(ctx.request.body); - ctx.body = await NoteHelpers.editNote(request, note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = ctx.status == 404 ? 404 : 401; - ctx.body = e.response.data; - } - }); - router.get<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const noteId = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); - - if (!note) { - ctx.status = 404; - return; - } - - const status = await NoteConverter.encode(note, user); - ctx.body = convertStatus(status); - } catch (e: any) { - console.error(e); - ctx.status = ctx.status == 404 ? 404 : 401; - ctx.body = e.response.data; - } - }); - router.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const noteId = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); - - if (!note) { - ctx.status = 404; - ctx.body = { - error: "Note not found" - }; - return; - } - - if (user.id !== note.userId) { - ctx.status = 403; - ctx.body = { - error: "Cannot delete someone else's note" - }; - return; - } - - ctx.body = await NoteHelpers.deleteNote(note, user) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(`Error processing ${ctx.method} /api${ctx.path}: ${e.message}`); - ctx.status = 500; - ctx.body = { - error: e.message - } - } - }); - - router.get<{ Params: { id: string } }>( - "/v1/statuses/:id/context", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const cache = UserHelpers.getFreshAccountCache(); - const note = await getNote(id, user ?? null).then(n => n).catch(() => null); - if (!note) { - if (!note) { - ctx.status = 404; - return; - } - } - - const ancestors = await NoteHelpers.getNoteAncestors(note, user, user ? 4096 : 60) - .then(n => NoteConverter.encodeMany(n, user, cache)) - .then(n => n.map(s => convertStatus(s))); - const descendants = await NoteHelpers.getNoteDescendants(note, user, user ? 4096 : 40, user ? 4096 : 20) - .then(n => NoteConverter.encodeMany(n, user, cache)) - .then(n => n.map(s => convertStatus(s))); - - ctx.body = { - ancestors, - descendants, - }; - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get<{ Params: { id: string } }>( - "/v1/statuses/:id/history", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const res = await NoteHelpers.getNoteEditHistory(note); - ctx.body = res.map(p => convertStatusEdit(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get<{ Params: { id: string } }>( - "/v1/statuses/:id/source", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const src = NoteHelpers.getNoteSource(note); - ctx.body = convertStatusSource(src); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get<{ Params: { id: string } }>( - "/v1/statuses/:id/reblogged_by", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await NoteHelpers.getNoteRebloggedBy(note, args.max_id, args.since_id, args.min_id, args.limit); - const users = await UserConverter.encodeMany(res.data, cache); - ctx.body = users.map(m => convertAccount(m)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get<{ Params: { id: string } }>( - "/v1/statuses/:id/favourited_by", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const cache = UserHelpers.getFreshAccountCache(); - const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); - const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit); - const users = await UserConverter.encodeMany(res.data, cache); - ctx.body = users.map(m => convertAccount(m)); - PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/favourite", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - const reaction = await NoteHelpers.getDefaultReaction().catch(_ => null); - - if (reaction === null) { - ctx.status = 500; - return; - } - - ctx.body = await NoteHelpers.reactToNote(note, user, reaction) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - console.error(e.response.data); - ctx.status = 400; - ctx.body = e.response.data; - } - }, - ); - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/unfavourite", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.removeReactFromNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/reblog", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.reblogNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/unreblog", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.unreblogNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/bookmark", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.bookmarkNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/unbookmark", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.unbookmarkNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/pin", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.pinNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string } }>( - "/v1/statuses/:id/unpin", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.unpinNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string; name: string } }>( - "/v1/statuses/:id/react/:name", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.reactToNote(note, user, ctx.params.name) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - - router.post<{ Params: { id: string; name: string } }>( - "/v1/statuses/:id/unreact/:name", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null) { - ctx.status = 404; - return; - } - - ctx.body = await NoteHelpers.removeReactFromNote(note, user) - .then(p => NoteConverter.encode(p, user)) - .then(p => convertStatus(p)); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); - router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null || !note.hasPoll) { - ctx.status = 404; - return; - } - - const data = await PollHelpers.getPoll(note, user); - ctx.body = convertPoll(data); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }); - router.post<{ Params: { id: string } }>( - "/v1/polls/:id/votes", - async (ctx) => { - try { - const auth = await authenticate(ctx.headers.authorization, null); - const user = auth[0] ?? null; - - if (!user) { - ctx.status = 401; - return; - } - - const id = convertId(ctx.params.id, IdType.IceshrimpId); - const note = await getNote(id, user).catch(_ => null); - - if (note === null || !note.hasPoll) { - ctx.status = 404; - return; - } - - const body: any = ctx.request.body; - const choices = NoteHelpers.normalizeToArray(body.choices ?? []).map(p => parseInt(p)); - if (choices.length < 1) { - ctx.status = 400; - ctx.body = { error: 'Must vote for at least one option' }; - return; - } - - const data = await PollHelpers.voteInPoll(choices, note, user); - ctx.body = convertPoll(data); - } catch (e: any) { - console.error(e); - ctx.status = 401; - ctx.body = e.response.data; - } - }, - ); + router.post("/v1/statuses", async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const key = getIdempotencyKey(ctx.headers, user); + if (key !== null) { + const result = await getFromIdempotencyCache(key); + + if (result) { + ctx.body = result; + return; + } + } + + let request = NoteHelpers.normalizeComposeOptions(ctx.request.body); + ctx.body = await NoteHelpers.createNote(request, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + + if (key !== null) postIdempotencyCache.set(key, {status: ctx.body}); + } catch (e: any) { + console.error(e); + ctx.status = 500; + ctx.body = {error: e.message}; + } + }); + router.put("/v1/statuses/:id", async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const noteId = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); + if (!note) { + if (!note) { + ctx.status = 404; + ctx.body = { + error: "Note not found" + }; + return; + } + } + + let request = NoteHelpers.normalizeEditOptions(ctx.request.body); + ctx.body = await NoteHelpers.editNote(request, note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = ctx.status == 404 ? 404 : 401; + ctx.body = e.response.data; + } + }); + router.get<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const noteId = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); + + if (!note) { + ctx.status = 404; + return; + } + + const status = await NoteConverter.encode(note, user); + ctx.body = convertStatus(status); + } catch (e: any) { + console.error(e); + ctx.status = ctx.status == 404 ? 404 : 401; + ctx.body = e.response.data; + } + }); + router.delete<{ Params: { id: string } }>("/v1/statuses/:id", async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const noteId = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(noteId, user ?? null).then(n => n).catch(() => null); + + if (!note) { + ctx.status = 404; + ctx.body = { + error: "Note not found" + }; + return; + } + + if (user.id !== note.userId) { + ctx.status = 403; + ctx.body = { + error: "Cannot delete someone else's note" + }; + return; + } + + ctx.body = await NoteHelpers.deleteNote(note, user) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(`Error processing ${ctx.method} /api${ctx.path}: ${e.message}`); + ctx.status = 500; + ctx.body = { + error: e.message + } + } + }); + + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/context", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const cache = UserHelpers.getFreshAccountCache(); + const note = await getNote(id, user ?? null).then(n => n).catch(() => null); + if (!note) { + if (!note) { + ctx.status = 404; + return; + } + } + + const ancestors = await NoteHelpers.getNoteAncestors(note, user, user ? 4096 : 60) + .then(n => NoteConverter.encodeMany(n, user, cache)) + .then(n => n.map(s => convertStatus(s))); + const descendants = await NoteHelpers.getNoteDescendants(note, user, user ? 4096 : 40, user ? 4096 : 20) + .then(n => NoteConverter.encodeMany(n, user, cache)) + .then(n => n.map(s => convertStatus(s))); + + ctx.body = { + ancestors, + descendants, + }; + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/history", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + const res = await NoteHelpers.getNoteEditHistory(note); + ctx.body = res.map(p => convertStatusEdit(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/source", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + const src = NoteHelpers.getNoteSource(note); + ctx.body = convertStatusSource(src); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/reblogged_by", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await NoteHelpers.getNoteRebloggedBy(note, args.max_id, args.since_id, args.min_id, args.limit); + const users = await UserConverter.encodeMany(res.data, cache); + ctx.body = users.map(m => convertAccount(m)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/favourited_by", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + const cache = UserHelpers.getFreshAccountCache(); + const args = normalizeUrlQuery(convertPaginationArgsIds(limitToInt(ctx.query as any))); + const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit); + const users = await UserConverter.encodeMany(res.data, cache); + ctx.body = users.map(m => convertAccount(m)); + PaginationHelpers.appendLinkPaginationHeader(args, ctx, res); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/favourite", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + const reaction = await NoteHelpers.getDefaultReaction().catch(_ => null); + + if (reaction === null) { + ctx.status = 500; + return; + } + + ctx.body = await NoteHelpers.reactToNote(note, user, reaction) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + console.error(e.response.data); + ctx.status = 400; + ctx.body = e.response.data; + } + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unfavourite", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.removeReactFromNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/reblog", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.reblogNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unreblog", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.unreblogNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/bookmark", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.bookmarkNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unbookmark", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.unbookmarkNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/pin", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.pinNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unpin", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.unpinNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string; name: string } }>( + "/v1/statuses/:id/react/:name", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.reactToNote(note, user, ctx.params.name) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + + router.post<{ Params: { id: string; name: string } }>( + "/v1/statuses/:id/unreact/:name", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null) { + ctx.status = 404; + return; + } + + ctx.body = await NoteHelpers.removeReactFromNote(note, user) + .then(p => NoteConverter.encode(p, user)) + .then(p => convertStatus(p)); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); + router.get<{ Params: { id: string } }>("/v1/polls/:id", async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null || !note.hasPoll) { + ctx.status = 404; + return; + } + + const data = await PollHelpers.getPoll(note, user); + ctx.body = convertPoll(data); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }); + router.post<{ Params: { id: string } }>( + "/v1/polls/:id/votes", + async (ctx) => { + try { + const auth = await authenticate(ctx.headers.authorization, null); + const user = auth[0] ?? null; + + if (!user) { + ctx.status = 401; + return; + } + + const id = convertId(ctx.params.id, IdType.IceshrimpId); + const note = await getNote(id, user).catch(_ => null); + + if (note === null || !note.hasPoll) { + ctx.status = 404; + return; + } + + const body: any = ctx.request.body; + const choices = NoteHelpers.normalizeToArray(body.choices ?? []).map(p => parseInt(p)); + if (choices.length < 1) { + ctx.status = 400; + ctx.body = {error: 'Must vote for at least one option'}; + return; + } + + const data = await PollHelpers.voteInPoll(choices, note, user); + ctx.body = convertPoll(data); + } catch (e: any) { + console.error(e); + ctx.status = 401; + ctx.body = e.response.data; + } + }, + ); } function getIdempotencyKey(headers: any, user: ILocalUser): string | null { - if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null; - return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`; + if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null; + return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`; } async function getFromIdempotencyCache(key: string): Promise { - return postIdempotencyLocks.acquire(key, async (): Promise => { - if (await postIdempotencyCache.get(key) !== undefined) { - let i = 5; - while ((await postIdempotencyCache.get(key))?.status === undefined) { - if (++i > 5) throw new Error('Post is duplicate but unable to resolve original'); - await new Promise((resolve) => { - setTimeout(resolve, 500); - }); - } + return postIdempotencyLocks.acquire(key, async (): Promise => { + if (await postIdempotencyCache.get(key) !== undefined) { + let i = 5; + while ((await postIdempotencyCache.get(key))?.status === undefined) { + if (++i > 5) throw new Error('Post is duplicate but unable to resolve original'); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + } - return (await postIdempotencyCache.get(key))?.status; - } else { - await postIdempotencyCache.set(key, {}); - return undefined; - } - }); + return (await postIdempotencyCache.get(key))?.status; + } else { + await postIdempotencyCache.set(key, {}); + return undefined; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index a986bb5a1..3751c8f6f 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -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(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(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(q[key]); + for (const key of keys) + if (q[key] && typeof q[key] === "string") + object[key] = toBoolean(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; + } + }); } diff --git a/packages/backend/src/server/api/mastodon/entities/account.ts b/packages/backend/src/server/api/mastodon/entities/account.ts index 8ba8a3710..87f7de019 100644 --- a/packages/backend/src/server/api/mastodon/entities/account.ts +++ b/packages/backend/src/server/api/mastodon/entities/account.ts @@ -2,31 +2,31 @@ /// /// 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; - moved: Account | null; - fields: Array; - 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; + moved: Account | null; + fields: Array; + 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; + } } diff --git a/packages/backend/src/server/api/mastodon/entities/activity.ts b/packages/backend/src/server/api/mastodon/entities/activity.ts index 1945867e0..af53b9ba9 100644 --- a/packages/backend/src/server/api/mastodon/entities/activity.ts +++ b/packages/backend/src/server/api/mastodon/entities/activity.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/announcement.ts b/packages/backend/src/server/api/mastodon/entities/announcement.ts index 507dbed5e..0876d8ae4 100644 --- a/packages/backend/src/server/api/mastodon/entities/announcement.ts +++ b/packages/backend/src/server/api/mastodon/entities/announcement.ts @@ -3,32 +3,32 @@ /// 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; - statuses: Array; - tags: Array; - emojis: Array; - reactions: Array; - }; + 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; + statuses: Array; + tags: Array; + emojis: Array; + reactions: Array; + }; - 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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/application.ts b/packages/backend/src/server/api/mastodon/entities/application.ts index d58c503d9..cdb97df94 100644 --- a/packages/backend/src/server/api/mastodon/entities/application.ts +++ b/packages/backend/src/server/api/mastodon/entities/application.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/async_attachment.ts b/packages/backend/src/server/api/mastodon/entities/async_attachment.ts index 6fefabe03..32d01dded 100644 --- a/packages/backend/src/server/api/mastodon/entities/async_attachment.ts +++ b/packages/backend/src/server/api/mastodon/entities/async_attachment.ts @@ -1,14 +1,14 @@ /// 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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/attachment.ts b/packages/backend/src/server/api/mastodon/entities/attachment.ts index 7dfed4fd7..3cdc67b76 100644 --- a/packages/backend/src/server/api/mastodon/entities/attachment.ts +++ b/packages/backend/src/server/api/mastodon/entities/attachment.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/card.ts b/packages/backend/src/server/api/mastodon/entities/card.ts index b1c12becd..75d3ba843 100644 --- a/packages/backend/src/server/api/mastodon/entities/card.ts +++ b/packages/backend/src/server/api/mastodon/entities/card.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/context.ts b/packages/backend/src/server/api/mastodon/entities/context.ts index 918e6ff66..401257caa 100644 --- a/packages/backend/src/server/api/mastodon/entities/context.ts +++ b/packages/backend/src/server/api/mastodon/entities/context.ts @@ -1,8 +1,8 @@ /// namespace MastodonEntity { - export type Context = { - ancestors: Array; - descendants: Array; - }; + export type Context = { + ancestors: Array; + descendants: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/conversation.ts b/packages/backend/src/server/api/mastodon/entities/conversation.ts index 3822a4355..81399d91a 100644 --- a/packages/backend/src/server/api/mastodon/entities/conversation.ts +++ b/packages/backend/src/server/api/mastodon/entities/conversation.ts @@ -2,10 +2,10 @@ /// namespace MastodonEntity { - export type Conversation = { - id: string; - accounts: Array; - last_status: Status | null; - unread: boolean; - }; + export type Conversation = { + id: string; + accounts: Array; + last_status: Status | null; + unread: boolean; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/emoji.ts b/packages/backend/src/server/api/mastodon/entities/emoji.ts index cbb800ffa..04c135d23 100644 --- a/packages/backend/src/server/api/mastodon/entities/emoji.ts +++ b/packages/backend/src/server/api/mastodon/entities/emoji.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/featured_tag.ts b/packages/backend/src/server/api/mastodon/entities/featured_tag.ts index 1d575aa1b..5b3f9654a 100644 --- a/packages/backend/src/server/api/mastodon/entities/featured_tag.ts +++ b/packages/backend/src/server/api/mastodon/entities/featured_tag.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/field.ts b/packages/backend/src/server/api/mastodon/entities/field.ts index 0dfb8bfad..ed9ec03b0 100644 --- a/packages/backend/src/server/api/mastodon/entities/field.ts +++ b/packages/backend/src/server/api/mastodon/entities/field.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/filter.ts b/packages/backend/src/server/api/mastodon/entities/filter.ts index ba48a8d57..d2f7d573a 100644 --- a/packages/backend/src/server/api/mastodon/entities/filter.ts +++ b/packages/backend/src/server/api/mastodon/entities/filter.ts @@ -1,12 +1,12 @@ namespace MastodonEntity { - export type Filter = { - id: string; - phrase: string; - context: Array; - expires_at: string | null; - irreversible: boolean; - whole_word: boolean; - }; + export type Filter = { + id: string; + phrase: string; + context: Array; + expires_at: string | null; + irreversible: boolean; + whole_word: boolean; + }; - export type FilterContext = string; + export type FilterContext = string; } diff --git a/packages/backend/src/server/api/mastodon/entities/history.ts b/packages/backend/src/server/api/mastodon/entities/history.ts index 5ef617584..ddb0d9df0 100644 --- a/packages/backend/src/server/api/mastodon/entities/history.ts +++ b/packages/backend/src/server/api/mastodon/entities/history.ts @@ -1,7 +1,7 @@ namespace MastodonEntity { - export type History = { - day: string; - uses: number; - accounts: number; - }; + export type History = { + day: string; + uses: number; + accounts: number; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/identity_proof.ts b/packages/backend/src/server/api/mastodon/entities/identity_proof.ts index 4dcdf67f7..07a4e33d0 100644 --- a/packages/backend/src/server/api/mastodon/entities/identity_proof.ts +++ b/packages/backend/src/server/api/mastodon/entities/identity_proof.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/instance.ts b/packages/backend/src/server/api/mastodon/entities/instance.ts index 8b7046c09..0d0e4f774 100644 --- a/packages/backend/src/server/api/mastodon/entities/instance.ts +++ b/packages/backend/src/server/api/mastodon/entities/instance.ts @@ -3,39 +3,39 @@ /// namespace MastodonEntity { - export type Instance = { - uri: string; - title: string; - description: string; - email: string; - version: string; - thumbnail: string | null; - urls: URLs; - stats: Stats; - languages: Array; - 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; - 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; + 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; + 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; + }; + }; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/list.ts b/packages/backend/src/server/api/mastodon/entities/list.ts index 72a93c79f..c82765696 100644 --- a/packages/backend/src/server/api/mastodon/entities/list.ts +++ b/packages/backend/src/server/api/mastodon/entities/list.ts @@ -1,6 +1,6 @@ namespace MastodonEntity { - export type List = { - id: string; - title: string; - }; + export type List = { + id: string; + title: string; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/marker.ts b/packages/backend/src/server/api/mastodon/entities/marker.ts index ad67b84bb..7ee1bb44c 100644 --- a/packages/backend/src/server/api/mastodon/entities/marker.ts +++ b/packages/backend/src/server/api/mastodon/entities/marker.ts @@ -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; + }; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/mention.ts b/packages/backend/src/server/api/mastodon/entities/mention.ts index 90ada1869..5c9e8e492 100644 --- a/packages/backend/src/server/api/mastodon/entities/mention.ts +++ b/packages/backend/src/server/api/mastodon/entities/mention.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/notification.ts b/packages/backend/src/server/api/mastodon/entities/notification.ts index e4041d8a6..753317eca 100644 --- a/packages/backend/src/server/api/mastodon/entities/notification.ts +++ b/packages/backend/src/server/api/mastodon/entities/notification.ts @@ -2,14 +2,22 @@ /// 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'; } diff --git a/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts b/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts index 3ef1cd666..694105ab3 100644 --- a/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts +++ b/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts @@ -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 */ diff --git a/packages/backend/src/server/api/mastodon/entities/poll.ts b/packages/backend/src/server/api/mastodon/entities/poll.ts index 079486046..1183ddab6 100644 --- a/packages/backend/src/server/api/mastodon/entities/poll.ts +++ b/packages/backend/src/server/api/mastodon/entities/poll.ts @@ -1,14 +1,14 @@ /// namespace MastodonEntity { - export type Poll = { - id: string; - expires_at: string | null; - expired: boolean; - multiple: boolean; - votes_count: number; - options: Array; - voted: boolean; - own_votes: Array; - }; + export type Poll = { + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: Array; + voted: boolean; + own_votes: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/poll_option.ts b/packages/backend/src/server/api/mastodon/entities/poll_option.ts index 8ee847a52..ac197b80b 100644 --- a/packages/backend/src/server/api/mastodon/entities/poll_option.ts +++ b/packages/backend/src/server/api/mastodon/entities/poll_option.ts @@ -1,6 +1,6 @@ namespace MastodonEntity { - export type PollOption = { - title: string; - votes_count: number | null; - }; + export type PollOption = { + title: string; + votes_count: number | null; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/preferences.ts b/packages/backend/src/server/api/mastodon/entities/preferences.ts index 752d3900f..e0b854622 100644 --- a/packages/backend/src/server/api/mastodon/entities/preferences.ts +++ b/packages/backend/src/server/api/mastodon/entities/preferences.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/push_subscription.ts b/packages/backend/src/server/api/mastodon/entities/push_subscription.ts index a12b35f63..379ecffc5 100644 --- a/packages/backend/src/server/api/mastodon/entities/push_subscription.ts +++ b/packages/backend/src/server/api/mastodon/entities/push_subscription.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/reaction.ts b/packages/backend/src/server/api/mastodon/entities/reaction.ts index 34741d41f..79e18cdbc 100644 --- a/packages/backend/src/server/api/mastodon/entities/reaction.ts +++ b/packages/backend/src/server/api/mastodon/entities/reaction.ts @@ -1,12 +1,12 @@ /// namespace MastodonEntity { - export type Reaction = { - count: number; - me: boolean; - name: string; - url?: string; - static_url?: string; - accounts?: Array; - }; + export type Reaction = { + count: number; + me: boolean; + name: string; + url?: string; + static_url?: string; + accounts?: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/relationship.ts b/packages/backend/src/server/api/mastodon/entities/relationship.ts index bca2f4cba..f96b4ac1a 100644 --- a/packages/backend/src/server/api/mastodon/entities/relationship.ts +++ b/packages/backend/src/server/api/mastodon/entities/relationship.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/report.ts b/packages/backend/src/server/api/mastodon/entities/report.ts index 1f87a1349..22682ff89 100644 --- a/packages/backend/src/server/api/mastodon/entities/report.ts +++ b/packages/backend/src/server/api/mastodon/entities/report.ts @@ -1,9 +1,9 @@ namespace MastodonEntity { - export type Report = { - id: string; - action_taken: string; - comment: string; - account_id: string; - status_ids: Array; - }; + export type Report = { + id: string; + action_taken: string; + comment: string; + account_id: string; + status_ids: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/results.ts b/packages/backend/src/server/api/mastodon/entities/results.ts index 2b4cc447c..f9f6768b9 100644 --- a/packages/backend/src/server/api/mastodon/entities/results.ts +++ b/packages/backend/src/server/api/mastodon/entities/results.ts @@ -3,9 +3,9 @@ /// namespace MastodonEntity { - export type Search = { - accounts: Array; - statuses: Array; - hashtags: Array; - }; + export type Search = { + accounts: Array; + statuses: Array; + hashtags: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts b/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts index 1613adbfc..362228ac0 100644 --- a/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts +++ b/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts @@ -1,10 +1,10 @@ /// /// namespace MastodonEntity { - export type ScheduledStatus = { - id: string; - scheduled_at: string; - params: StatusParams; - media_attachments: Array; - }; + export type ScheduledStatus = { + id: string; + scheduled_at: string; + params: StatusParams; + media_attachments: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/source.ts b/packages/backend/src/server/api/mastodon/entities/source.ts index a0ac1b869..29dcffbed 100644 --- a/packages/backend/src/server/api/mastodon/entities/source.ts +++ b/packages/backend/src/server/api/mastodon/entities/source.ts @@ -1,10 +1,10 @@ /// namespace MastodonEntity { - export type Source = { - privacy: string | null; - sensitive: boolean | null; - language: string | null; - note: string; - fields: Array; - }; + export type Source = { + privacy: string | null; + sensitive: boolean | null; + language: string | null; + note: string; + fields: Array; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/stats.ts b/packages/backend/src/server/api/mastodon/entities/stats.ts index fdb517d22..827b17892 100644 --- a/packages/backend/src/server/api/mastodon/entities/stats.ts +++ b/packages/backend/src/server/api/mastodon/entities/stats.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/status.ts b/packages/backend/src/server/api/mastodon/entities/status.ts index b6112c64c..1eba560c0 100644 --- a/packages/backend/src/server/api/mastodon/entities/status.ts +++ b/packages/backend/src/server/api/mastodon/entities/status.ts @@ -9,67 +9,67 @@ /// 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; - mentions: Array; - tags: Array; - card: Card | null; - poll: Poll | null; - application: Application | null; - language: string | null; - pinned: boolean | undefined; - reactions: Array; - 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; + mentions: Array; + tags: Array; + card: Card | null; + poll: Poll | null; + application: Application | null; + language: string | null; + pinned: boolean | undefined; + reactions: Array; + 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 + } } diff --git a/packages/backend/src/server/api/mastodon/entities/status_edit.ts b/packages/backend/src/server/api/mastodon/entities/status_edit.ts index dfffa8e4e..595895459 100644 --- a/packages/backend/src/server/api/mastodon/entities/status_edit.ts +++ b/packages/backend/src/server/api/mastodon/entities/status_edit.ts @@ -9,14 +9,14 @@ /// namespace MastodonEntity { - export type StatusEdit = { - account: Account; - content: string; - created_at: string; - emojis: Emoji[]; - sensitive: boolean; - spoiler_text: string; - media_attachments: Array; - poll: Poll | null; - }; + export type StatusEdit = { + account: Account; + content: string; + created_at: string; + emojis: Emoji[]; + sensitive: boolean; + spoiler_text: string; + media_attachments: Array; + poll: Poll | null; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/status_params.ts b/packages/backend/src/server/api/mastodon/entities/status_params.ts index 58338b69e..8d44d2545 100644 --- a/packages/backend/src/server/api/mastodon/entities/status_params.ts +++ b/packages/backend/src/server/api/mastodon/entities/status_params.ts @@ -1,12 +1,12 @@ namespace MastodonEntity { - export type StatusParams = { - text: string; - in_reply_to_id: string | null; - media_ids: Array | 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 | null; + sensitive: boolean | null; + spoiler_text: string | null; + visibility: "public" | "unlisted" | "private" | "direct"; + scheduled_at: string | null; + application_id: string; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/status_source.ts b/packages/backend/src/server/api/mastodon/entities/status_source.ts index 04758f599..c73cdbf51 100644 --- a/packages/backend/src/server/api/mastodon/entities/status_source.ts +++ b/packages/backend/src/server/api/mastodon/entities/status_source.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/tag.ts b/packages/backend/src/server/api/mastodon/entities/tag.ts index 1a29e67cc..3ef002820 100644 --- a/packages/backend/src/server/api/mastodon/entities/tag.ts +++ b/packages/backend/src/server/api/mastodon/entities/tag.ts @@ -1,10 +1,10 @@ /// namespace MastodonEntity { - export type Tag = { - name: string; - url: string; - history: Array | null; - following?: boolean; - }; + export type Tag = { + name: string; + url: string; + history: Array | null; + following?: boolean; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/token.ts b/packages/backend/src/server/api/mastodon/entities/token.ts index 87011b812..08a680395 100644 --- a/packages/backend/src/server/api/mastodon/entities/token.ts +++ b/packages/backend/src/server/api/mastodon/entities/token.ts @@ -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; + }; } diff --git a/packages/backend/src/server/api/mastodon/entities/urls.ts b/packages/backend/src/server/api/mastodon/entities/urls.ts index cedc94b45..c391ef99a 100644 --- a/packages/backend/src/server/api/mastodon/entities/urls.ts +++ b/packages/backend/src/server/api/mastodon/entities/urls.ts @@ -1,5 +1,5 @@ namespace MastodonEntity { - export type URLs = { - streaming_api: string; - }; + export type URLs = { + streaming_api: string; + }; } diff --git a/packages/backend/src/server/api/mastodon/helpers/list.ts b/packages/backend/src/server/api/mastodon/helpers/list.ts index cb5bff39b..299791534 100644 --- a/packages/backend/src/server/api/mastodon/helpers/list.ts +++ b/packages/backend/src/server/api/mastodon/helpers/list.ts @@ -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 { - 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 { + 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 { - 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 { + 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> { - 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> { + 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], + }; + }); + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/media.ts b/packages/backend/src/server/api/mastodon/helpers/media.ts index 9760a5ded..0bdc0cc42 100644 --- a/packages/backend/src/server/api/mastodon/helpers/media.ts +++ b/packages/backend/src/server/api/mastodon/helpers/media.ts @@ -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> { - 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> { + 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> { - await DriveFiles.update(file.id, { - comment: body?.description ?? undefined - }); + public static async updateMedia(user: ILocalUser, file: DriveFile, body: any): Promise> { + 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 | null> { - return this.getMedia(user, id) - .then(p => p ? DriveFiles.pack(p) : null); - } + public static async getMediaPacked(user: ILocalUser, id: string): Promise | null> { + return this.getMedia(user, id) + .then(p => p ? DriveFiles.pack(p) : null); + } - public static async getMedia(user: ILocalUser, id: string): Promise { - return DriveFiles.findOneBy({id: id, userId: user.id}); - } + public static async getMedia(user: ILocalUser, id: string): Promise { + return DriveFiles.findOneBy({id: id, userId: user.id}); + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts index 7cbd403ec..034a8a067 100644 --- a/packages/backend/src/server/api/mastodon/helpers/note.ts +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -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 { - return Metas.createQueryBuilder() - .select('"defaultReaction"') - .execute() - .then(p => p[0].defaultReaction); - } + public static async getDefaultReaction(): Promise { + return Metas.createQueryBuilder() + .select('"defaultReaction"') + .execute() + .then(p => p[0].defaultReaction); + } - public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise { - await createReaction(user, note, reaction); - return getNote(note.id, user); - } + public static async reactToNote(note: Note, user: ILocalUser, reaction: string): Promise { + await createReaction(user, note, reaction); + return getNote(note.id, user); + } - public static async removeReactFromNote(note: Note, user: ILocalUser): Promise { - await deleteReaction(user, note); - return getNote(note.id, user); - } + public static async removeReactFromNote(note: Note, user: ILocalUser): Promise { + await deleteReaction(user, note); + return getNote(note.id, user); + } - public static async reblogNote(note: Note, user: ILocalUser): Promise { - const data = { - createdAt: new Date(), - files: [], - renote: note - }; - return await createNote(user, data); - } + public static async reblogNote(note: Note, user: ILocalUser): Promise { + const data = { + createdAt: new Date(), + files: [], + renote: note + }; + return await createNote(user, data); + } - public static async unreblogNote(note: Note, user: ILocalUser): Promise { - 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 { + 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 { - const bookmarked = await NoteFavorites.exist({ - where: { - noteId: note.id, - userId: user.id, - }, - }); + public static async bookmarkNote(note: Note, user: ILocalUser): Promise { + 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 { - 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 { + 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 { - const pinned = await UserNotePinings.exist({ - where: { - userId: user.id, - noteId: note.id - } - }); + public static async pinNote(note: Note, user: ILocalUser): Promise { + 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 { - const pinned = await UserNotePinings.exist({ - where: { - userId: user.id, - noteId: note.id - } - }); + public static async unpinNote(note: Note, user: ILocalUser): Promise { + 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 { - 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 { + 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> { - 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> { + 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 { - 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[] = []; - if (edits.length < 1) return []; + public static async getNoteEditHistory(note: Note): Promise { + 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[] = []; + 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> { - 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> { + 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 { - 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 { + 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 { - const notes = new Array; - for (let i = 0; i < limit; i++) { - const currentNote = notes.at(-1) ?? rootNote; - if (!currentNote.replyId) break; - const nextNote = await getNote(currentNote.replyId, user).catch((e) => { - if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") return null; - throw e; - }); - if (nextNote && await Notes.isVisibleForMe(nextNote, user?.id ?? null)) notes.push(nextNote); - else break; - } + public static async getNoteAncestors(rootNote: Note, user: ILocalUser | null, limit: number = 10): Promise { + const notes = new Array; + for (let i = 0; i < limit; i++) { + const currentNote = notes.at(-1) ?? rootNote; + if (!currentNote.replyId) break; + const nextNote = await getNote(currentNote.replyId, user).catch((e) => { + if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") return null; + throw e; + }); + if (nextNote && await Notes.isVisibleForMe(nextNote, user?.id ?? null)) notes.push(nextNote); + else break; + } - return notes.reverse(); - } + return notes.reverse(); + } - public static async createNote(request: MastodonEntity.StatusCreationRequest, user: ILocalUser): Promise { - 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 { + 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 { - 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 { + 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 { - return extractMentionedUsers(user, mfm.parse(text)!); - } + public static async extractMentions(text: string, user: ILocalUser): Promise { + 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(subject: T | T[]) { - return Array.isArray(subject) ? subject : [subject]; - } + public static normalizeToArray(subject: T | T[]) { + return Array.isArray(subject) ? subject : [subject]; + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/notification.ts b/packages/backend/src/server/api/mastodon/helpers/notification.ts index 64e479883..a4e5db16d 100644 --- a/packages/backend/src/server/api/mastodon/helpers/notification.ts +++ b/packages/backend/src/server/api/mastodon/helpers/notification.ts @@ -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 { 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 { - return Notifications.findOneBy({id: id, notifieeId: user.id}); - } + public static async getNotification(id: string, user: ILocalUser): Promise { + return Notifications.findOneBy({id: id, notifieeId: user.id}); + } - public static async dismissNotification(id: string, user: ILocalUser): Promise { - const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true}); - } + public static async dismissNotification(id: string, user: ILocalUser): Promise { + const result = await Notifications.update({id: id, notifieeId: user.id}, {isRead: true}); + } - public static async clearAllNotifications(user: ILocalUser): Promise { - await Notifications.update({notifieeId: user.id}, {isRead: true}); - } + public static async clearAllNotifications(user: ILocalUser): Promise { + 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; + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/pagination.ts b/packages/backend/src/server/api/mastodon/helpers/pagination.ts index a5a7d142e..aae35b669 100644 --- a/packages/backend/src/server/api/mastodon/helpers/pagination.ts +++ b/packages/backend/src/server/api/mastodon/helpers/pagination.ts @@ -3,81 +3,82 @@ import config from "@/config/index.js"; import { convertId, IdType } from "../../index.js"; export class PaginationHelpers { - public static makePaginationQuery( - q: SelectQueryBuilder, - 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( + q: SelectQueryBuilder, + 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(query: SelectQueryBuilder, limit: number, reverse: boolean): Promise { - // 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(query: SelectQueryBuilder, limit: number, reverse: boolean): Promise { + // 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(', ')); + } + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/poll.ts b/packages/backend/src/server/api/mastodon/helpers/poll.ts index bc472f0f0..87d0e3989 100644 --- a/packages/backend/src/server/api/mastodon/helpers/poll.ts +++ b/packages/backend/src/server/api/mastodon/helpers/poll.ts @@ -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'); diff --git a/packages/backend/src/server/api/mastodon/helpers/search.ts b/packages/backend/src/server/api/mastodon/helpers/search.ts index 3224718b3..d2ba06681 100644 --- a/packages/backend/src/server/api/mastodon/helpers/search.ts +++ b/packages/backend/src/server/api/mastodon/helpers/search.ts @@ -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 { - 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 { + 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 { - 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 { + 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(/^@?(?[a-zA-Z0-9_]+)@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)$/); - if (!match) match = q.match(/^@(?[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(/^@?(?[a-zA-Z0-9_]+)@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)$/); + if (!match) match = q.match(/^@(?[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 { - 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 { + 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 { - 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 { + 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 + }; + })); + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts index a09883a08..de899d0ce 100644 --- a/packages/backend/src/server/api/mastodon/helpers/timeline.ts +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -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 { - if (limit > 40) limit = 40; + public static async getHomeTimeline(user: ILocalUser, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20): Promise { + 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 { - 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 { + 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); + } } diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts index 24014872b..59fef45b1 100644 --- a/packages/backend/src/server/api/mastodon/helpers/user.ts +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -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 = { - 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 { - //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 { - 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 { - 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 { - 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 { - //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 { - 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 { - 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 { - 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 { - //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 { - 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 { - 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> { - 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> { - 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> { - 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 { - 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> { - 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> { - 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> { - 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> { - 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> { - return this.getUserRelationships('following', user, localUser, maxId, sinceId, minId, limit); - } - - public static async getUserRelationhipToMany(targetIds: string[], localUserId: string): Promise { - return Promise.all(targetIds.map(targetId => this.getUserRelationshipTo(targetId, localUserId))); - } - - public static async getUserRelationshipTo(targetId: string, localUserId: string): Promise { - 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 { - 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 { - 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 { + //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 { + 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 { + 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 { + 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 { + //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 { + 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 { + 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 { + 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 { + //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 { + 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 { + 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> { + 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> { + 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> { + 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 { + 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> { + 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> { + 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> { + 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> { + 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> { + return this.getUserRelationships('following', user, localUser, maxId, sinceId, minId, limit); + } + + public static async getUserRelationhipToMany(targetIds: string[], localUserId: string): Promise { + return Promise.all(targetIds.map(targetId => this.getUserRelationshipTo(targetId, localUserId))); + } + + public static async getUserRelationshipTo(targetId: string, localUserId: string): Promise { + 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 { + 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 { + return RegistryItems.findOneBy({domain: IsNull(), userId: user.id, key: 'defaultNoteVisibility', scope: '{client,base}'}).then(p => p?.value ?? 'public') + } } diff --git a/packages/backend/src/server/api/mastodon/index.ts b/packages/backend/src/server/api/mastodon/index.ts index 2929c4948..c172b83eb 100644 --- a/packages/backend/src/server/api/mastodon/index.ts +++ b/packages/backend/src/server/api/mastodon/index.ts @@ -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); }