diff --git a/package.json b/package.json index f46bd7ca29..c2f842a846 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "gulp": "gulp build", "watch": "pnpm run dev", "dev": "pnpm node ./scripts/dev.js", + "dev:staging": "NODE_OPTIONS=--max_old_space_size=3072 NODE_ENV=development pnpm run build && pnpm run start", "lint": "pnpm -r run lint", "cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts", "cy:run": "cypress run", diff --git a/packages/backend/package.json b/packages/backend/package.json index dda128ecaf..80484f95ce 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,6 +37,10 @@ "@tensorflow/tfjs": "^4.2.0", "ajv": "8.11.2", "archiver": "5.3.1", + "koa-body": "^6.0.1", + "autobind-decorator": "2.4.0", + "autolinker": "4.0.0", + "axios": "^1.3.2", "autwh": "0.1.0", "aws-sdk": "2.1277.0", "bcryptjs": "2.4.3", @@ -75,6 +79,7 @@ "koa-send": "5.0.1", "koa-slow": "2.1.0", "koa-views": "7.0.2", + "@cutls/megalodon": "5.1.15", "mfm-js": "0.23.2", "mime-types": "2.1.35", "multer": "1.4.4-lts.1", diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts index 573034f6b7..08b44788de 100644 --- a/packages/backend/src/misc/emoji-regex.ts +++ b/packages/backend/src/misc/emoji-regex.ts @@ -2,3 +2,4 @@ import twemoji from "twemoji-parser/dist/lib/regex.js"; const twemojiRegex = twemoji.default; export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); +export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts index 2bc3b90ca3..37c5031c0f 100644 --- a/packages/backend/src/models/repositories/note.ts +++ b/packages/backend/src/models/repositories/note.ts @@ -197,6 +197,8 @@ export const NoteRepository = db.getRepository(Note).extend({ .map((x) => decodeReaction(x).reaction) .map((x) => x.replace(/:/g, "")); + const noteEmoji = await populateEmojis(note.emojis.concat(reactionEmojiNames), host); + const reactionEmoji = await populateEmojis(reactionEmojiNames, host); const packed: Packed<"Note"> = await awaitAll({ id: note.id, createdAt: note.createdAt.toISOString(), @@ -213,8 +215,9 @@ export const NoteRepository = db.getRepository(Note).extend({ renoteCount: note.renoteCount, repliesCount: note.repliesCount, reactions: convertLegacyReactions(note.reactions), + reactionEmojis: reactionEmoji, + emojis: noteEmoji, tags: note.tags.length > 0 ? note.tags : undefined, - emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host), fileIds: note.fileIds, files: DriveFiles.packMany(note.fileIds), replyId: note.replyId, diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts index 4a7bd80fcd..6bc8443f0f 100644 --- a/packages/backend/src/models/schema/note.ts +++ b/packages/backend/src/models/schema/note.ts @@ -161,26 +161,8 @@ export const packedNoteSchema = { nullable: false, }, emojis: { - type: "array", - optional: false, - nullable: false, - items: { - type: "object", - optional: false, - nullable: false, - properties: { - name: { - type: "string", - optional: false, - nullable: false, - }, - url: { - type: "string", - optional: false, - nullable: true, - }, - }, - }, + type: 'object', + optional: true, nullable: true, }, reactions: { type: "object", diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index b6d9c3e1fc..353f137a76 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -198,6 +198,7 @@ import * as ep___i_readAnnouncement from "./endpoints/i/read-announcement.js"; import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js"; import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js"; import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js"; +import * as ep___i_registry_getUnsecure from './endpoints/i/registry/get-unsecure.js'; import * as ep___i_registry_get from "./endpoints/i/registry/get.js"; import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js"; import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js"; @@ -538,6 +539,7 @@ const eps = [ ["i/regenerate-token", ep___i_regenerateToken], ["i/registry/get-all", ep___i_registry_getAll], ["i/registry/get-detail", ep___i_registry_getDetail], + ["i/registry/get-unsecure", ep___i_registry_getUnsecure], ["i/registry/get", ep___i_registry_get], ["i/registry/keys-with-type", ep___i_registry_keysWithType], ["i/registry/keys", ep___i_registry_keys], @@ -765,17 +767,17 @@ export interface IEndpointMeta { } export interface IEndpoint { - name: string; - exec: any; - meta: IEndpointMeta; - params: Schema; + name: string, + exec: any, // TODO: may be obosolete @ThatOneCalculator + meta: IEndpointMeta, + params: Schema, } -const endpoints: IEndpoint[] = eps.map(([name, ep]) => { +const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { return { name: name, exec: ep.default, - meta: ep.meta || {}, + meta: ep.meta ?? {}, params: ep.paramDef, }; }); diff --git a/packages/backend/src/server/api/endpoints/i/get-unsecure.ts b/packages/backend/src/server/api/endpoints/i/get-unsecure.ts new file mode 100644 index 0000000000..eef7f5eca5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/get-unsecure.ts @@ -0,0 +1,50 @@ +import { ApiError } from "../../error.js"; +import define from "../../define.js"; +import { Items } from "@/"; + +export const meta = { + requireCredential: true, + + secure: false, + + errors: { + noSuchKey: { + message: "No such key.", + code: "NO_SUCH_KEY", + id: "ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + key: { type: "string" }, + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: ["key"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (ps.key !== "reactions") return; + const query = Items.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.key = :key", { key: ps.key }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; +}); diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index b84bbdbb39..da98a9df19 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -7,6 +7,7 @@ import Router from "@koa/router"; import multer from "@koa/multer"; import bodyParser from "koa-bodyparser"; import cors from "@koa/cors"; +import { apiMastodonCompatible } from './mastodon/ApiMastodonCompatibleService.js'; import { Instances, AccessTokens, Users } from "@/models/index.js"; import config from "@/config/index.js"; import endpoints from "./endpoints.js"; @@ -57,6 +58,8 @@ const upload = multer({ // Init router const router = new Router(); +apiMastodonCompatible(router); + /** * Register endpoint handlers */ diff --git a/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts b/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts new file mode 100644 index 0000000000..57a86c96d2 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts @@ -0,0 +1,58 @@ +import Router from "@koa/router"; +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import { apiAuthMastodon } from './endpoints/auth.js'; +import { apiAccountMastodon } from './endpoints/account.js'; +import { apiStatusMastodon } from './endpoints/status.js'; +import { apiFilterMastodon } from './endpoints/filter.js'; +import { apiTimelineMastodon } from './endpoints/timeline.js'; +import { apiNotificationsMastodon } from './endpoints/notifications.js'; +import { apiSearchMastodon } from './endpoints/search.js'; +import { getInstance } from './endpoints/meta.js'; + +export function getClient(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('misskey', BASE_URL, accessToken) as MegalodonInterface; + return client +} + +export function apiMastodonCompatible(router: Router): void { + apiAuthMastodon(router) + apiAccountMastodon(router) + apiStatusMastodon(router) + apiFilterMastodon(router) + apiTimelineMastodon(router) + apiNotificationsMastodon(router) + apiSearchMastodon(router) + + 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(); + ctx.body = getInstance(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/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts new file mode 100644 index 0000000000..1b55a5fbd3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -0,0 +1,323 @@ +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import { getClient } from '../ApiMastodonCompatibleService.js'; +import { toLimitToInt } from './timeline.js'; + +export function apiAccountMastodon(router: Router): void { + + router.get('/v1/accounts/verify_credentials', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.verifyAccountCredentials(); + const acct = data.data; + acct.url = `${BASE_URL}/@${acct.url}` + acct.note = '' + acct.avatar_static = acct.avatar + acct.header = acct.header || '' + acct.header_static = acct.header || '' + acct.source = { + note: acct.note, + fields: acct.fields, + privacy: 'public', + sensitive: false, + language: '' + } + ctx.body = 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', koaBody(), 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.updateCredentials((ctx.request as any).body as any); + 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/accounts/:id', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccount(ctx.params.id); + 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/accounts/:id/statuses', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountStatuses(ctx.params.id, toLimitToInt(ctx.query as any)); + 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/accounts/:id/followers', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountFollowers(ctx.params.id, ctx.query as any); + 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/accounts/:id/following', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountFollowing(ctx.params.id, ctx.query as any); + 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/accounts/:id/lists', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountLists(ctx.params.id); + ctx.body = data.data; + } 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, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.followAccount(ctx.params.id); + const acct = data.data; + acct.following = true; + ctx.body = data.data; + } 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, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unfollowAccount(ctx.params.id); + const acct = data.data; + acct.following = false; + ctx.body = data.data; + } 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, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.blockAccount(ctx.params.id); + ctx.body = data.data; + } 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, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unblockAccount(ctx.params.id); + ctx.body = data.data; + } 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', koaBody(), 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.muteAccount(ctx.params.id, (ctx.request as any).body as any); + ctx.body = data.data; + } 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, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unmuteAccount(ctx.params.id); + 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/accounts/relationships', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const idsRaw = (ctx.query as any)['id[]'] + const ids = typeof idsRaw === 'string' ? [idsRaw] : idsRaw + const data = await client.getRelationships(ids) as any; + 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, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getBookmarks(ctx.query as any) as any; + 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/favourites', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFavourites(ctx.query as any); + 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/mutes', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getMutes(ctx.query as any); + 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/blocks', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getBlocks(ctx.query as any); + 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/follow_ctxs', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getFollowRequests((ctx.query as any || { limit: 20 }).limit); + ctx.body = data.data; + } 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_ctxs/:id/authorize', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.acceptFollowRequest(ctx.params.id); + ctx.body = data.data; + } 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_ctxs/:id/reject', async (ctx, next) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.rejectFollowRequest(ctx.params.id); + 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/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts new file mode 100644 index 0000000000..5f5756077f --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -0,0 +1,81 @@ +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import { getClient } from '../ApiMastodonCompatibleService.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' +] +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' +] + +export function apiAuthMastodon(router: Router): void { + + router.post('/v1/apps', koaBody(), 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 { + let scope = body.scopes + console.log(body) + 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) + + let red = body.redirect_uris + if (red === 'urn:ietf:wg:oauth:2.0:oob') { + red = 'https://thedesk.top/hello.html' + } + const appData = await client.registerApp(body.client_name, { scopes: scopeArr, redirect_uris: red, website: body.website }); + ctx.body = { + id: appData.id, + name: appData.name, + website: appData.website, + redirect_uri: red, + client_id: Buffer.from(appData.url || '').toString('base64'), + client_secret: appData.clientSecret, + } + } 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 new file mode 100644 index 0000000000..3c66362dd5 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -0,0 +1,83 @@ +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import { getClient } from '../ApiMastodonCompatibleService.js'; + +export function apiFilterMastodon(router: Router): void { + + router.get('/v1/filters', koaBody(), 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; + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.get('/v1/filters/:id', koaBody(), 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(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.post('/v1/filters', koaBody(), 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 = data.data; + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.post('/v1/filters/:id', koaBody(), 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(ctx.params.id, body.phrase, body.context); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.delete('/v1/filters/:id', koaBody(), 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(ctx.params.id); + 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/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts new file mode 100644 index 0000000000..3496272b9e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -0,0 +1,97 @@ +import { Entity } from "@cutls/megalodon"; +// TODO: add calckey features +export function getInstance(response: Entity.Instance) { + return { + uri: response.uri, + title: response.title || "", + short_description: response.description || "", + description: response.description || "", + email: response.email || "", + version: "3.0.0 compatible (Calckey)", + urls: response.urls, + stats: response.stats, + thumbnail: response.thumbnail || "", + languages: ["en", "de", "ja"], + registrations: response.registrations, + approval_required: !response.registrations, + invites_enabled: response.registrations, + configuration: { + accounts: { + max_featured_tags: 20, + }, + statuses: { + max_characters: 3000, + max_media_attachments: 4, + characters_reserved_per_url: response.uri.length, + }, + media_attachments: { + supported_mime_types: [ + "image/jpeg", + "image/png", + "image/gif", + "image/heic", + "image/heif", + "image/webp", + "image/avif", + "video/webm", + "video/mp4", + "video/quicktime", + "video/ogg", + "audio/wave", + "audio/wav", + "audio/x-wav", + "audio/x-pn-wave", + "audio/vnd.wave", + "audio/ogg", + "audio/vorbis", + "audio/mpeg", + "audio/mp3", + "audio/webm", + "audio/flac", + "audio/aac", + "audio/m4a", + "audio/x-m4a", + "audio/mp4", + "audio/3gpp", + "video/x-ms-asf", + ], + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_rate_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 8, + max_characters_per_option: 50, + min_expiration: 300, + max_expiration: 2629746, + }, + }, + contact_account: { + id: "1", + username: "admin", + acct: "admin", + display_name: "admin", + locked: true, + bot: true, + discoverable: false, + group: false, + created_at: "1971-01-01T00:00:00.000Z", + note: "", + url: "https://http.cat/404", + avatar: "https://http.cat/404", + avatar_static: "https://http.cat/404", + header: "https://http.cat/404", + header_static: "https://http.cat/404", + followers_count: -1, + following_count: 0, + statuses_count: 0, + last_status_at: "1971-01-01T00:00:00.000Z", + noindex: true, + emojis: [], + fields: [], + }, + rules: [], + }; +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts new file mode 100644 index 0000000000..638f0d2d41 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -0,0 +1,89 @@ +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import { getClient } from '../ApiMastodonCompatibleService.js'; +import { toTextWithReaction } from './timeline.js'; +function toLimitToInt(q: any) { + if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10) + return q +} + +export function apiNotificationMastodon(router: Router): void { + + router.get('/v1/notifications', koaBody(), 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.getNotifications(toLimitToInt(ctx.query)); + const notfs = data.data; + const ret = notfs.map((n) => { + if(n.type !== 'follow' && n.type !== 'follow_request') { + if (n.type === 'reaction') n.type = 'favourite' + n.status = toTextWithReaction(n.status ? [n.status] : [], ctx.hostname)[0] + return n + } else { + return n + } + }) + ctx.body = ret; + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.get('/v1/notification/:id', koaBody(), 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 dataRaw = await client.getNotification(ctx.params.id); + const data = dataRaw.data; + if(data.type !== 'follow' && data.type !== 'follow_request') { + if (data.type === 'reaction') data.type = 'favourite' + ctx.body = toTextWithReaction([data as any], ctx.request.hostname)[0] + } else { + ctx.body = data + } + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.post('/v1/notifications/clear', koaBody(), 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.dismissNotifications(); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = 401; + ctx.body = e.response.data; + } + }); + + router.post('/v1/notification/:id/dismiss', koaBody(), 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.dismissNotification(ctx.params.id); + 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/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts new file mode 100644 index 0000000000..f87e199f5f --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -0,0 +1,25 @@ +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import { getClient } from '../ApiMastodonCompatibleService.js'; + +export function apiSearchMastodon(router: Router): void { + + router.get('/v1/search', koaBody(), 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 query: any = ctx.query + const type = query.type || '' + const data = await client.search(query.q, type, query); + 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/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts new file mode 100644 index 0000000000..593be10f93 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -0,0 +1,403 @@ +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import megalodon, { MegalodonInterface } from '@cutls/megalodon'; +import { getClient } from '../ApiMastodonCompatibleService.js'; +import fs from 'fs' +import { pipeline } from 'node:stream'; +import { promisify } from 'node:util'; +import { createTemp } from '@/misc/create-temp.js'; +import { emojiRegex, emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js'; +import axios from 'axios'; +const pump = promisify(pipeline); + +export function apiStatusMastodon(router: Router): void { + router.post('/v1/statuses', koaBody(), async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const body: any = ctx.request.body + const text = body.status + const removed = text.replace(/@\S+/g, '').replaceAll(' ', '') + const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed) + const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed) + if (body.in_reply_to_id && isDefaultEmoji || isCustomEmoji) { + const a = await client.createEmojiReaction(body.in_reply_to_id, removed) + ctx.body = a.data + } + if (body.in_reply_to_id && removed === '/unreact') { + try { + const id = body.in_reply_to_id + const post = await client.getStatus(id) + const react = post.data.emoji_reactions.filter((e) => e.me)[0].name + const data = await client.deleteEmojiReaction(id, react); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + } + if (!body.media_ids) body.media_ids = undefined + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined + const data = await client.postStatus(text, body); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.get<{ Params: { id: string } }>('/v1/statuses/: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.getStatus(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.delete<{ Params: { id: string } }>('/v1/statuses/: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.deleteStatus(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + interface IReaction { + id: string + createdAt: string + user: MisskeyEntity.User, + type: string + } + router.get<{ Params: { id: string } }>('/v1/statuses/:id/context', async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const id = ctx.params.id + const data = await client.getStatusContext(id, ctx.query as any); + const status = await client.getStatus(id); + const reactionsAxios = await axios.get(`${BASE_URL}/api/notes/reactions?noteId=${id}`) + const reactions: IReaction[] = reactionsAxios.data + const text = reactions.map((r) => `${r.type.replace('@.', '')} ${r.user.username}`).join('
') + data.data.descendants.unshift(statusModel(status.data.id, status.data.account.id, status.data.emojis, text)) + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getStatusRebloggedBy(ctx.params.id); + ctx.body = data.data; + } 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, reply) => { + ctx.body = [] + }); + router.post<{ Params: { id: string } }>('/v1/statuses/:id/favourite', async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const react = await getFirstReaction(BASE_URL, accessTokens); + try { + const a = await client.createEmojiReaction(ctx.params.id, react) as any; + //const data = await client.favouriteStatus(ctx.params.id) as any; + ctx.body = a.data; + } 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/statuses/:id/unfavourite', async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + const react = await getFirstReaction(BASE_URL, accessTokens); + try { + const data = await client.deleteEmojiReaction(ctx.params.id, react); + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.reblogStatus(ctx.params.id); + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unreblogStatus(ctx.params.id); + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.bookmarkStatus(ctx.params.id); + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unbookmarkStatus(ctx.params.id) as any; + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.pinStatus(ctx.params.id); + ctx.body = data.data; + } 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, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.unpinStatus(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.post('/v1/media', async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const multipartData = await ctx.file; + if (!multipartData) { + ctx.body = { error: 'No image' }; + return; + } + const [path] = await createTemp(); + await pump(multipartData.buffer, fs.createWriteStream(path)); + const image = fs.readFileSync(path); + const data = await client.uploadMedia(image); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.post('/v2/media', async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const multipartData = await ctx.file; + if (!multipartData) { + ctx.body = { error: 'No image' }; + return; + } + const [path] = await createTemp(); + await pump(multipartData.buffer, fs.createWriteStream(path)); + const image = fs.readFileSync(path); + const data = await client.uploadMedia(image); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.get<{ Params: { id: string } }>('/v1/media/: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.getMedia(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.put<{ Params: { id: string } }>('/v1/media/:id', koaBody(), 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.updateMedia(ctx.params.id, ctx.request.body as any); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.get<{ Params: { id: string } }>('/v1/polls/: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.getPoll(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.post<{ Params: { id: string } }>('/v1/polls/:id/votes', koaBody(), 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.votePoll(ctx.params.id, (ctx.request.body as any).choices); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + +} + +async function getFirstReaction(BASE_URL: string, accessTokens: string | undefined) { + const accessTokenArr = accessTokens?.split(' ') ?? [null]; + const accessToken = accessTokenArr[accessTokenArr.length - 1]; + let react = '๐Ÿ‘' + try { + const api = await axios.post(`${BASE_URL}/api/i/registry/get-unsecure`, { + scope: ['client', 'base'], + key: 'reactions', + i: accessToken + }) + const reactRaw = api.data + react = Array.isArray(reactRaw) ? api.data[0] : '๐Ÿ‘' + console.log(api.data) + return react + } catch (e) { + return react + } +} + +export function statusModel(id: string | null, acctId: string | null, emojis: MastodonEntity.Emoji[], content: string) { + const now = "1970-01-02T00:00:00.000Z" + return { + id: '9atm5frjhb', + uri: 'https://http.cat/404', // "" + url: 'https://http.cat/404', // "", + account: { + id: '9arzuvv0sw', + username: 'ReactionBot', + acct: 'ReactionBot', + display_name: 'ReactionOfThisPost', + locked: false, + created_at: now, + followers_count: 0, + following_count: 0, + statuses_count: 0, + note: '', + url: 'https://http.cat/404', + avatar: 'https://http.cat/404', + avatar_static: 'https://http.cat/404', + header: 'https://http.cat/404', // "" + header_static: 'https://http.cat/404', // "" + emojis: [], + fields: [], + moved: null, + bot: false, + }, + in_reply_to_id: id, + in_reply_to_account_id: acctId, + reblog: null, + content: `

${content}

`, + plain_content: null, + created_at: now, + emojis: emojis, + replies_count: 0, + reblogs_count: 0, + favourites_count: 0, + favourited: false, + reblogged: false, + muted: false, + sensitive: false, + spoiler_text: '', + visibility: 'public' as const, + media_attachments: [], + mentions: [], + tags: [], + card: null, + poll: null, + application: null, + language: null, + pinned: false, + emoji_reactions: [], + bookmarked: false, + quote: false, + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts new file mode 100644 index 0000000000..3fdb6ce881 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -0,0 +1,246 @@ +import Router from "@koa/router"; +import { koaBody } from 'koa-body'; +import megalodon, { Entity, MegalodonInterface } from '@cutls/megalodon'; +import { getClient } from '../ApiMastodonCompatibleService.js' +import { statusModel } from './status.js'; +import Autolinker from 'autolinker'; +import { ParsedUrlQuery } from "querystring"; + +export function toLimitToInt(q: ParsedUrlQuery) { + if (q.limit) if (typeof q.limit === 'string') q.limit = parseInt(q.limit, 10).toString() + return q +} + +export function toTextWithReaction(status: Entity.Status[], host: string) { + return status.map((t) => { + if (!t) return statusModel(null, null, [], 'no content') + if (!t.emoji_reactions) return t + if (t.reblog) t.reblog = toTextWithReaction([t.reblog], host)[0] + const reactions = t.emoji_reactions.map((r) => `${r.name.replace('@.', '')} (${r.count}${r.me ? "* " : ''})`); + //t.emojis = getEmoji(t.content, host) + t.content = `

${autoLinker(t.content, host)}

${reactions.join(', ')}

` + return t + }) +} +export function autoLinker(input: string, host: string) { + return Autolinker.link(input, { + hashtag: 'twitter', + mention: 'twitter', + email: false, + stripPrefix: false, + replaceFn : function (match) { + switch(match.type) { + case 'url': + return true + case 'mention': + console.log("Mention: ", match.getMention()); + console.log("Mention Service Name: ", match.getServiceName()); + return `@${match.getMention()}`; + case 'hashtag': + console.log("Hashtag: ", match.getHashtag()); + return `#${match.getHashtag()}`; + } + return false + } + } ); +} + +export function apiTimelineMastodon(router: Router): void { + router.get('/v1/timelines/public', async (ctx, reply) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = ctx.query + const data = query.local ? await client.getLocalTimeline(toLimitToInt(query)) : await client.getPublicTimeline(toLimitToInt(query)); + ctx.body = toTextWithReaction(data.data, ctx.hostname); + } 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, toLimitToInt(ctx.query)); + ctx.body = toTextWithReaction(data.data, ctx.hostname); + } 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/home', 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.getHomeTimeline(toLimitToInt(ctx.query)); + ctx.body = toTextWithReaction(data.data, ctx.hostname); + } 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(ctx.params.listId, toLimitToInt(ctx.query)); + ctx.body = toTextWithReaction(data.data, ctx.hostname); + } 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(toLimitToInt(ctx.query)); + 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/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.getLists(); + 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', 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.getList(ctx.params.id); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + console.error(e.response.data) + ctx.status = (401); + ctx.body = e.response.data; + } + }); + 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.query as any).title); + ctx.body = 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(ctx.params.id, ctx.query as any); + 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', 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(ctx.params.id); + 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) => { + const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const data = await client.getAccountsInList(ctx.params.id, ctx.query as any); + ctx.body = data.data; + } 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/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(ctx.params.id, (ctx.query as any).account_ids); + 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(ctx.params.id, (ctx.query as any).account_ids); + ctx.body = data.data; + } catch (e: any) { + console.error(e) + console.error(e.response.data) + ctx.status = (401); + ctx.body = e.response.data; + } + }); +} +function escapeHTML(str: string) { + if (!str) { + return '' + } + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} +function nl2br(str: string) { + if (!str) { + return '' + } + str = str.replace(/\r\n/g, '
') + str = str.replace(/(\n|\r)/g, '
') + return str +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 9675d184c8..5be8ab109e 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -24,6 +24,9 @@ import { readNotification } from "../common/read-notification.js"; import channels from "./channels/index.js"; import type Channel from "./channel.js"; import type { StreamEventEmitter, StreamMessages } from "./types.js"; +import { Converter } from "@cutls/megalodon"; +import { getClient } from "../mastodon/ApiMastodonCompatibleService.js"; +import { toTextWithReaction } from "../mastodon/endpoints/timeline.js"; /** * Main stream connection @@ -41,17 +44,27 @@ export default class Connection { private channels: Channel[] = []; private subscribingNotes: any = {}; private cachedNotes: Packed<"Note">[] = []; + private isMastodonCompatible: boolean = false; + private host: string; + private accessToken: string; + private currentSubscribe: string[][] = []; constructor( wsConnection: websocket.connection, subscriber: EventEmitter, user: User | null | undefined, token: AccessToken | null | undefined, + host: string, + accessToken: string, + prepareStream: string | undefined, ) { + console.log("constructor", prepareStream); this.wsConnection = wsConnection; this.subscriber = subscriber; if (user) this.user = user; if (token) this.token = token; + if (host) this.host = host; + if (accessToken) this.accessToken = accessToken; this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); this.onUserEvent = this.onUserEvent.bind(this); @@ -73,6 +86,13 @@ export default class Connection { this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); } + console.log("prepare", prepareStream); + if (prepareStream) { + this.onWsConnectionMessage({ + type: "utf8", + utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }), + }); + } } private onUserEvent(data: StreamMessages["user"]["payload"]) { @@ -125,58 +145,149 @@ export default class Connection { if (data.type !== "utf8") return; if (data.utf8Data == null) return; - let obj: Record; + let objs: Record[]; try { - obj = JSON.parse(data.utf8Data); + objs = [JSON.parse(data.utf8Data)]; } catch (e) { return; } + + const simpleObj = objs[0]; + if (simpleObj.stream) { + // is Mastodon Compatible + this.isMastodonCompatible = true; + if (simpleObj.type === "subscribe") { + let forSubscribe = []; + if (simpleObj.stream === "user") { + this.currentSubscribe.push(["user"]); + objs = [ + { + type: "connect", + body: { + channel: "main", + id: simpleObj.stream, + }, + }, + { + type: "connect", + body: { + channel: "homeTimeline", + id: simpleObj.stream, + }, + }, + ]; + const client = getClient(this.host, this.accessToken); + try { + const tl = await client.getHomeTimeline(); + for (const t of tl.data) forSubscribe.push(t.id); + } catch (e: any) { + console.log(e); + console.error(e.response.data); + } + } else if (simpleObj.stream === "public:local") { + this.currentSubscribe.push(["public:local"]); + objs = [ + { + type: "connect", + body: { + channel: "localTimeline", + id: simpleObj.stream, + }, + }, + ]; + const client = getClient(this.host, this.accessToken); + const tl = await client.getLocalTimeline(); + for (const t of tl.data) forSubscribe.push(t.id); + } else if (simpleObj.stream === "public") { + this.currentSubscribe.push(["public"]); + objs = [ + { + type: "connect", + body: { + channel: "globalTimeline", + id: simpleObj.stream, + }, + }, + ]; + const client = getClient(this.host, this.accessToken); + const tl = await client.getPublicTimeline(); + for (const t of tl.data) forSubscribe.push(t.id); + } else if (simpleObj.stream === "list") { + this.currentSubscribe.push(["list", simpleObj.list]); + objs = [ + { + type: "connect", + body: { + channel: "list", + id: simpleObj.stream, + params: { + listId: simpleObj.list, + }, + }, + }, + ]; + const client = getClient(this.host, this.accessToken); + const tl = await client.getListTimeline(simpleObj.list); + for (const t of tl.data) forSubscribe.push(t.id); + } + for (const s of forSubscribe) { + objs.push({ + type: "s", + body: { + id: s, + }, + }); + } + } + } - const { type, body } = obj; + for (const obj of objs) { + const { type, body } = obj; + console.log(type, body); + switch (type) { + case "readNotification": + this.onReadNotification(body); + break; + case "subNote": + this.onSubscribeNote(body); + break; + case "s": + this.onSubscribeNote(body); + break; // alias + case "sr": + this.onSubscribeNote(body); + this.readNote(body); + break; + case "unsubNote": + this.onUnsubscribeNote(body); + break; + case "un": + this.onUnsubscribeNote(body); + break; // alias + case "connect": + this.onChannelConnectRequested(body); + break; + case "disconnect": + this.onChannelDisconnectRequested(body); + break; + case "channel": + this.onChannelMessageRequested(body); + break; + case "ch": + this.onChannelMessageRequested(body); + break; // alias - switch (type) { - case "readNotification": - this.onReadNotification(body); - break; - case "subNote": - this.onSubscribeNote(body); - break; - case "s": - this.onSubscribeNote(body); - break; // alias - case "sr": - this.onSubscribeNote(body); - this.readNote(body); - break; - case "unsubNote": - this.onUnsubscribeNote(body); - break; - case "un": - this.onUnsubscribeNote(body); - break; // alias - case "connect": - this.onChannelConnectRequested(body); - break; - case "disconnect": - this.onChannelDisconnectRequested(body); - break; - case "channel": - this.onChannelMessageRequested(body); - break; - case "ch": - this.onChannelMessageRequested(body); - break; // alias - - // ๅ€‹ใ€…ใฎใƒใƒฃใƒณใƒใƒซใงใฏใชใใƒซใƒผใƒˆใƒฌใƒ™ใƒซใงใ“ใ‚Œใ‚‰ใฎใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’ๅ—ใ‘ๅ–ใ‚‹็†็”ฑใฏใ€ - // ใ‚ฏใƒฉใ‚คใ‚ขใƒณใƒˆใฎไบ‹ๆƒ…ใ‚’่€ƒๆ…ฎใ—ใŸใจใใ€ๅ…ฅๅŠ›ใƒ•ใ‚ฉใƒผใƒ ใฏใƒŽใƒผใƒˆใƒใƒฃใƒณใƒใƒซใ‚„ใƒกใƒƒใ‚ปใƒผใ‚ธใฎใƒกใ‚คใƒณใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใจใฏๅˆฅ - // ใชใ“ใจใ‚‚ใ‚ใ‚‹ใŸใ‚ใ€ใใ‚Œใ‚‰ใฎใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใŒใใ‚Œใžใ‚Œๅ„ใƒใƒฃใƒณใƒใƒซใซๆŽฅ็ถšใ™ใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹ใฎใฏ้ขๅ€’ใชใŸใ‚ใ€‚ - case "typingOnChannel": - this.typingOnChannel(body.channel); - break; - case "typingOnMessaging": - this.typingOnMessaging(body); - break; + // ๅ€‹ใ€…ใฎใƒใƒฃใƒณใƒใƒซใงใฏใชใใƒซใƒผใƒˆใƒฌใƒ™ใƒซใงใ“ใ‚Œใ‚‰ใฎใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’ๅ—ใ‘ๅ–ใ‚‹็†็”ฑใฏใ€ + // ใ‚ฏใƒฉใ‚คใ‚ขใƒณใƒˆใฎไบ‹ๆƒ…ใ‚’่€ƒๆ…ฎใ—ใŸใจใใ€ๅ…ฅๅŠ›ใƒ•ใ‚ฉใƒผใƒ ใฏใƒŽใƒผใƒˆใƒใƒฃใƒณใƒใƒซใ‚„ใƒกใƒƒใ‚ปใƒผใ‚ธใฎใƒกใ‚คใƒณใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใจใฏๅˆฅ + // ใชใ“ใจใ‚‚ใ‚ใ‚‹ใŸใ‚ใ€ใใ‚Œใ‚‰ใฎใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใŒใใ‚Œใžใ‚Œๅ„ใƒใƒฃใƒณใƒใƒซใซๆŽฅ็ถšใ™ใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹ใฎใฏ้ขๅ€’ใชใŸใ‚ใ€‚ + case "typingOnChannel": + this.typingOnChannel(body.channel); + break; + case "typingOnMessaging": + this.typingOnMessaging(body); + break; + } } } @@ -280,12 +391,75 @@ export default class Connection { * ใ‚ฏใƒฉใ‚คใ‚ขใƒณใƒˆใซใƒกใƒƒใ‚ปใƒผใ‚ธ้€ไฟก */ public sendMessageToWs(type: string, payload: any) { - this.wsConnection.send( - JSON.stringify({ - type: type, - body: payload, - }), - ); + console.log(payload, this.isMastodonCompatible); + if (this.isMastodonCompatible) { + if (payload.type === "note") { + this.wsConnection.send( + JSON.stringify({ + stream: [payload.id], + event: "update", + payload: JSON.stringify( + toTextWithReaction( + [Converter.note(payload.body, this.host)], + this.host, + )[0], + ), + }), + ); + this.onSubscribeNote({ + id: payload.body.id, + }); + } else if (payload.type === "reacted" || payload.type === "unreacted") { + // reaction + const client = getClient(this.host, this.accessToken); + client.getStatus(payload.id).then((data) => { + const newPost = toTextWithReaction([data.data], this.host); + for (const stream of this.currentSubscribe) { + this.wsConnection.send( + JSON.stringify({ + stream, + event: "status.update", + payload: JSON.stringify(newPost[0]), + }), + ); + } + }); + } else if (payload.type === "deleted") { + // delete + for (const stream of this.currentSubscribe) { + this.wsConnection.send( + JSON.stringify({ + stream, + event: "delete", + payload: payload.id, + }), + ); + } + } else if (payload.type === "unreadNotification") { + if (payload.id === "user") { + const body = Converter.notification(payload.body, this.host); + if (body.type === "reaction") body.type = "favourite"; + body.status = toTextWithReaction( + body.status ? [body.status] : [], + "", + )[0]; + this.wsConnection.send( + JSON.stringify({ + stream: ["user"], + event: "notification", + payload: JSON.stringify(body), + }), + ); + } + } + } else { + this.wsConnection.send( + JSON.stringify({ + type: type, + body: payload, + }), + ); + } } /** diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts index 9e84ec3074..4ccad96e81 100644 --- a/packages/backend/src/server/api/streaming.ts +++ b/packages/backend/src/server/api/streaming.ts @@ -16,10 +16,13 @@ export const initializeStreamingServer = (server: http.Server) => { ws.on("request", async (request) => { const q = request.resourceURL.query as ParsedUrlQuery; + const headers = request.httpRequest.headers['sec-websocket-protocol'] || ''; + const cred = q.i || q.access_token || headers; + const accessToken = cred.toString(); const [user, app] = await authenticate( request.httpRequest.headers.authorization, - q.i, + accessToken, ).catch((err) => { request.reject(403, err.message); return []; @@ -43,8 +46,11 @@ export const initializeStreamingServer = (server: http.Server) => { } redisClient.on("message", onRedisMessage); + const host = `https://${request.host}`; + const prepareStream = q.stream?.toString(); + console.log('start', q); - const main = new MainStreamConnection(connection, ev, user, app); + const main = new MainStreamConnection(connection, ev, user, app, host, accessToken, prepareStream); const intervalId = user ? setInterval(() => { diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts index 4d4b81d7aa..4d7259b075 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -20,6 +20,8 @@ import { createTemp } from "@/misc/create-temp.js"; import { publishMainStream } from "@/services/stream.js"; import * as Acct from "@/misc/acct.js"; import { envOption } from "@/env.js"; +const { koaBody } = require('koa-body'); +import megalodon, { MegalodonInterface } from 'megalodon'; import activityPub from "./activitypub.js"; import nodeinfo from "./nodeinfo.js"; import wellKnown from "./well-known.js"; @@ -133,6 +135,34 @@ router.get("/verify-email/:code", async (ctx) => { } }); +router.get("/oauth/authorize", async (ctx) => { + const client_id = ctx.request.query.client_id; + console.log(ctx.request.req); + ctx.redirect(Buffer.from(client_id?.toString() || '', 'base64').toString()); +}); + +router.get("/oauth/token", koaBody(), async (ctx) => { + const body: any = ctx.request.body; + const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; + const generator = (megalodon as any).default; + const client = generator('misskey', BASE_URL, null) as MegalodonInterface; + const m = body.code.match(/^[a-zA-Z0-9-]+/); + if (!m.length) return { error: 'Invalid code' } + try { + const atData = await client.fetchAccessToken(null, body.client_secret, m[0]); + ctx.body = { + access_token: atData.accessToken, + token_type: 'Bearer', + scope: 'read write follow', + created_at: new Date().getTime() / 1000 + }; + } catch (err: any) { + console.error(err); + ctx.status = 401; + ctx.body = err.response.data; + } +}); + // Register router app.use(router.routes()); diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts index 4ae8e5bfda..642a17d578 100644 --- a/packages/backend/src/server/web/index.ts +++ b/packages/backend/src/server/web/index.ts @@ -634,6 +634,10 @@ router.get("/streaming", async (ctx) => { ctx.status = 503; ctx.set("Cache-Control", "private, max-age=0"); }); +router.get("/api/v1/streaming", async (ctx) => { + ctx.status = 503; + ctx.set("Cache-Control", "private, max-age=0"); +}); // Render base html for all requests router.get("(.*)", async (ctx) => { diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue index 22d73a609d..bb3c54bd32 100644 --- a/packages/client/src/pages/auth.vue +++ b/packages/client/src/pages/auth.vue @@ -78,8 +78,9 @@ export default defineComponent({ methods: { accepted() { this.state = 'accepted'; + const getUrlParams = () => window.location.search.substring(1).split('&').reduce((result, query) => { const [k, v] = query.split('='); result[k] = decodeURI(v); return result; }, {}); if (this.session.app.callbackUrl) { - location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`; + location.href = `${this.session.app.callbackUrl}?token=${this.session.token}&code=${this.session.token}&state=${getUrlParams().state || ''}`; } }, onLogin(res) { login(res.i); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc39972499..7d5858f94a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,7 @@ importers: '@bull-board/api': ^4.6.4 '@bull-board/koa': ^4.6.4 '@bull-board/ui': ^4.6.4 + '@cutls/megalodon': 5.1.15 '@discordapp/twemoji': 14.0.2 '@elastic/elasticsearch': 7.17.0 '@koa/cors': 3.4.3 @@ -117,8 +118,10 @@ importers: ajv: 8.11.2 archiver: 5.3.1 autobind-decorator: 2.4.0 + autolinker: 4.0.0 autwh: 0.1.0 aws-sdk: 2.1277.0 + axios: ^1.3.2 bcryptjs: 2.4.3 blurhash: 1.1.5 bull: 4.10.2 @@ -152,6 +155,7 @@ importers: jsonld: 6.0.0 jsrsasign: 10.6.1 koa: 2.13.4 + koa-body: ^6.0.1 koa-bodyparser: 4.3.0 koa-favicon: 2.1.0 koa-json-body: 5.3.0 @@ -219,6 +223,7 @@ importers: '@bull-board/api': 4.10.2 '@bull-board/koa': 4.10.2_6tybghmia4wsnt33xeid7y4rby '@bull-board/ui': 4.10.2 + '@cutls/megalodon': 5.1.15 '@discordapp/twemoji': 14.0.2 '@elastic/elasticsearch': 7.17.0 '@koa/cors': 3.4.3 @@ -231,8 +236,10 @@ importers: '@tensorflow/tfjs': 4.2.0_seedrandom@3.0.5 ajv: 8.11.2 archiver: 5.3.1 + autolinker: 4.0.0 autwh: 0.1.0 aws-sdk: 2.1277.0 + axios: 1.3.2 bcryptjs: 2.4.3 blurhash: 1.1.5 bull: 4.10.2 @@ -261,6 +268,7 @@ importers: jsonld: 6.0.0 jsrsasign: 10.6.1 koa: 2.13.4 + koa-body: 6.0.1 koa-bodyparser: 4.3.0 koa-favicon: 2.1.0 koa-json-body: 5.3.0 @@ -839,6 +847,30 @@ packages: dependencies: '@jridgewell/trace-mapping': 0.3.9 + /@cutls/megalodon/5.1.15: + resolution: {integrity: sha512-4+mIKUYYr2CLY3idSxXk56WSTG9ww3opeenmsPRxftTwcjQTYxGntNkWmJWEbzeJ4rPslnvpwD7cFR62bPf41g==} + engines: {node: '>=15.0.0'} + dependencies: + '@types/oauth': 0.9.1 + '@types/ws': 8.5.4 + axios: 1.2.2 + dayjs: 1.11.7 + form-data: 4.0.0 + https-proxy-agent: 5.0.1 + oauth: 0.10.0 + object-assign-deep: 0.4.0 + parse-link-header: 2.0.0 + socks-proxy-agent: 7.0.0 + typescript: 4.9.4 + uuid: 9.0.0 + ws: 8.12.0 + transitivePeerDependencies: + - bufferutil + - debug + - supports-color + - utf-8-validate + dev: false + /@cypress/request/2.88.11: resolution: {integrity: sha512-M83/wfQ1EkspjkE2lNWNV5ui2Cv7UCv1swW1DqljahbzLVWltcsexQh8jYtuS/vzFXP+HySntGM83ZXA9fn17w==} engines: {node: '>= 6'} @@ -2022,6 +2054,13 @@ packages: cbor: 8.1.0 dev: true + /@types/co-body/6.1.0: + resolution: {integrity: sha512-3e0q2jyDAnx/DSZi0z2H0yoZ2wt5yRDZ+P7ymcMObvq0ufWRT4tsajyO+Q1VwVWiv9PRR4W3YEjEzBjeZlhF+w==} + dependencies: + '@types/node': 18.11.18 + '@types/qs': 6.9.7 + dev: false + /@types/connect/3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: @@ -2093,6 +2132,12 @@ packages: '@types/node': 18.11.18 dev: true + /@types/formidable/2.0.5: + resolution: {integrity: sha512-uvMcdn/KK3maPOaVUAc3HEYbCEhjaGFwww4EsX6IJfWIJ1tzHtDHczuImH3GKdusPnAAmzB07St90uabZeCKPA==} + dependencies: + '@types/node': 18.11.18 + dev: false + /@types/glob-stream/6.1.1: resolution: {integrity: sha512-AGOUTsTdbPkRS0qDeyeS+6KypmfVpbT5j23SN8UPG63qjKXNKjXn6V9wZUr8Fin0m9l8oGYaPK8b2WUMF8xI1A==} dependencies: @@ -2360,7 +2405,6 @@ packages: resolution: {integrity: sha512-a1iY62/a3yhZ7qH7cNUsxoI3U/0Fe9+RnuFrpTKr+0WVOzbKlSLojShCKe20aOD1Sppv+i8Zlq0pLDuTJnwS4A==} dependencies: '@types/node': 18.11.18 - dev: true /@types/offscreencanvas/2019.3.0: resolution: {integrity: sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==} @@ -2545,6 +2589,12 @@ packages: '@types/node': 18.11.18 dev: true + /@types/ws/8.5.4: + resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + dependencies: + '@types/node': 18.11.18 + dev: false + /@types/yauzl/2.10.0: resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==} requiresBuild: true @@ -3228,6 +3278,12 @@ packages: resolution: {integrity: sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw==} engines: {node: '>=8.10', npm: '>=6.4.1'} + /autolinker/4.0.0: + resolution: {integrity: sha512-fl5Kh6BmEEZx+IWBfEirnRUU5+cOiV0OK7PEt0RBKvJMJ8GaRseIOeDU3FKf4j3CE5HVefcjHmhYPOcaVt0bZw==} + dependencies: + tslib: 2.4.1 + dev: false + /autoprefixer/6.7.7: resolution: {integrity: sha512-WKExI/eSGgGAkWAO+wMVdFObZV7hQen54UpD1kCCTN3tvlL3W1jL4+lPP/M7MwoP7Q4RHzKtO3JQ4HxYEcd+xQ==} dependencies: @@ -3292,6 +3348,26 @@ packages: - debug dev: true + /axios/1.2.2: + resolution: {integrity: sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==} + dependencies: + follow-redirects: 1.15.2 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + + /axios/1.3.2: + resolution: {integrity: sha512-1M3O703bYqYuPhbHeya5bnhpYVsDDRyQSabNja04mZtboLNSuZ4YrltestrLXfHgmzua4TpUqRiVKbiQuo2epw==} + dependencies: + follow-redirects: 1.15.2 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: false + /babel-eslint/10.1.0_eslint@8.31.0: resolution: {integrity: sha512-ifWaTHQ0ce+448CYop8AdrQiBsGrnC+bMgfyKFdi6EsPLTAWG+QfyDeM6OH+FmWnKvEq5NnBMLvlBUPKQZoDSg==} engines: {node: '>=6'} @@ -4116,7 +4192,7 @@ packages: resolution: {integrity: sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==} dependencies: inflation: 2.0.0 - qs: 6.10.4 + qs: 6.11.0 raw-body: 2.5.1 type-is: 1.6.18 dev: false @@ -4125,7 +4201,7 @@ packages: resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==} dependencies: inflation: 2.0.0 - qs: 6.10.4 + qs: 6.11.0 raw-body: 2.5.1 type-is: 1.6.18 dev: false @@ -4961,7 +5037,6 @@ packages: /dayjs/1.11.7: resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} - dev: true /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} @@ -5236,6 +5311,13 @@ packages: engines: {node: '>=8'} dev: false + /dezalgo/1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + dev: false + /diff/4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -6299,6 +6381,15 @@ packages: dependencies: fetch-blob: 3.2.0 + /formidable/2.1.1: + resolution: {integrity: sha512-0EcS9wCFEzLvfiks7omJ+SiYJAiD+TzK4Pcw1UlUoGnhUxDcMKjt0P7x8wEb0u6OHu8Nb98WG3nxtlF5C7bvUQ==} + dependencies: + dezalgo: 1.0.4 + hexoid: 1.0.0 + once: 1.4.0 + qs: 6.11.0 + dev: false + /fragment-cache/0.2.1: resolution: {integrity: sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==} engines: {node: '>=0.10.0'} @@ -6947,6 +7038,11 @@ packages: hasBin: true dev: true + /hexoid/1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + dev: false + /highlight.js/10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} dev: false @@ -8015,6 +8111,17 @@ packages: engines: {node: '>=0.10.0'} dev: true + /koa-body/6.0.1: + resolution: {integrity: sha512-M8ZvMD8r+kPHy28aWP9VxL7kY8oPWA+C7ZgCljrCMeaU7uX6wsIQgDHskyrAr9sw+jqnIXyv4Mlxri5R4InIJg==} + dependencies: + '@types/co-body': 6.1.0 + '@types/formidable': 2.0.5 + '@types/koa': 2.13.5 + co-body: 6.1.0 + formidable: 2.1.1 + zod: 3.20.3 + dev: false + /koa-bodyparser/4.3.0: resolution: {integrity: sha512-uyV8G29KAGwZc4q/0WUAjH+Tsmuv9ImfBUF2oZVyZtaeo0husInagyn/JH85xMSxM0hEk/mbCII5ubLDuqW/Rw==} engines: {node: '>=8.0.0'} @@ -9371,6 +9478,11 @@ packages: resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} dev: false + /object-assign-deep/0.4.0: + resolution: {integrity: sha512-54Uvn3s+4A/cMWx9tlRez1qtc7pN7pbQ+Yi7mjLjcBpWLlP+XbSHiHbQW6CElDiV4OvuzqnMrBdkgxI1mT8V/Q==} + engines: {node: '>=6'} + dev: false + /object-assign/4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -9662,6 +9774,12 @@ packages: error-ex: 1.3.2 dev: true + /parse-link-header/2.0.0: + resolution: {integrity: sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw==} + dependencies: + xtend: 4.0.2 + dev: false + /parse-node-version/1.0.1: resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} engines: {node: '>= 0.10'} @@ -10304,6 +10422,10 @@ packages: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} dev: true + /proxy-from-env/1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: false + /ps-tree/1.2.0: resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} engines: {node: '>= 0.10'} @@ -10465,6 +10587,14 @@ packages: engines: {node: '>=0.6'} dependencies: side-channel: 1.0.4 + dev: true + + /qs/6.11.0: + resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + dev: false /qs/6.5.3: resolution: {integrity: sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==} @@ -12975,6 +13105,19 @@ packages: utf-8-validate: optional: true + /ws/8.12.0: + resolution: {integrity: sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xev/3.0.2: resolution: {integrity: sha512-8kxuH95iMXzHZj+fwqfA4UrPcYOy6bGIgfWzo9Ji23JoEc30ge/Z++Ubkiuy8c0+M64nXmmxrmJ7C8wnuBhluw==} dev: false @@ -13187,6 +13330,10 @@ packages: resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} dev: false + /zod/3.20.3: + resolution: {integrity: sha512-+MLeeUcLTlnzVo5xDn9+LVN9oX4esvgZ7qfZczBN+YVUvZBafIrPPVyG2WdjMWU2Qkb2ZAh2M8lpqf1wIoGqJQ==} + dev: false + github.com/misskey-dev/browser-image-resizer/0380d12c8e736788ea7f4e6e985175521ea7b23c: resolution: {tarball: https://codeload.github.com/misskey-dev/browser-image-resizer/tar.gz/0380d12c8e736788ea7f4e6e985175521ea7b23c} name: browser-image-resizer