From b1e3c2e1c7d495c18779d0e47dfcfa26516580db Mon Sep 17 00:00:00 2001 From: cutestnekoaqua Date: Thu, 9 Feb 2023 23:21:50 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20=E2=9C=A8=20v1=20Mastodon=20API=20This?= =?UTF-8?q?=20commit=20adds=20(maybe=20unstable)=20support=20for=20Mastodo?= =?UTF-8?q?ns=20v1=20api=20also=20some=20v2=20endpoints,=20maybe=20I=20mis?= =?UTF-8?q?s=20stuff,=20I=20dont=20know.=20We=20will=20need=20to=20test=20?= =?UTF-8?q?this=20but=20it=20should=20be=20kinda=20stable=20and=20work=20l?= =?UTF-8?q?ike=20(old)=20butter.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Natty Co-authored-by: cutls --- packages/backend/package.json | 5 +- packages/backend/src/misc/emoji-regex.ts | 1 + .../backend/src/models/repositories/note.ts | 5 +- packages/backend/src/models/schema/note.ts | 22 +- packages/backend/src/server/api/endpoints.ts | 2 + .../server/api/endpoints/i/get-unsecure.ts | 50 +++ .../mastodon/ApiMastodonCompatibleService.ts | 119 ++---- .../server/api/mastodon/endpoints/account.ts | 323 ++++++++++++++ .../src/server/api/mastodon/endpoints/auth.ts | 81 ++++ .../server/api/mastodon/endpoints/filter.ts | 83 ++++ .../src/server/api/mastodon/endpoints/meta.ts | 97 +++++ .../api/mastodon/endpoints/notifications.ts | 89 ++++ .../server/api/mastodon/endpoints/search.ts | 25 ++ .../server/api/mastodon/endpoints/status.ts | 403 ++++++++++++++++++ .../server/api/mastodon/endpoints/timeline.ts | 246 +++++++++++ .../backend/src/server/api/stream/index.ts | 275 +++++++++--- packages/backend/src/server/api/streaming.ts | 10 +- packages/backend/src/server/index.ts | 15 +- packages/backend/src/server/web/index.ts | 4 + packages/client/src/pages/auth.vue | 3 +- pnpm-lock.yaml | 145 +++++-- 21 files changed, 1805 insertions(+), 198 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/i/get-unsecure.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/account.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/auth.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/filter.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/meta.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/notifications.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/search.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/status.ts create mode 100644 packages/backend/src/server/api/mastodon/endpoints/timeline.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index 1491d6259..bdedcc70e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,7 +40,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", @@ -81,7 +84,7 @@ "koa-send": "5.0.1", "koa-slow": "2.1.0", "koa-views": "7.0.2", - "@cutls/megalodon": "^5.1.1", + "@cutls/megalodon": "5.1.15", "mfm-js": "0.23.2", "mime-types": "2.1.35", "mocha": "10.2.0", diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts index 573034f6b..08b44788d 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 2bc3b90ca..37c5031c0 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 4a7bd80fc..6bc8443f0 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 69298e73f..353f137a7 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], 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 000000000..eef7f5eca --- /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/mastodon/ApiMastodonCompatibleService.ts b/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts index 008fb8943..57a86c96d 100644 --- a/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts +++ b/packages/backend/src/server/api/mastodon/ApiMastodonCompatibleService.ts @@ -1,98 +1,30 @@ import Router from "@koa/router"; -import megalodon, { MegalodonInterface } from 'megalodon'; +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'; -function getClient(BASE_URL: string, authorization: string | undefined): MegalodonInterface { +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 } -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 apiMastodonCompatible(router: Router): void { - - router.post('/v1/apps', 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.req; - try { - let scope = body.scopes - if (typeof scope === 'string') scope = scope.split(' ') - const pushScope: string[] = [] - for (const s of scope) { - if (s.match(/^read/)) for (const r of readScope) pushScope.push(r) - if (s.match(/^write/)) for (const r of writeScope) pushScope.push(r) - } - 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: pushScope, redirect_uris: red, website: body.website }); - ctx.body = { - id: appData.id, - name: appData.name, - website: appData.website, - redirect_uri: appData.redirectUri, - 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; - } - }); - - router.get('/v1/accounts/verify_credentials', 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.verifyAccountCredentials(); - ctx.body = data.data; - } catch (e: any) { - console.error(e) - ctx.status = 401; - return e.response.data; - } - }); - + 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}`; @@ -108,4 +40,19 @@ export function apiMastodonCompatible(router: Router): void { } }); + 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 000000000..1b55a5fbd --- /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 000000000..5f5756077 --- /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 000000000..3c66362dd --- /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 000000000..3496272b9 --- /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 000000000..638f0d2d4 --- /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 000000000..f87e199f5 --- /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 000000000..593be10f9 --- /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 000000000..3fdb6ce88 --- /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 9675d184c..aeeb54606 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,150 @@ 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]; - const { type, body } = obj; + 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, + }, + }); + } + } + } - 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 + 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 - // ๅ€‹ใ€…ใฎใƒใƒฃใƒณใƒใƒซใงใฏใชใใƒซใƒผใƒˆใƒฌใƒ™ใƒซใงใ“ใ‚Œใ‚‰ใฎใƒกใƒƒใ‚ปใƒผใ‚ธใ‚’ๅ—ใ‘ๅ–ใ‚‹็†็”ฑใฏใ€ - // ใ‚ฏใƒฉใ‚คใ‚ขใƒณใƒˆใฎไบ‹ๆƒ…ใ‚’่€ƒๆ…ฎใ—ใŸใจใใ€ๅ…ฅๅŠ›ใƒ•ใ‚ฉใƒผใƒ ใฏใƒŽใƒผใƒˆใƒใƒฃใƒณใƒใƒซใ‚„ใƒกใƒƒใ‚ปใƒผใ‚ธใฎใƒกใ‚คใƒณใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใจใฏๅˆฅ - // ใชใ“ใจใ‚‚ใ‚ใ‚‹ใŸใ‚ใ€ใใ‚Œใ‚‰ใฎใ‚ณใƒณใƒใƒผใƒใƒณใƒˆใŒใใ‚Œใžใ‚Œๅ„ใƒใƒฃใƒณใƒใƒซใซๆŽฅ็ถšใ™ใ‚‹ใ‚ˆใ†ใซใ™ใ‚‹ใฎใฏ้ขๅ€’ใชใŸใ‚ใ€‚ - 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 +392,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 9e84ec307..4ccad96e8 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 6b752676f..4d7259b07 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -20,6 +20,7 @@ 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"; @@ -140,13 +141,21 @@ router.get("/oauth/authorize", async (ctx) => { ctx.redirect(Buffer.from(client_id?.toString() || '', 'base64').toString()); }); -router.get("/oauth/token", async (ctx) => { - const body: any = ctx.request.body +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 { - ctx.body = await client.fetchAccessToken(null, body.client_secret, body.code); + 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; diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts index 4ae8e5bfd..642a17d57 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 81a387422..bb3c54bd3 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}&code=${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 f1ce65483..772f4173f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,6 +60,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 @@ -120,8 +121,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 @@ -155,6 +158,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 @@ -163,7 +167,6 @@ importers: koa-send: 5.0.1 koa-slow: 2.1.0 koa-views: 7.0.2 - megalodon: ^5.1.1 mfm-js: 0.23.2 mime-types: 2.1.35 mocha: 10.2.0 @@ -224,6 +227,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 @@ -239,8 +243,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 @@ -271,6 +277,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 @@ -279,7 +286,6 @@ importers: koa-send: 5.0.1 koa-slow: 2.1.0 koa-views: 7.0.2_6tybghmia4wsnt33xeid7y4rby - megalodon: 5.1.1 mfm-js: 0.23.2 mime-types: 2.1.35 mocha: 10.2.0 @@ -847,6 +853,30 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: false + /@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'} @@ -2016,6 +2046,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: @@ -2083,6 +2120,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: @@ -3202,6 +3245,12 @@ packages: engines: {node: '>=8.10', npm: '>=6.4.1'} dev: false + /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: @@ -3253,7 +3302,7 @@ packages: /axios/0.24.0: resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} dependencies: - follow-redirects: 1.15.2_debug@4.3.4 + follow-redirects: 1.15.2 transitivePeerDependencies: - debug dev: false @@ -3261,7 +3310,7 @@ packages: /axios/0.25.0_debug@4.3.4: resolution: {integrity: sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==} dependencies: - follow-redirects: 1.15.2_debug@4.3.4 + follow-redirects: 1.15.2 transitivePeerDependencies: - debug dev: true @@ -3269,7 +3318,17 @@ packages: /axios/1.2.2: resolution: {integrity: sha512-bz/J4gS2S3I7mpN/YZfGFTqhXTYzRho8Ay38w2otuuDR322KzFIWm/4W2K6gIwvWaws5n+mnb7D1lN9uD+QH6Q==} dependencies: - follow-redirects: 1.15.2_debug@4.3.4 + 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: @@ -4102,7 +4161,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 @@ -4111,7 +4170,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 @@ -5232,6 +5291,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'} @@ -6213,7 +6279,7 @@ packages: readable-stream: 2.3.7 dev: false - /follow-redirects/1.15.2_debug@4.3.4: + /follow-redirects/1.15.2: resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} engines: {node: '>=4.0'} peerDependencies: @@ -6221,8 +6287,6 @@ packages: peerDependenciesMeta: debug: optional: true - dependencies: - debug: 4.3.4 /for-each/0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} @@ -6282,6 +6346,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'} @@ -6932,6 +7005,11 @@ packages: hasBin: true dev: false + /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 @@ -7999,6 +8077,17 @@ packages: engines: {node: '>=0.10.0'} dev: false + /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'} @@ -8672,30 +8761,6 @@ packages: engines: {node: '>= 0.6'} dev: false - /megalodon/5.1.1: - resolution: {integrity: sha512-zsYzzmogmk9lnXzGk3kKv58LUmZVFMebiya/1CZqZYnBVxq18Ep8l1AU41o+INANMqYxG+hAQvJhE+Z5dcUabQ==} - 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 - /merge-stream/2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -10473,6 +10538,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==} @@ -13230,6 +13303,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