more mastodon work

This commit is contained in:
Cleo John 2023-02-28 17:23:04 +01:00
parent 74903846b6
commit 05acb51da2
11 changed files with 203 additions and 56 deletions

View File

@ -98,6 +98,7 @@
"punycode": "2.1.1", "punycode": "2.1.1",
"pureimage": "0.3.15", "pureimage": "0.3.15",
"qrcode": "1.5.1", "qrcode": "1.5.1",
"qs": "6.9.7",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.18.0", "re2": "1.18.0",
@ -158,6 +159,7 @@
"@types/pug": "2.0.6", "@types/pug": "2.0.6",
"@types/punycode": "2.1.0", "@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0", "@types/qrcode": "1.5.0",
"@types/qs": "6.9.7",
"@types/random-seed": "0.3.3", "@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4", "@types/ratelimiter": "3.4.4",
"@types/redis": "4.0.11", "@types/redis": "4.0.11",

View File

@ -71,26 +71,8 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization; const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { try {
let userArray = ctx.query.acct?.toString().split("@"); const data = await client.search((request.query as any).acct, 'accounts');
let userid; ctx.body = data.data.accounts[0];
if (userArray === undefined) {
ctx.status = 401;
ctx.body = { error: "no user specified" };
return;
}
if (userArray.length === 1) {
const q: FindOptionsWhere<User> = {
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;
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
console.error(e.response.data); console.error(e.response.data);

View File

@ -44,12 +44,10 @@ const writeScope = [
export function apiAuthMastodon(router: Router): void { export function apiAuthMastodon(router: Router): void {
router.post("/v1/apps", async (ctx) => { router.post("/v1/apps", async (ctx) => {
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const accessTokens = ctx.request.headers.authorization; const client = getClient(BASE_URL, '');
const client = getClient(BASE_URL, accessTokens); const body: any = ctx.request.body || ctx.request.query;
const body: any = ctx.request.body;
try { try {
let scope = body.scopes; let scope = body.scopes;
console.log(body);
if (typeof scope === "string") scope = scope.split(" "); if (typeof scope === "string") scope = scope.split(" ");
const pushScope = new Set<string>(); const pushScope = new Set<string>();
for (const s of scope) { for (const s of scope) {
@ -64,14 +62,16 @@ export function apiAuthMastodon(router: Router): void {
redirect_uris: red, redirect_uris: red,
website: body.website, website: body.website,
}); });
ctx.body = { const returns = {
id: appData.id, id: Math.floor(Math.random() * 100).toString(),
name: appData.name, name: appData.name,
website: appData.website, website: body.website,
redirect_uri: red, redirect_uri: red,
client_id: Buffer.from(appData.url || "").toString("base64"), 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) { } catch (e: any) {
console.error(e); console.error(e);
ctx.status = 401; ctx.status = 401;

View File

@ -10,18 +10,18 @@ export async function getInstance(response: Entity.Instance) {
const totalStatuses = Notes.count({ where: { userHost: IsNull() } }); const totalStatuses = Notes.count({ where: { userHost: IsNull() } });
return { return {
uri: response.uri, uri: response.uri,
title: response.title || "", title: response.title || "Calckey",
short_description: response.description || "", short_description: response.description.substring(0, 50) || "See real server website",
description: response.description || "", 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 || "", 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, urls: response.urls,
stats: { stats: {
user_count: (await totalUsers), user_count: (await totalUsers),
status_count: (await totalStatuses), status_count: (await totalStatuses),
domain_count: response.stats.domain_count domain_count: response.stats.domain_count
}, },
thumbnail: response.thumbnail || "", thumbnail: response.thumbnail || 'https://http.cat/404',
languages: meta.langs, languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations, registrations: !meta.disableRegistration || response.registrations,
approval_required: !response.registrations, approval_required: !response.registrations,

View File

@ -1,6 +1,9 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon"; import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router"; import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js"; 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 { export function apiSearchMastodon(router: Router): void {
router.get("/v1/search", async (ctx) => { router.get("/v1/search", async (ctx) => {
@ -9,7 +12,7 @@ export function apiSearchMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body; const body: any = ctx.request.body;
try { try {
const query: any = ctx.query; const query: any = limitToInt(ctx.query);
const type = query.type || ""; const type = query.type || "";
const data = await client.search(query.q, type, query); const data = await client.search(query.q, type, query);
ctx.body = data.data; ctx.body = data.data;
@ -19,4 +22,110 @@ export function apiSearchMastodon(router: Router): void {
ctx.body = e.response.data; 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 [];
}
} }

View File

@ -2,6 +2,12 @@ import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js"; import { getClient } from "../ApiMastodonCompatibleService.js";
import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js"; import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js";
import axios from "axios"; 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 { export function apiStatusMastodon(router: Router): void {
router.post("/v1/statuses", async (ctx) => { router.post("/v1/statuses", async (ctx) => {
@ -9,9 +15,12 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization; const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { 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 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 isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed); const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
if ((body.in_reply_to_id && isDefaultEmoji) || isCustomEmoji) { 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 = 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); const data = await client.postStatus(text, body);
ctx.body = data.data; ctx.body = data.data;
} catch (e: any) { } catch (e: any) {
@ -70,7 +81,7 @@ export function apiStatusMastodon(router: Router): void {
const data = await client.deleteStatus(ctx.params.id); const data = await client.deleteStatus(ctx.params.id);
ctx.body = data.data; ctx.body = data.data;
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e.response.data, request.params.id);
ctx.status = 401; ctx.status = 401;
ctx.body = e.response.data; ctx.body = e.response.data;
} }
@ -430,6 +441,6 @@ export function statusModel(
pinned: false, pinned: false,
emoji_reactions: [], emoji_reactions: [],
bookmarked: false, bookmarked: false,
quote: false, quote: null,
}; };
} }

View File

@ -9,7 +9,9 @@ export function limitToInt(q: ParsedUrlQuery) {
let object: any = q; let object: any = q;
if (q.limit) if (q.limit)
if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10); 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) { export function argsToBools(q: ParsedUrlQuery) {
@ -26,12 +28,29 @@ export function argsToBools(q: ParsedUrlQuery) {
export function toTextWithReaction(status: Entity.Status[], host: string) { export function toTextWithReaction(status: Entity.Status[], host: string) {
return status.map((t) => { return status.map((t) => {
if (!t) return statusModel(null, null, [], "no content"); if (!t) return statusModel(null, null, [], "no content");
t.quote = null as any;
if (!t.emoji_reactions) return t; if (!t.emoji_reactions) return t;
if (t.reblog) t.reblog = toTextWithReaction([t.reblog], host)[0]; if (t.reblog) t.reblog = toTextWithReaction([t.reblog], host)[0];
const reactions = t.emoji_reactions.map( const reactions = t.emoji_reactions.map((r) => {
(r) => `${r.name.replace("@.", "")} (${r.count}${r.me ? "* " : ""})`, const emojiNotation = r.url ? `:${r.name.replace('@.', '')}:` : r.name
); return `${emojiNotation} (${r.count}${r.me ? `* ` : ''})`
//t.emojis = getEmoji(t.content, host) });
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 = `<p>${autoLinker(t.content, host)}</p><p>${reactions.join( t.content = `<p>${autoLinker(t.content, host)}</p><p>${reactions.join(
", ", ", ",
)}</p>`; )}</p>`;
@ -103,7 +122,7 @@ export function apiTimelineMastodon(router: Router): void {
} }
}, },
); );
router.get<{ Params: { hashtag: string } }>( router.get(
"/v1/timelines/home", "/v1/timelines/home",
async (ctx, reply) => { async (ctx, reply) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`; const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;

View File

@ -414,12 +414,13 @@ export default class Connection {
const client = getClient(this.host, this.accessToken); const client = getClient(this.host, this.accessToken);
client.getStatus(payload.id).then((data) => { client.getStatus(payload.id).then((data) => {
const newPost = toTextWithReaction([data.data], this.host); const newPost = toTextWithReaction([data.data], this.host);
const targetPost = newPost[0]
for (const stream of this.currentSubscribe) { for (const stream of this.currentSubscribe) {
this.wsConnection.send( this.wsConnection.send(
JSON.stringify({ JSON.stringify({
stream, stream,
event: "status.update", event: "status.update",
payload: JSON.stringify(newPost[0]), payload: JSON.stringify(targetPost),
}), }),
); );
} }

View File

@ -154,24 +154,29 @@ router.get("/verify-email/:code", async (ctx) => {
}); });
mastoRouter.get("/oauth/authorize", 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); 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) => { 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; let client_id: any = ctx.request.query.client_id;
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`; const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
const generator = (megalodon as any).default; const generator = (megalodon as any).default;
const client = generator("misskey", BASE_URL, null) as MegalodonInterface; const client = generator("misskey", BASE_URL, null) as MegalodonInterface;
let m = null; let m = null;
let token = null;
if (body.code) { 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) { if (!m.length) {
ctx.body = { error: "Invalid code" }; ctx.body = { error: "Invalid code" };
return; return;
} }
token = `${m[1]}-${m[2]}-${m[3]}-${m[4]}-${m[5]}`
console.log(body.code, token)
} }
if (client_id instanceof Array) { if (client_id instanceof Array) {
client_id = client_id.toString(); client_id = client_id.toString();
@ -182,14 +187,16 @@ mastoRouter.post("/oauth/token", async (ctx) => {
const atData = await client.fetchAccessToken( const atData = await client.fetchAccessToken(
client_id, client_id,
body.client_secret, body.client_secret,
m ? m[0] : "", token ? token : "",
); );
ctx.body = { const ret = {
access_token: atData.accessToken, access_token: atData.accessToken,
token_type: "Bearer", token_type: "Bearer",
scope: "read write follow", scope: body.scope || 'read write follow push',
created_at: Math.floor(new Date().getTime() / 1000), created_at: Math.floor(new Date().getTime() / 1000),
}; };
console.log('token-response', ret)
ctx.body = ret;
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
ctx.status = 401; ctx.status = 401;

View File

@ -86,7 +86,14 @@ export default defineComponent({
accepted() { accepted() {
this.state = '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; }, {}); 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); const url = new URL(this.session.app.callbackUrl);
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url'); 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") { if (this.session.app.callbackUrl === "urn:ietf:wg:oauth:2.0:oob") {

View File

@ -100,6 +100,7 @@ importers:
'@types/pug': 2.0.6 '@types/pug': 2.0.6
'@types/punycode': 2.1.0 '@types/punycode': 2.1.0
'@types/qrcode': 1.5.0 '@types/qrcode': 1.5.0
'@types/qs': 6.9.7
'@types/random-seed': 0.3.3 '@types/random-seed': 0.3.3
'@types/ratelimiter': 3.4.4 '@types/ratelimiter': 3.4.4
'@types/redis': 4.0.11 '@types/redis': 4.0.11
@ -183,6 +184,7 @@ importers:
punycode: 2.1.1 punycode: 2.1.1
pureimage: 0.3.15 pureimage: 0.3.15
qrcode: 1.5.1 qrcode: 1.5.1
qs: 6.9.7
random-seed: 0.3.0 random-seed: 0.3.0
ratelimiter: 3.4.1 ratelimiter: 3.4.1
re2: 1.18.0 re2: 1.18.0
@ -295,6 +297,7 @@ importers:
punycode: 2.1.1 punycode: 2.1.1
pureimage: 0.3.15 pureimage: 0.3.15
qrcode: 1.5.1 qrcode: 1.5.1
qs: 6.9.7
random-seed: 0.3.0 random-seed: 0.3.0
ratelimiter: 3.4.1 ratelimiter: 3.4.1
re2: 1.18.0 re2: 1.18.0
@ -357,6 +360,7 @@ importers:
'@types/pug': 2.0.6 '@types/pug': 2.0.6
'@types/punycode': 2.1.0 '@types/punycode': 2.1.0
'@types/qrcode': 1.5.0 '@types/qrcode': 1.5.0
'@types/qs': 6.9.7
'@types/random-seed': 0.3.3 '@types/random-seed': 0.3.3
'@types/ratelimiter': 3.4.4 '@types/ratelimiter': 3.4.4
'@types/redis': 4.0.11 '@types/redis': 4.0.11
@ -4264,7 +4268,7 @@ packages:
resolution: {integrity: sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==} resolution: {integrity: sha512-sX/LQ7LqUhgyaxzbe7IqwPeTr2yfpfUIQ/dgpKo6ZI4y4lpQA0YxAomWIY+7I7rHWcG02PG+OuPREzMW/5tszQ==}
dependencies: dependencies:
inflation: 2.0.0 inflation: 2.0.0
qs: 6.11.0 qs: 6.9.7
raw-body: 2.5.1 raw-body: 2.5.1
type-is: 1.6.18 type-is: 1.6.18
dev: false dev: false
@ -4273,7 +4277,7 @@ packages:
resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==} resolution: {integrity: sha512-m7pOT6CdLN7FuXUcpuz/8lfQ/L77x8SchHCF4G0RBTJO20Wzmhn5Sp4/5WsKy8OSpifBSUrmg83qEqaDHdyFuQ==}
dependencies: dependencies:
inflation: 2.0.0 inflation: 2.0.0
qs: 6.11.0 qs: 6.9.7
raw-body: 2.5.1 raw-body: 2.5.1
type-is: 1.6.18 type-is: 1.6.18
dev: false dev: false
@ -10691,6 +10695,11 @@ packages:
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
dev: false dev: false
/qs/6.9.7:
resolution: {integrity: sha512-IhMFgUmuNpyRfxA90umL7ByLlgRXu6tIfKPpF5TmcfRLlLCckfP/g3IQmju6jjpu+Hh8rA+2p6A27ZSPOOHdKw==}
engines: {node: '>=0.6'}
dev: false
/query-string/4.3.4: /query-string/4.3.4:
resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==} resolution: {integrity: sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}