Skip to content

Commit 587ceec

Browse files
authored
feat(app/web): bypass metrics.api.github.overuse with OAuth (lowlighter#1171)
1 parent 0937317 commit 587ceec

File tree

13 files changed

+657
-111
lines changed

13 files changed

+657
-111
lines changed

source/app/metrics/metadata.mjs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) {
104104
if ((meta.category === "github") && (!meta.disclaimer))
105105
meta.disclaimer = "This plugin is not affiliated, associated, authorized, endorsed by, or in any way officially connected with [GitHub](https://github.com).\nAll product and company names are trademarks™ or registered® trademarks of their respective holders."
106106

107+
//Deprecation
108+
meta.deprecated = !!meta?.deprecation
109+
107110
//Inputs parser
108111
{
109112
meta.inputs = function({data: {user = null} = {}, q, account}, defaults = {}) {
@@ -345,30 +348,30 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) {
345348
//Web metadata
346349
{
347350
meta.web = Object.fromEntries(
348-
Object.entries(inputs).map(([key, {type, description: text, example, default: defaulted, min = 0, max = 9999, values}]) => [
351+
Object.entries(inputs).map(([key, {type, description: text, example, default: defaulted, min = 0, max = 9999, values, extras}]) => [
349352
//Format key
350353
metadata.to.query(key),
351354
//Value descriptor
352355
(() => {
353356
switch (type) {
354357
case "boolean":
355-
return {text, type: "boolean", defaulted: /^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(defaulted) ? true : /^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(defaulted) ? false : defaulted}
358+
return {text, type: "boolean", defaulted: /^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(defaulted) ? true : /^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(defaulted) ? false : defaulted, extras}
356359
case "number":
357-
return {text, type: "number", min, max, defaulted}
360+
return {text, type: "number", min, max, defaulted, extras}
358361
case "array":
359-
return {text, type: "text", placeholder: example ?? defaulted, defaulted}
362+
return {text, type: "text", placeholder: example ?? defaulted, defaulted, extras}
360363
case "string": {
361364
if (Array.isArray(values))
362365
return {text, type: "select", values, defaulted}
363-
return {text, type: "text", placeholder: example ?? defaulted, defaulted}
366+
return {text, type: "text", placeholder: example ?? defaulted, defaulted, extras}
364367
}
365368
case "json":
366-
return {text, type: "text", placeholder: example ?? defaulted, defaulted}
369+
return {text, type: "text", placeholder: example ?? defaulted, defaulted, extras}
367370
default:
368371
return null
369372
}
370373
})(),
371-
]).filter(([key, value]) => (value) && (key !== name)),
374+
]).filter(([key, value]) => (value)&&(!((name === "base")&&(key === "repositories")))),
372375
)
373376
}
374377

source/app/web/instance.mjs

Lines changed: 143 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import express from "express"
66
import ratelimit from "express-rate-limit"
77
import cache from "memory-cache"
88
import util from "util"
9+
import url from "url"
10+
import axios from "axios"
911
import mocks from "../../../tests/mocks/index.mjs"
1012
import metrics from "../metrics/index.mjs"
1113
import presets from "../metrics/presets.mjs"
1214
import setup from "../metrics/setup.mjs"
15+
import crypto from "crypto"
1316

1417
/**App */
1518
export default async function({sandbox = false} = {}) {
@@ -55,7 +58,20 @@ export default async function({sandbox = false} = {}) {
5558
//Apply mocking if needed
5659
if (mock)
5760
Object.assign(api, await mocks(api))
58-
const {graphql, rest} = api
61+
//Custom user octokits sessions
62+
const authenticated = new Map()
63+
const uapi = session => {
64+
if (!/^[a-f0-9]+$/i.test(`${session}`))
65+
return null
66+
if (authenticated.has(session)) {
67+
const {login, token} = authenticated.get(session)
68+
console.debug(`metrics/app/session/${login} > authenticated with session ${session.substring(0, 6)}, using custom octokit`)
69+
return {login, graphql: octokit.graphql.defaults({headers: {authorization: `token ${token}`}}), rest: new OctokitRest.Octokit({auth: token})}
70+
}
71+
else if (session)
72+
console.debug(`metrics/app/session > unknown session ${session.substring(0, 6)}, using default octokit`)
73+
return null
74+
}
5975

6076
//Setup server
6177
const app = express()
@@ -87,22 +103,19 @@ export default async function({sandbox = false} = {}) {
87103
const limiter = ratelimit({max: debug ? Number.MAX_SAFE_INTEGER : 60, windowMs: 60 * 1000, headers: false})
88104
const metadata = Object.fromEntries(
89105
Object.entries(conf.metadata.plugins)
90-
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "category", "web", "supports", "scopes"].includes(key)))])
106+
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "category", "web", "supports", "scopes", "deprecated"].includes(key)))])
91107
.map(([key, value]) => [key, key === "core" ? {...value, web: Object.fromEntries(Object.entries(value.web).filter(([key]) => /^config[.]/.test(key)).map(([key, value]) => [key.replace(/^config[.]/, ""), value]))} : value]),
92108
)
93-
const enabled = Object.entries(metadata).filter(([_name, {category}]) => category !== "core").map(([name]) => ({name, category: metadata[name]?.category ?? "community", enabled: plugins[name]?.enabled ?? false}))
109+
const enabled = Object.entries(metadata).filter(([_name, {category}]) => category !== "core").map(([name]) => ({name, category: metadata[name]?.category ?? "community", deprecated: metadata[name]?.deprecated ?? false, enabled: plugins[name]?.enabled ?? false}))
94110
const templates = Object.entries(Templates).map(([name]) => ({name, enabled: (conf.settings.templates.enabled.length ? conf.settings.templates.enabled.includes(name) : true) ?? false}))
95111
const actions = {flush: new Map()}
96-
const requests = {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}}
112+
const requests = {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}, search: {limit: 0, used: 0, remaining: 0, reset: NaN}}
97113
let _requests_refresh = false
98114
if (!conf.settings.notoken) {
99115
const refresh = async () => {
100116
try {
101-
const {limit} = await graphql("{ limit:rateLimit {limit remaining reset:resetAt used} }")
102-
Object.assign(requests, {
103-
rest: (await rest.rateLimit.get()).data.rate,
104-
graphql: {...limit, reset: new Date(limit.reset).getTime()},
105-
})
117+
const {resources} = (await api.rest.rateLimit.get()).data
118+
Object.assign(requests, {rest: resources.core, graphql: resources.graphql, search: resources.search})
106119
}
107120
catch {
108121
console.debug("metrics/app > failed to update remaining requests")
@@ -130,8 +143,16 @@ export default async function({sandbox = false} = {}) {
130143
app.get("/.templates/:template", limiter, (req, res) => req.params.template in conf.templates ? res.status(200).json(conf.templates[req.params.template]) : res.sendStatus(404))
131144
for (const template in conf.templates)
132145
app.use(`/.templates/${template}/partials`, express.static(`${conf.paths.templates}/${template}/partials`))
133-
//Modes
146+
//Modes and extras
134147
app.get("/.modes", limiter, (req, res) => res.status(200).json(conf.settings.modes))
148+
app.get("/.extras", limiter, async (req, res) => {
149+
if ((authenticated.has(req.headers["x-metrics-session"]))&&(conf.settings.extras?.logged)) {
150+
if (conf.settings.extras?.features !== true)
151+
return res.status(200).json([...conf.settings.extras.features, ...conf.settings.extras.logged])
152+
}
153+
res.status(200).json(conf.settings.extras?.features ?? conf.settings?.extras?.default ?? false)
154+
})
155+
app.get("/.extras.logged", limiter, async (req, res) => res.status(200).json(conf.settings.extras?.logged ?? []))
135156
//Styles
136157
app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.css`))
137158
app.get("/.css/style.vars.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.vars.css`))
@@ -152,7 +173,18 @@ export default async function({sandbox = false} = {}) {
152173
app.get("/.js/clipboard.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/clipboard/dist/clipboard.min.js`))
153174
//Meta
154175
app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version))
155-
app.get("/.requests", limiter, (req, res) => res.status(200).json(requests))
176+
app.get("/.requests", limiter, async (req, res) => {
177+
try {
178+
const custom = uapi(req.headers["x-metrics-session"])
179+
if (custom) {
180+
const {data:{resources}} = await custom.rest.rateLimit.get()
181+
if (resources)
182+
return res.status(200).json({rest:resources.core, graphql:resources.graphql, search:resources.search, login:custom.login})
183+
}
184+
}
185+
catch {} //eslint-disable-line no-empty
186+
return res.status(200).json(requests)
187+
})
156188
app.get("/.hosted", limiter, (req, res) => res.status(200).json(conf.settings.hosted || null))
157189
//Cache
158190
app.get("/.uncache", limiter, (req, res) => {
@@ -172,6 +204,84 @@ export default async function({sandbox = false} = {}) {
172204
}
173205
})
174206

207+
//OAuth
208+
if (conf.settings.oauth) {
209+
console.debug("metrics/app/oauth > enabled")
210+
const states = new Map()
211+
app.get("/.oauth/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/index.html`))
212+
app.get("/.oauth/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/index.html`))
213+
app.get("/.oauth/script.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/script.js`))
214+
app.get("/.oauth/authenticate", (req, res) => {
215+
//Create a state to protect against cross-site request forgery attacks
216+
const state = crypto.randomBytes(64).toString("hex")
217+
const scopes = new url.URLSearchParams(req.query).get("scopes")
218+
const from = new url.URLSearchParams(req.query).get("scopes")
219+
states.set(state, {from, scopes})
220+
console.debug(`metrics/app/oauth > request ${state}`)
221+
//OAuth through GitHub
222+
return res.redirect(`https://github.com/login/oauth/authorize?${new url.URLSearchParams({
223+
client_id:conf.settings.oauth.id,
224+
state,
225+
redirect_uri:`${conf.settings.oauth.url}/.oauth/authorize`,
226+
allow_signup:false,
227+
scope:scopes,
228+
})}`)
229+
})
230+
app.get("/.oauth/authorize", async (req, res) => {
231+
//Check state
232+
const {code, state} = req.query
233+
if ((!state)||(!states.has(state))) {
234+
console.debug("metrics/app/oauth > 400 (invalid state)")
235+
return res.status(400).send("Bad request: invalid state")
236+
}
237+
//OAuth
238+
try {
239+
//Authorize user
240+
console.debug("metrics/app/oauth > authorization")
241+
const {data} = await axios.post("https://github.com/login/oauth/access_token", `${new url.URLSearchParams({
242+
client_id:conf.settings.oauth.id,
243+
client_secret:conf.settings.oauth.secret,
244+
code,
245+
})}`)
246+
const token = new url.URLSearchParams(data).get("access_token")
247+
//Validate user
248+
const {data:{login}} = await axios.get("https://api.github.com/user", {headers:{Authorization:`token ${token}`}})
249+
console.debug(`metrics/app/oauth > authorization success for ${login}`)
250+
const session = crypto.randomBytes(128).toString("hex")
251+
authenticated.set(session, {login, token})
252+
console.debug(`metrics/app/oauth > created session ${session.substring(0, 6)}`)
253+
//Redirect user back
254+
const {from} = states.get(state)
255+
return res.redirect(`/.oauth/redirect?${new url.URLSearchParams({to:from, session})}`)
256+
}
257+
catch {
258+
console.debug("metrics/app/oauth > authorization failed")
259+
return res.status(401).send("Unauthorized: oauth failed")
260+
}
261+
finally {
262+
states.delete(state)
263+
}
264+
})
265+
app.get("/.oauth/revoke/:session", limiter, async (req, res) => {
266+
const session = req.params.session?.replace(/[\n\r]/g, "")
267+
if (authenticated.has(session)) {
268+
const {token} = authenticated.get(session)
269+
try {
270+
console.log(await axios.delete(`https://api.github.com/applications/${conf.settings.oauth.id}/grant`, {auth:{username:conf.settings.oauth.id, password:conf.settings.oauth.secret}, headers:{Accept:"application/vnd.github+json"}, data:{access_token:token}}))
271+
authenticated.delete(session)
272+
console.debug(`metrics/app/oauth > deleted session ${session.substring(0, 6)}`)
273+
return res.redirect("/.oauth")
274+
}
275+
catch {} //eslint-disable-line no-empty
276+
}
277+
return res.status(400).send("Bad request: invalid session")
278+
})
279+
app.get("/.oauth/redirect", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/redirect.html`))
280+
app.get("/.oauth/enabled", limiter, (req, res) => res.json(true))
281+
}
282+
else
283+
app.get("/.oauth/enabled", limiter, (req, res) => res.json(false))
284+
175285
//Pending requests
176286
const pending = new Map()
177287

@@ -236,7 +346,7 @@ export default async function({sandbox = false} = {}) {
236346
}
237347
;(async () => {
238348
try {
239-
const json = await metrics.insights({login}, {graphql, rest, conf, callbacks}, {Plugins, Templates})
349+
const json = await metrics.insights({login}, {...api, ...uapi(req.headers["x-metrics-session"]), conf, callbacks}, {Plugins, Templates})
240350
//Cache
241351
cache.put(`insights.${login}`, json)
242352
if ((!debug) && (cached)) {
@@ -289,12 +399,14 @@ export default async function({sandbox = false} = {}) {
289399
app.get("/.js/embed/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.js`))
290400
app.get("/.js/embed/app.placeholder.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.placeholder.js`))
291401
//App routes
292-
app.get("/:login/:repository?", ...middlewares, async (req, res) => {
402+
app.get("/:login/:repository?", ...middlewares, async (req, res, next) => {
293403
//Request params
294404
const login = req.params.login?.replace(/[\n\r]/g, "")
295405
const repository = req.params.repository?.replace(/[\n\r]/g, "")
296406
let solve = null
297407
//Check username
408+
if ((login.startsWith("."))||(login.includes("/")))
409+
return next()
298410
if (!/^[-\w]+$/i.test(login)) {
299411
console.debug(`metrics/app/${login} > 400 (invalid username)`)
300412
return res.status(400).send("Bad request: username seems invalid")
@@ -335,19 +447,28 @@ export default async function({sandbox = false} = {}) {
335447

336448
//Compute rendering
337449
try {
338-
//Render
450+
//Prepare settings
339451
const q = req.query
340452
console.debug(`metrics/app/${login} > ${util.inspect(q, {depth: Infinity, maxStringLength: 256})}`)
341-
if ((q["config.presets"]) && ((conf.settings.extras?.features?.includes("metrics.setup.community.presets")) || (conf.settings.extras?.features === true) || (conf.settings.extras?.default))) {
453+
const octokit = {...api, ...uapi(req.headers["x-metrics-session"])}
454+
let uconf = conf
455+
if ((octokit.login)&&(conf.settings.extras?.logged)&&(uconf.settings.extras?.features !== true)) {
456+
console.debug(`metrics/app/${login} > session is authenticated, adding additional permissions ${conf.settings.extras.logged}`)
457+
uconf = {...conf, settings:{...conf.settings, extras:{...conf.settings.extras}}}
458+
uconf.settings.extras.features = uconf.settings.extras.features ?? []
459+
uconf.settings.extras.features.push(...conf.settings.extras.logged)
460+
}
461+
//Preset
462+
if ((q["config.presets"]) && ((uconf.settings.extras?.features?.includes("metrics.setup.community.presets")) || (uconf.settings.extras?.features === true) || (uconf.settings.extras?.default))) {
342463
console.debug(`metrics/app/${login} > presets have been specified, loading them`)
343464
Object.assign(q, await presets(q["config.presets"]))
344465
}
345-
const convert = conf.settings.outputs.includes(q["config.output"]) ? q["config.output"] : conf.settings.outputs[0]
466+
//Render
467+
const convert = uconf.settings.outputs.includes(q["config.output"]) ? q["config.output"] : uconf.settings.outputs[0]
346468
const {rendered, mime} = await metrics({login, q}, {
347-
graphql,
348-
rest,
469+
...octokit,
349470
plugins,
350-
conf,
471+
conf:uconf,
351472
die: q["plugins.errors.fatal"] ?? false,
352473
verify: q.verify ?? false,
353474
convert: convert !== "auto" ? convert : null,
@@ -441,9 +562,11 @@ export default async function({sandbox = false} = {}) {
441562
"── Content ────────────────────────────────────────────────────────",
442563
`Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`,
443564
`Templates enabled │ ${templates.filter(({enabled}) => enabled).map(({name}) => name).join(", ")}`,
565+
"── OAuth ──────────────────────────────────────────────────────────",
566+
`Client id │ ${conf.settings.oauth?.id ?? "(none)"}`,
444567
"── Extras ─────────────────────────────────────────────────────────",
445568
`Default │ ${conf.settings.extras?.default ?? false}`,
446-
`Features │ ${conf.settings.extras?.features ?? "(none)"}`,
569+
`Features │ ${Array.isArray(conf.settings.extras?.features) ? conf.settings.extras.features?.length ? conf.settings.extras?.features : "(none)" : "(default)"}`,
447570
"───────────────────────────────────────────────────────────────────",
448571
"Server ready !",
449572
].join("\n")))

0 commit comments

Comments
 (0)