From 05acb51da28da65d2cfec6f85534c5ae37cd9ed3 Mon Sep 17 00:00:00 2001 From: Cleo John Date: Tue, 28 Feb 2023 17:23:04 +0100 Subject: [PATCH] more mastodon work --- packages/backend/package.json | 2 + .../server/api/mastodon/endpoints/account.ts | 22 +--- .../src/server/api/mastodon/endpoints/auth.ts | 16 +-- .../src/server/api/mastodon/endpoints/meta.ts | 10 +- .../server/api/mastodon/endpoints/search.ts | 111 +++++++++++++++++- .../server/api/mastodon/endpoints/status.ts | 21 +++- .../server/api/mastodon/endpoints/timeline.ts | 31 ++++- .../backend/src/server/api/stream/index.ts | 3 +- packages/backend/src/server/index.ts | 21 ++-- packages/client/src/pages/auth.vue | 9 +- pnpm-lock.yaml | 13 +- 11 files changed, 203 insertions(+), 56 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index f3aaafc9a2..3a009dd149 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -98,6 +98,7 @@ "punycode": "2.1.1", "pureimage": "0.3.15", "qrcode": "1.5.1", + "qs": "6.9.7", "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.18.0", @@ -158,6 +159,7 @@ "@types/pug": "2.0.6", "@types/punycode": "2.1.0", "@types/qrcode": "1.5.0", + "@types/qs": "6.9.7", "@types/random-seed": "0.3.3", "@types/ratelimiter": "3.4.4", "@types/redis": "4.0.11", diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts index 5b7df948e3..312e381359 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/account.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -71,26 +71,8 @@ export function apiAccountMastodon(router: Router): void { const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - let userArray = ctx.query.acct?.toString().split("@"); - let userid; - if (userArray === undefined) { - ctx.status = 401; - ctx.body = { error: "no user specified" }; - return; - } - if (userArray.length === 1) { - const q: FindOptionsWhere = { - usernameLower: userArray[0].toLowerCase(), - host: IsNull(), - }; - - const user = await Users.findOneBy(q); - userid = user?.id; - } else { - userid = (await resolveUser(userArray[0], userArray[1])).id; - } - const data = await client.getAccount(userid ? userid : ""); - ctx.body = data.data; + const data = await client.search((request.query as any).acct, 'accounts'); + ctx.body = data.data.accounts[0]; } catch (e: any) { console.error(e); console.error(e.response.data); diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts index 5d10641fb2..f1c54be0ae 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/auth.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -44,12 +44,10 @@ const writeScope = [ export function apiAuthMastodon(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.body; + const client = getClient(BASE_URL, ''); + const body: any = ctx.request.body || ctx.request.query; try { let scope = body.scopes; - console.log(body); if (typeof scope === "string") scope = scope.split(" "); const pushScope = new Set(); for (const s of scope) { @@ -64,14 +62,16 @@ export function apiAuthMastodon(router: Router): void { redirect_uris: red, website: body.website, }); - ctx.body = { - id: appData.id, + const returns = { + id: Math.floor(Math.random() * 100).toString(), name: appData.name, - website: appData.website, + website: body.website, redirect_uri: red, client_id: Buffer.from(appData.url || "").toString("base64"), - client_secret: appData.clientSecret, + client_secret: appData.clientSecret }; + console.log(returns) + ctx.body = returns; } catch (e: any) { console.error(e); ctx.status = 401; diff --git a/packages/backend/src/server/api/mastodon/endpoints/meta.ts b/packages/backend/src/server/api/mastodon/endpoints/meta.ts index 67f3901e4e..e5e0f26222 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/meta.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/meta.ts @@ -10,18 +10,18 @@ export async function getInstance(response: Entity.Instance) { const totalStatuses = Notes.count({ where: { userHost: IsNull() } }); return { uri: response.uri, - title: response.title || "", - short_description: response.description || "", - description: response.description || "", + title: response.title || "Calckey", + short_description: response.description.substring(0, 50) || "See real server website", + description: response.description || "This is a vanilla Calckey Instance. It doesnt seem to have a description. BTW you are using the Mastodon api to access this server :)", email: response.email || "", - version: "3.0.0 compatible (Calckey)", + version: "3.0.0 compatible (3.5+ Calckey)", //I hope this version string is correct, we will need to test it. urls: response.urls, stats: { user_count: (await totalUsers), status_count: (await totalStatuses), domain_count: response.stats.domain_count }, - thumbnail: response.thumbnail || "", + thumbnail: response.thumbnail || 'https://http.cat/404', languages: meta.langs, registrations: !meta.disableRegistration || response.registrations, approval_required: !response.registrations, diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts index c8ca1f0504..456050fa0d 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/search.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -1,6 +1,9 @@ import megalodon, { MegalodonInterface } from "@calckey/megalodon"; import Router from "@koa/router"; import { getClient } from "../ApiMastodonCompatibleService.js"; +import axios from "axios"; +import { Converter } from "@calckey/megalodon"; +import { limitToInt } from "./timeline.js"; export function apiSearchMastodon(router: Router): void { router.get("/v1/search", async (ctx) => { @@ -9,7 +12,7 @@ export function apiSearchMastodon(router: Router): void { const client = getClient(BASE_URL, accessTokens); const body: any = ctx.request.body; try { - const query: any = ctx.query; + const query: any = limitToInt(ctx.query); const type = query.type || ""; const data = await client.search(query.q, type, query); ctx.body = data.data; @@ -19,4 +22,110 @@ export function apiSearchMastodon(router: Router): void { ctx.body = e.response.data; } }); + router.get("/v2/search", async (ctx) => { + const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; + const accessTokens = ctx.headers.authorization; + const client = getClient(BASE_URL, accessTokens); + try { + const query: any = limitToInt(ctx.query); + const type = query.type; + if (type) { + const data = await client.search(query.q, type, query); + ctx.body = data.data; + } else { + const acct = await client.search(query.q, "accounts", query); + const stat = await client.search(query.q, "statuses", query); + const tags = await client.search(query.q, "hashtags", query); + ctx.body = { + accounts: acct.data.accounts, + statuses: stat.data.statuses, + hashtags: tags.data.hashtags, + }; + } + } catch (e: any) { + console.error(e); + ctx.status = (401); + ctx.body e.response.data; + } + }); + router.get("/v1/trends/statuses", async (ctx) => { + const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; + const accessTokens = ctx.headers.authorization; + try { + const data = await getHighlight(BASE_URL, ctx.request.hostname, accessTokens); + ctx.body = data; + } catch (e: any) { + console.error(e); + ctx.status = (401); + ctx.body = e.response.data; + } + }); + router.get("/v2/suggestions", async (ctx) => { + const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; + const accessTokens = ctx.headers.authorization; + try { + const query: any = ctx.query; + const data = await getFeaturedUser( + BASE_URL, + ctx.request.hostname, + accessTokens, + query.limit || 20, + ); + console.log(data); + ctx.body = data; + } catch (e: any) { + console.error(e); + ctx.status = (401); + ctx.body = e.response.data; + } + }); +} +async function getHighlight( + BASE_URL: string, + domain: string, + accessTokens: string | undefined, +) { + const accessTokenArr = accessTokens?.split(" ") ?? [null]; + const accessToken = accessTokenArr[accessTokenArr.length - 1]; + try { + const api = await axios.post(`${BASE_URL}/api/notes/featured`, { + i: accessToken, + }); + const data: MisskeyEntity.Note[] = api.data; + return data.map((note) => Converter.note(note, domain)); + } catch (e: any) { + console.log(e); + console.log(e.response.data); + return []; + } +} +async function getFeaturedUser( + BASE_URL: string, + host: string, + accessTokens: string | undefined, + limit: number, +) { + const accessTokenArr = accessTokens?.split(" ") ?? [null]; + const accessToken = accessTokenArr[accessTokenArr.length - 1]; + try { + const api = await axios.post(`${BASE_URL}/api/users`, { + i: accessToken, + limit, + origin: "local", + sort: "+follower", + state: "alive", + }); + const data: MisskeyEntity.UserDetail[] = api.data; + console.log(data); + return data.map((u) => { + return { + source: "past_interactions", + account: Converter.userDetail(u, host), + }; + }); + } catch (e: any) { + console.log(e); + console.log(e.response.data); + return []; + } } diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts index 3981a17811..d04b7a8b95 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/status.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -2,6 +2,12 @@ import Router from "@koa/router"; import { getClient } from "../ApiMastodonCompatibleService.js"; import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js"; import axios from "axios"; +import querystring from 'node:querystring' +import qs from 'qs' +function normalizeQuery(data: any) { + const str = querystring.stringify(data); + return qs.parse(str); +} export function apiStatusMastodon(router: Router): void { router.post("/v1/statuses", async (ctx) => { @@ -9,9 +15,12 @@ export function apiStatusMastodon(router: Router): void { const accessTokens = ctx.headers.authorization; const client = getClient(BASE_URL, accessTokens); try { - const body: any = ctx.request.body; + let body: any = ctx.request.body; + if ((!body.poll && body['poll[options][]']) || (!body.media_ids && body['media_ids[]'])) { + body = normalizeQuery(body) + } const text = body.status; - const removed = text.replace(/@\S+/g, "").replaceAll(" ", ""); + const removed = text.replace(/@\S+/g, "").replace(/\s|​/g, '') const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed); const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); if ((body.in_reply_to_id && isDefaultEmoji) || isCustomEmoji) { @@ -35,7 +44,9 @@ export function apiStatusMastodon(router: Router): void { } } if (!body.media_ids) body.media_ids = undefined; - if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + if (body.media_ids && !body.media_ids.length) body.media_ids = undefined; + const { sensitive } = body + body.sensitive = typeof sensitive === 'string' ? sensitive === 'true' : sensitive const data = await client.postStatus(text, body); ctx.body = data.data; } catch (e: any) { @@ -70,7 +81,7 @@ export function apiStatusMastodon(router: Router): void { const data = await client.deleteStatus(ctx.params.id); ctx.body = data.data; } catch (e: any) { - console.error(e); + console.error(e.response.data, request.params.id); ctx.status = 401; ctx.body = e.response.data; } @@ -430,6 +441,6 @@ export function statusModel( pinned: false, emoji_reactions: [], bookmarked: false, - quote: false, + quote: null, }; } diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts index 7cd9e26cb8..1b5afd6d06 100644 --- a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -9,7 +9,9 @@ export function limitToInt(q: ParsedUrlQuery) { let object: any = q; if (q.limit) if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10); - return q; + if (q.offset) + if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10); + return object; } export function argsToBools(q: ParsedUrlQuery) { @@ -26,12 +28,29 @@ export function argsToBools(q: ParsedUrlQuery) { export function toTextWithReaction(status: Entity.Status[], host: string) { return status.map((t) => { if (!t) return statusModel(null, null, [], "no content"); + t.quote = null as any; 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) + const reactions = t.emoji_reactions.map((r) => { + const emojiNotation = r.url ? `:${r.name.replace('@.', '')}:` : r.name + return `${emojiNotation} (${r.count}${r.me ? `* ` : ''})` + }); + const reaction = t.emoji_reactions as Entity.Reaction[]; + const emoji = t.emojis || [] + for (const r of reaction) { + if (!r.url) continue + emoji.push({ + 'shortcode': r.name, + 'url': r.url, + 'static_url': r.url, + 'visible_in_picker': true, + },) + } + const isMe = reaction.findIndex((r) => r.me) > -1; + const total = reaction.reduce((sum, reaction) => sum + reaction.count, 0); + t.favourited = isMe; + t.favourites_count = total; + t.emojis = emoji t.content = `

${autoLinker(t.content, host)}

${reactions.join( ", ", )}

`; @@ -103,7 +122,7 @@ export function apiTimelineMastodon(router: Router): void { } }, ); - router.get<{ Params: { hashtag: string } }>( + router.get( "/v1/timelines/home", async (ctx, reply) => { const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 10b9fb6497..f285b2f5bf 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -414,12 +414,13 @@ export default class Connection { const client = getClient(this.host, this.accessToken); client.getStatus(payload.id).then((data) => { const newPost = toTextWithReaction([data.data], this.host); + const targetPost = newPost[0] for (const stream of this.currentSubscribe) { this.wsConnection.send( JSON.stringify({ stream, event: "status.update", - payload: JSON.stringify(newPost[0]), + payload: JSON.stringify(targetPost), }), ); } diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts index 6dff706653..e35bbb86bb 100644 --- a/packages/backend/src/server/index.ts +++ b/packages/backend/src/server/index.ts @@ -154,24 +154,29 @@ router.get("/verify-email/:code", async (ctx) => { }); mastoRouter.get("/oauth/authorize", async (ctx) => { - const client_id = ctx.request.query.client_id; + const { client_id, state, redirect_uri } = ctx.request.query.client_id; console.log(ctx.request.req); - ctx.redirect(Buffer.from(client_id?.toString() || "", "base64").toString()); + const param = state ? `state=${state}&mastodon=true` : "mastodon=true"; + ctx.redirect(`${Buffer.from(client_id || '', 'base64').toString()}?${param}`); }); mastoRouter.post("/oauth/token", async (ctx) => { - const body: any = ctx.request.body; + const body: any = ctx.request.body || ctx.request.query; + console.log('token-request', body) let client_id: any = ctx.request.query.client_id; const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; const generator = (megalodon as any).default; const client = generator("misskey", BASE_URL, null) as MegalodonInterface; let m = null; + let token = null; if (body.code) { - m = body.code.match(/^[a-zA-Z0-9-]+/); + m = body.code.match(/^([a-zA-Z0-9]{8})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{4})([a-zA-Z0-9]{12})/); if (!m.length) { ctx.body = { error: "Invalid code" }; return; } + token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}` + console.log(body.code, token) } if (client_id instanceof Array) { client_id = client_id.toString(); @@ -182,14 +187,16 @@ mastoRouter.post("/oauth/token", async (ctx) => { const atData = await client.fetchAccessToken( client_id, body.client_secret, - m ? m[0] : "", + token ? token : "", ); - ctx.body = { + const ret = { access_token: atData.accessToken, token_type: "Bearer", - scope: "read write follow", + scope: body.scope || 'read write follow push', created_at: Math.floor(new Date().getTime() / 1000), }; + console.log('token-response', ret) + ctx.body = ret; } catch (err: any) { console.error(err); ctx.status = 401; diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue index c3f5801601..b7ea082c64 100644 --- a/packages/client/src/pages/auth.vue +++ b/packages/client/src/pages/auth.vue @@ -86,7 +86,14 @@ export default defineComponent({ 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) { + const isMastodon = !!getUrlParams().mastodon + if (this.session.app.callbackUrl && isMastodon) { + const state = getUrlParams().state + const stateParam = `&state=${state}` + const tokenRaw = this.session.token + const token = tokenRaw.replaceAll('-', '') + location.href = `${this.session.app.callbackUrl}?code=${token}${stateParam}`; + } else if (this.session.app.callbackUrl) { const url = new URL(this.session.app.callbackUrl); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url'); if (this.session.app.callbackUrl === "urn:ietf:wg:oauth:2.0:oob") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80df2d79bd..a6c6bd1b3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,7 @@ importers: '@types/pug': 2.0.6 '@types/punycode': 2.1.0 '@types/qrcode': 1.5.0 + '@types/qs': 6.9.7 '@types/random-seed': 0.3.3 '@types/ratelimiter': 3.4.4 '@types/redis': 4.0.11 @@ -183,6 +184,7 @@ importers: punycode: 2.1.1 pureimage: 0.3.15 qrcode: 1.5.1 + qs: 6.9.7 random-seed: 0.3.0 ratelimiter: 3.4.1 re2: 1.18.0 @@ -295,6 +297,7 @@ importers: punycode: 2.1.1 pureimage: 0.3.15 qrcode: 1.5.1 + qs: 6.9.7 random-seed: 0.3.0 ratelimiter: 3.4.1 re2: 1.18.0 @@ -357,6 +360,7 @@ importers: '@types/pug': 2.0.6 '@types/punycode': 2.1.0 '@types/qrcode': 1.5.0 + '@types/qs': 6.9.7 '@types/random-seed': 0.3.3 '@types/ratelimiter': 3.4.4 '@types/redis': 4.0.11 @@ -4264,7 +4268,7 @@ packages: resolution: {integrity: sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==} dependencies: inflation: 2.0.0 - qs: 6.11.0 + qs: 6.9.7 raw-body: 2.5.1 type-is: 1.6.18 dev: false @@ -4273,7 +4277,7 @@ packages: resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==} dependencies: inflation: 2.0.0 - qs: 6.11.0 + qs: 6.9.7 raw-body: 2.5.1 type-is: 1.6.18 dev: false @@ -10691,6 +10695,11 @@ packages: engines: {node: '>=0.6'} dev: false + /qs/6.9.7: + resolution: {integrity: sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==} + engines: {node: '>=0.6'} + dev: false + /query-string/4.3.4: resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==} engines: {node: '>=0.10.0'}