diff --git a/source/app/metrics/metadata.mjs b/source/app/metrics/metadata.mjs
index fbae3ea1364..dd841e47eac 100644
--- a/source/app/metrics/metadata.mjs
+++ b/source/app/metrics/metadata.mjs
@@ -104,6 +104,9 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) {
if ((meta.category === "github") && (!meta.disclaimer))
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."
+ //Deprecation
+ meta.deprecated = !!meta?.deprecation
+
//Inputs parser
{
meta.inputs = function({data: {user = null} = {}, q, account}, defaults = {}) {
@@ -345,30 +348,30 @@ metadata.plugin = async function({__plugins, __templates, name, logger}) {
//Web metadata
{
meta.web = Object.fromEntries(
- Object.entries(inputs).map(([key, {type, description: text, example, default: defaulted, min = 0, max = 9999, values}]) => [
+ Object.entries(inputs).map(([key, {type, description: text, example, default: defaulted, min = 0, max = 9999, values, extras}]) => [
//Format key
metadata.to.query(key),
//Value descriptor
(() => {
switch (type) {
case "boolean":
- 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}
+ 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}
case "number":
- return {text, type: "number", min, max, defaulted}
+ return {text, type: "number", min, max, defaulted, extras}
case "array":
- return {text, type: "text", placeholder: example ?? defaulted, defaulted}
+ return {text, type: "text", placeholder: example ?? defaulted, defaulted, extras}
case "string": {
if (Array.isArray(values))
return {text, type: "select", values, defaulted}
- return {text, type: "text", placeholder: example ?? defaulted, defaulted}
+ return {text, type: "text", placeholder: example ?? defaulted, defaulted, extras}
}
case "json":
- return {text, type: "text", placeholder: example ?? defaulted, defaulted}
+ return {text, type: "text", placeholder: example ?? defaulted, defaulted, extras}
default:
return null
}
})(),
- ]).filter(([key, value]) => (value) && (key !== name)),
+ ]).filter(([key, value]) => (value)&&(!((name === "base")&&(key === "repositories")))),
)
}
diff --git a/source/app/web/instance.mjs b/source/app/web/instance.mjs
index 1e9ebf23614..3931c90253c 100644
--- a/source/app/web/instance.mjs
+++ b/source/app/web/instance.mjs
@@ -6,10 +6,13 @@ import express from "express"
import ratelimit from "express-rate-limit"
import cache from "memory-cache"
import util from "util"
+import url from "url"
+import axios from "axios"
import mocks from "../../../tests/mocks/index.mjs"
import metrics from "../metrics/index.mjs"
import presets from "../metrics/presets.mjs"
import setup from "../metrics/setup.mjs"
+import crypto from "crypto"
/**App */
export default async function({sandbox = false} = {}) {
@@ -55,7 +58,20 @@ export default async function({sandbox = false} = {}) {
//Apply mocking if needed
if (mock)
Object.assign(api, await mocks(api))
- const {graphql, rest} = api
+ //Custom user octokits sessions
+ const authenticated = new Map()
+ const uapi = session => {
+ if (!/^[a-f0-9]+$/i.test(`${session}`))
+ return null
+ if (authenticated.has(session)) {
+ const {login, token} = authenticated.get(session)
+ console.debug(`metrics/app/session/${login} > authenticated with session ${session.substring(0, 6)}, using custom octokit`)
+ return {login, graphql: octokit.graphql.defaults({headers: {authorization: `token ${token}`}}), rest: new OctokitRest.Octokit({auth: token})}
+ }
+ else if (session)
+ console.debug(`metrics/app/session > unknown session ${session.substring(0, 6)}, using default octokit`)
+ return null
+ }
//Setup server
const app = express()
@@ -87,22 +103,19 @@ export default async function({sandbox = false} = {}) {
const limiter = ratelimit({max: debug ? Number.MAX_SAFE_INTEGER : 60, windowMs: 60 * 1000, headers: false})
const metadata = Object.fromEntries(
Object.entries(conf.metadata.plugins)
- .map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "category", "web", "supports", "scopes"].includes(key)))])
+ .map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "category", "web", "supports", "scopes", "deprecated"].includes(key)))])
.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]),
)
- const enabled = Object.entries(metadata).filter(([_name, {category}]) => category !== "core").map(([name]) => ({name, category: metadata[name]?.category ?? "community", enabled: plugins[name]?.enabled ?? false}))
+ 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}))
const templates = Object.entries(Templates).map(([name]) => ({name, enabled: (conf.settings.templates.enabled.length ? conf.settings.templates.enabled.includes(name) : true) ?? false}))
const actions = {flush: new Map()}
- const requests = {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}}
+ 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}}
let _requests_refresh = false
if (!conf.settings.notoken) {
const refresh = async () => {
try {
- const {limit} = await graphql("{ limit:rateLimit {limit remaining reset:resetAt used} }")
- Object.assign(requests, {
- rest: (await rest.rateLimit.get()).data.rate,
- graphql: {...limit, reset: new Date(limit.reset).getTime()},
- })
+ const {resources} = (await api.rest.rateLimit.get()).data
+ Object.assign(requests, {rest: resources.core, graphql: resources.graphql, search: resources.search})
}
catch {
console.debug("metrics/app > failed to update remaining requests")
@@ -130,8 +143,16 @@ export default async function({sandbox = false} = {}) {
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))
for (const template in conf.templates)
app.use(`/.templates/${template}/partials`, express.static(`${conf.paths.templates}/${template}/partials`))
- //Modes
+ //Modes and extras
app.get("/.modes", limiter, (req, res) => res.status(200).json(conf.settings.modes))
+ app.get("/.extras", limiter, async (req, res) => {
+ if ((authenticated.has(req.headers["x-metrics-session"]))&&(conf.settings.extras?.logged)) {
+ if (conf.settings.extras?.features !== true)
+ return res.status(200).json([...conf.settings.extras.features, ...conf.settings.extras.logged])
+ }
+ res.status(200).json(conf.settings.extras?.features ?? conf.settings?.extras?.default ?? false)
+ })
+ app.get("/.extras.logged", limiter, async (req, res) => res.status(200).json(conf.settings.extras?.logged ?? []))
//Styles
app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.css`))
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} = {}) {
app.get("/.js/clipboard.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/clipboard/dist/clipboard.min.js`))
//Meta
app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version))
- app.get("/.requests", limiter, (req, res) => res.status(200).json(requests))
+ app.get("/.requests", limiter, async (req, res) => {
+ try {
+ const custom = uapi(req.headers["x-metrics-session"])
+ if (custom) {
+ const {data:{resources}} = await custom.rest.rateLimit.get()
+ if (resources)
+ return res.status(200).json({rest:resources.core, graphql:resources.graphql, search:resources.search, login:custom.login})
+ }
+ }
+ catch {} //eslint-disable-line no-empty
+ return res.status(200).json(requests)
+ })
app.get("/.hosted", limiter, (req, res) => res.status(200).json(conf.settings.hosted || null))
//Cache
app.get("/.uncache", limiter, (req, res) => {
@@ -172,6 +204,84 @@ export default async function({sandbox = false} = {}) {
}
})
+ //OAuth
+ if (conf.settings.oauth) {
+ console.debug("metrics/app/oauth > enabled")
+ const states = new Map()
+ app.get("/.oauth/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/index.html`))
+ app.get("/.oauth/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/index.html`))
+ app.get("/.oauth/script.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/script.js`))
+ app.get("/.oauth/authenticate", (req, res) => {
+ //Create a state to protect against cross-site request forgery attacks
+ const state = crypto.randomBytes(64).toString("hex")
+ const scopes = new url.URLSearchParams(req.query).get("scopes")
+ const from = new url.URLSearchParams(req.query).get("scopes")
+ states.set(state, {from, scopes})
+ console.debug(`metrics/app/oauth > request ${state}`)
+ //OAuth through GitHub
+ return res.redirect(`https://github.com/login/oauth/authorize?${new url.URLSearchParams({
+ client_id:conf.settings.oauth.id,
+ state,
+ redirect_uri:`${conf.settings.oauth.url}/.oauth/authorize`,
+ allow_signup:false,
+ scope:scopes,
+ })}`)
+ })
+ app.get("/.oauth/authorize", async (req, res) => {
+ //Check state
+ const {code, state} = req.query
+ if ((!state)||(!states.has(state))) {
+ console.debug("metrics/app/oauth > 400 (invalid state)")
+ return res.status(400).send("Bad request: invalid state")
+ }
+ //OAuth
+ try {
+ //Authorize user
+ console.debug("metrics/app/oauth > authorization")
+ const {data} = await axios.post("https://github.com/login/oauth/access_token", `${new url.URLSearchParams({
+ client_id:conf.settings.oauth.id,
+ client_secret:conf.settings.oauth.secret,
+ code,
+ })}`)
+ const token = new url.URLSearchParams(data).get("access_token")
+ //Validate user
+ const {data:{login}} = await axios.get("https://api.github.com/user", {headers:{Authorization:`token ${token}`}})
+ console.debug(`metrics/app/oauth > authorization success for ${login}`)
+ const session = crypto.randomBytes(128).toString("hex")
+ authenticated.set(session, {login, token})
+ console.debug(`metrics/app/oauth > created session ${session.substring(0, 6)}`)
+ //Redirect user back
+ const {from} = states.get(state)
+ return res.redirect(`/.oauth/redirect?${new url.URLSearchParams({to:from, session})}`)
+ }
+ catch {
+ console.debug("metrics/app/oauth > authorization failed")
+ return res.status(401).send("Unauthorized: oauth failed")
+ }
+ finally {
+ states.delete(state)
+ }
+ })
+ app.get("/.oauth/revoke/:session", limiter, async (req, res) => {
+ const session = req.params.session?.replace(/[\n\r]/g, "")
+ if (authenticated.has(session)) {
+ const {token} = authenticated.get(session)
+ try {
+ 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}}))
+ authenticated.delete(session)
+ console.debug(`metrics/app/oauth > deleted session ${session.substring(0, 6)}`)
+ return res.redirect("/.oauth")
+ }
+ catch {} //eslint-disable-line no-empty
+ }
+ return res.status(400).send("Bad request: invalid session")
+ })
+ app.get("/.oauth/redirect", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/oauth/redirect.html`))
+ app.get("/.oauth/enabled", limiter, (req, res) => res.json(true))
+ }
+ else
+ app.get("/.oauth/enabled", limiter, (req, res) => res.json(false))
+
//Pending requests
const pending = new Map()
@@ -236,7 +346,7 @@ export default async function({sandbox = false} = {}) {
}
;(async () => {
try {
- const json = await metrics.insights({login}, {graphql, rest, conf, callbacks}, {Plugins, Templates})
+ const json = await metrics.insights({login}, {...api, ...uapi(req.headers["x-metrics-session"]), conf, callbacks}, {Plugins, Templates})
//Cache
cache.put(`insights.${login}`, json)
if ((!debug) && (cached)) {
@@ -289,12 +399,14 @@ export default async function({sandbox = false} = {}) {
app.get("/.js/embed/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.js`))
app.get("/.js/embed/app.placeholder.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/embed/app.placeholder.js`))
//App routes
- app.get("/:login/:repository?", ...middlewares, async (req, res) => {
+ app.get("/:login/:repository?", ...middlewares, async (req, res, next) => {
//Request params
const login = req.params.login?.replace(/[\n\r]/g, "")
const repository = req.params.repository?.replace(/[\n\r]/g, "")
let solve = null
//Check username
+ if ((login.startsWith("."))||(login.includes("/")))
+ return next()
if (!/^[-\w]+$/i.test(login)) {
console.debug(`metrics/app/${login} > 400 (invalid username)`)
return res.status(400).send("Bad request: username seems invalid")
@@ -335,19 +447,28 @@ export default async function({sandbox = false} = {}) {
//Compute rendering
try {
- //Render
+ //Prepare settings
const q = req.query
console.debug(`metrics/app/${login} > ${util.inspect(q, {depth: Infinity, maxStringLength: 256})}`)
- if ((q["config.presets"]) && ((conf.settings.extras?.features?.includes("metrics.setup.community.presets")) || (conf.settings.extras?.features === true) || (conf.settings.extras?.default))) {
+ const octokit = {...api, ...uapi(req.headers["x-metrics-session"])}
+ let uconf = conf
+ if ((octokit.login)&&(conf.settings.extras?.logged)&&(uconf.settings.extras?.features !== true)) {
+ console.debug(`metrics/app/${login} > session is authenticated, adding additional permissions ${conf.settings.extras.logged}`)
+ uconf = {...conf, settings:{...conf.settings, extras:{...conf.settings.extras}}}
+ uconf.settings.extras.features = uconf.settings.extras.features ?? []
+ uconf.settings.extras.features.push(...conf.settings.extras.logged)
+ }
+ //Preset
+ if ((q["config.presets"]) && ((uconf.settings.extras?.features?.includes("metrics.setup.community.presets")) || (uconf.settings.extras?.features === true) || (uconf.settings.extras?.default))) {
console.debug(`metrics/app/${login} > presets have been specified, loading them`)
Object.assign(q, await presets(q["config.presets"]))
}
- const convert = conf.settings.outputs.includes(q["config.output"]) ? q["config.output"] : conf.settings.outputs[0]
+ //Render
+ const convert = uconf.settings.outputs.includes(q["config.output"]) ? q["config.output"] : uconf.settings.outputs[0]
const {rendered, mime} = await metrics({login, q}, {
- graphql,
- rest,
+ ...octokit,
plugins,
- conf,
+ conf:uconf,
die: q["plugins.errors.fatal"] ?? false,
verify: q.verify ?? false,
convert: convert !== "auto" ? convert : null,
@@ -441,9 +562,11 @@ export default async function({sandbox = false} = {}) {
"── Content ────────────────────────────────────────────────────────",
`Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`,
`Templates enabled │ ${templates.filter(({enabled}) => enabled).map(({name}) => name).join(", ")}`,
+ "── OAuth ──────────────────────────────────────────────────────────",
+ `Client id │ ${conf.settings.oauth?.id ?? "(none)"}`,
"── Extras ─────────────────────────────────────────────────────────",
`Default │ ${conf.settings.extras?.default ?? false}`,
- `Features │ ${conf.settings.extras?.features ?? "(none)"}`,
+ `Features │ ${Array.isArray(conf.settings.extras?.features) ? conf.settings.extras.features?.length ? conf.settings.extras?.features : "(none)" : "(default)"}`,
"───────────────────────────────────────────────────────────────────",
"Server ready !",
].join("\n")))
diff --git a/source/app/web/settings.example.json b/source/app/web/settings.example.json
index f1381d0a0d6..b0ab88af8e1 100644
--- a/source/app/web/settings.example.json
+++ b/source/app/web/settings.example.json
@@ -20,6 +20,11 @@
"by": "", "//": "Web instance host (displayed in footer)",
"link": "", "//": "Web instance host link (displayed in footer)"
},
+ "oauth":{
+ "id": null, "//": "GitHub OAUTH client id",
+ "secret": null, "//": "GitHub OAUTH client secret",
+ "url":"https://example.com", "//": "GitHub OAUTH callback url (must be the same as the web instance host)"
+ },
"control":{
"token": null, "//": "Control token (can be used by external services to perform actions on instance, such as stopping it for redeploys)"
},
@@ -32,7 +37,6 @@
},
"extras": {
"default": false, "//": "Default extras state (advised to let 'false' unless in debug mode)",
- "presets": false, "//": "Allow use of 'config.presets' option",
"features": false, "//": "Enable extra features (advised to let 'false' on web instances), see below for supported features",
"//": "________________________________________________________________________",
"//": "metrics.setup.community.templates | Allow community templates download",
@@ -47,7 +51,12 @@
"//": "metrics.run.puppeteer.scrapping | Allow to run puppeteer to scrape data",
"//": "metrics.run.puppeteer.user.css | Allow to run CSS by user during puppeteer render",
"//": "metrics.run.puppeteer.user.js | Allow to run JavaScript by user during puppeteer render",
- "//": "metrics.npm.optional.* | Allow use of specified dependency"
+ "//": "metrics.npm.optional.* | Allow use of specified dependency",
+ "//": "________________________________________________________________________",
+ "//": "Additional extra features when user is logged with GitHub",
+ "logged": [
+ "metrics.api.github.overuse"
+ ]
},
"plugins.default": false, "//": "Default plugin state (advised to let 'false' unless in debug mode)",
"plugins": { "//": "Global plugin configuration",
diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js
index d8e0e7b60b0..ab7f7136933 100644
--- a/source/app/web/statics/app.js
+++ b/source/app/web/statics/app.js
@@ -7,6 +7,8 @@
//Interpolate config from browser
try {
this.palette = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
+ if (localStorage.getItem("session.metrics"))
+ axios.defaults.headers.common["x-metrics-session"] = localStorage.getItem("session.metrics")
}
catch (error) {}
//Init
@@ -31,6 +33,11 @@
const {data: modes} = await axios.get("/.modes")
this.modes = modes
})(),
+ //OAuth
+ (async () => {
+ const {data: enabled} = await axios.get("/.oauth/enabled")
+ this.oauth = enabled
+ })(),
])
},
//Watchers
@@ -49,12 +56,17 @@
user1: "",
user2: "",
palette: "light",
- requests: {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}},
+ 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}},
hosted: null,
modes: [],
+ oauth: false,
},
//Computed data
computed: {
+ //URL parameters
+ params() {
+ return new URLSearchParams({from:location.href})
+ },
//Is in preview mode
preview() {
return /-preview$/.test(this.version)
diff --git a/source/app/web/statics/embed/app.js b/source/app/web/statics/embed/app.js
index 8ec77e34d57..d5393ddfc87 100644
--- a/source/app/web/statics/embed/app.js
+++ b/source/app/web/statics/embed/app.js
@@ -12,6 +12,8 @@
try {
this.config.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
this.palette = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
+ if (localStorage.getItem("session.metrics"))
+ axios.defaults.headers.common["x-metrics-session"] = localStorage.getItem("session.metrics")
}
catch (error) {}
//Init
@@ -41,6 +43,11 @@
this.plugins.base = base
this.plugins.enabled.base = Object.fromEntries(base.map(key => [key, true]))
})(),
+ //Extras
+ (async () => {
+ const {data: extras} = await axios.get("/.extras")
+ this.extras = extras
+ })(),
//Version
(async () => {
const {data: version} = await axios.get("/.version")
@@ -51,6 +58,11 @@
const {data: hosted} = await axios.get("/.hosted")
this.hosted = hosted
})(),
+ //OAuth
+ (async () => {
+ const {data: enabled} = await axios.get("/.oauth/enabled")
+ this.oauth = enabled
+ })(),
])
//Generate placeholder
this.mock({timeout: 200})
@@ -89,11 +101,13 @@
tab: "overview",
palette: "light",
clipboard: null,
- requests: {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}},
+ 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}},
cached: new Map(),
config: Object.fromEntries(Object.entries(metadata.core.web).map(([key, {defaulted}]) => [key, defaulted])),
metadata: Object.fromEntries(Object.entries(metadata).map(([key, {web}]) => [key, web])),
hosted: null,
+ extras: false,
+ oauth: false,
docs: {
overview: {
link: "https://github.com/lowlighter/metrics#-documentation",
@@ -154,9 +168,19 @@
},
//Computed data
computed: {
+ //URL parameters
+ params() {
+ return new URLSearchParams({from:location.href})
+ },
//Unusable plugins
unusable() {
- return this.plugins.list.filter(({name}) => this.plugins.enabled[name]).filter(({enabled}) => !enabled).map(({name}) => name)
+ const plugins = Object.entries(this.plugins.enabled).filter(([key, value]) => (value == true)&&(!this.supports(this.plugins.options.descriptions[key]))).map(([key]) => key)
+ const options = this.edited.filter(option => !this.supports(this.plugins.options.descriptions[option]))
+ return [...plugins, ...options].sort()
+ },
+ //Edited plugins options
+ edited() {
+ return Object.keys(this.plugins.enabled).flatMap(plugin => Object.keys(this.options({name:plugin})).filter(key => this.plugins.options[key] !== metadata[plugin]?.web[key]?.defaulted))
},
//User's avatar
avatar() {
@@ -246,19 +270,6 @@
].sort(),
].join("\n")
},
- //Configurable plugins
- configure() {
- //Check enabled plugins
- const enabled = Object.entries(this.plugins.enabled).filter(([key, value]) => (value) && (key !== "base")).map(([key, value]) => key)
- const filter = new RegExp(`^(?:${enabled.join("|")})[.]`)
- //Search related options
- const entries = Object.entries(this.plugins.options.descriptions).filter(([key, value]) => (filter.test(key)) && (!(key in metadata.base.web)))
- entries.push(...enabled.map(key => [key, this.plugins.descriptions[key]]))
- entries.sort((a, b) => a[0].localeCompare(b[0]))
- //Return object
- const configure = Object.fromEntries(entries)
- return Object.keys(configure).length ? configure : null
- },
//Is in preview mode
preview() {
return /-preview$/.test(this.version)
@@ -327,6 +338,21 @@
catch {}
}
},
+ //Get available options from plugin
+ options({name}) {
+ return Object.fromEntries(Object.entries(this.plugins.options.descriptions).filter(([key]) => ((key.startsWith(`${name}.`))||(key === name)) && (!(key in metadata.base.web))))
+ },
+ //Check if option is supported
+ supports(option) {
+ if (!option)
+ return false
+ const {extras:required = null} = option
+ if (!Array.isArray(required))
+ return true
+ if (!Array.isArray(this.extras))
+ return this.extras
+ return required.filter(permission => !this.extras.includes(permission)).length === 0
+ }
},
})
})()
diff --git a/source/app/web/statics/embed/index.html b/source/app/web/statics/embed/index.html
index a6e5f4484ea..37da85368e3 100644
--- a/source/app/web/statics/embed/index.html
+++ b/source/app/web/statics/embed/index.html
@@ -20,6 +20,16 @@
@@ -55,81 +65,103 @@
Generate your metrics!
-
Remaining GitHub requests:
-
{{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL
+
Remaining GitHub requests for {{ requests.login }} :
+
{{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL / {{ requests.search.remaining }} search
Metrics are rendered by metrics.lecoq.io in preview mode.
Any backend editions won't be reflected but client-side rendering can still be tested.
- The following plugins are not available on this web instance: {{ unusable.join(", ") }}
+ The following plugins options are not available on this web instance: {{ unusable.join(", ") }}
This web instance has run out of GitHub API requests.
Please wait until {{ rlreset }} to generate metrics again.
-
- 🖼️ Template
-
-
- {{ templates.descriptions[template.name] || template.name }}
-
-
-
-
-
🗃️ Base content
-
-
- {{ plugins.descriptions[`base.${part}`] || `base.${part}` }}
-
+
-
-
🧩 Additional plugins
-
-
- {{ name }}
-
+
+
+
diff --git a/source/app/web/statics/index.html b/source/app/web/statics/index.html
index fccff939af4..7be9f3b7930 100644
--- a/source/app/web/statics/index.html
+++ b/source/app/web/statics/index.html
@@ -19,6 +19,16 @@
@@ -72,7 +82,7 @@
This web instance has run out of GitHub API requests.
Please wait until {{ rlreset }} to generate metrics again.
-
Remaining GitHub requests: {{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL
+
Remaining GitHub requests for {{ requests.login }} : {{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL / {{ requests.search.remaining }} search
Send feedback on GitHub discussions !
diff --git a/source/app/web/statics/insights/index.html b/source/app/web/statics/insights/index.html
index a4e0e871bfb..30ef4e37d86 100644
--- a/source/app/web/statics/insights/index.html
+++ b/source/app/web/statics/insights/index.html
@@ -20,6 +20,16 @@
@@ -33,7 +43,7 @@
Search a GitHub user
-
Remaining GitHub requests: {{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL
+
Remaining GitHub requests for {{ requests.login }} : {{ requests.rest.remaining }} REST / {{ requests.graphql.remaining }} GraphQL / {{ requests.search.remaining }} search
Send feedback on GitHub discussions !
diff --git a/source/app/web/statics/insights/script.js b/source/app/web/statics/insights/script.js
index 6798bac8e3b..a4a54015a35 100644
--- a/source/app/web/statics/insights/script.js
+++ b/source/app/web/statics/insights/script.js
@@ -7,6 +7,8 @@
//Palette
try {
this.palette = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
+ if (localStorage.getItem("session.metrics"))
+ axios.defaults.headers.common["x-metrics-session"] = localStorage.getItem("session.metrics")
}
catch (error) {}
//Embed
@@ -44,6 +46,11 @@
const {data: hosted} = await axios.get("/.hosted")
this.hosted = hosted
})(),
+ //OAuth
+ (async () => {
+ const {data: enabled} = await axios.get("/.oauth/enabled")
+ this.oauth = enabled
+ })(),
])
},
//Watchers
@@ -155,6 +162,9 @@
},
//Computed properties
computed: {
+ params() {
+ return new URLSearchParams({from:location.href})
+ },
stats() {
return this.metrics?.rendered.user ?? null
},
@@ -246,10 +256,11 @@
embed: false,
localstorage: false,
searchable: false,
- requests: {rest: {limit: 0, used: 0, remaining: 0, reset: NaN}, graphql: {limit: 0, used: 0, remaining: 0, reset: NaN}},
+ 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}},
palette: "light",
metrics: null,
pending: false,
+ oauth: false,
error: null,
config: {},
progress: 0,
diff --git a/source/app/web/statics/oauth/index.html b/source/app/web/statics/oauth/index.html
new file mode 100644
index 00000000000..ff5d7993e12
--- /dev/null
+++ b/source/app/web/statics/oauth/index.html
@@ -0,0 +1,98 @@
+
+
+
+
+
Metrics
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Signing in with your GitHub account lets you use this web instance with your own API requests quota.
+
+
+ The following extra features permissions will be granted when logged with your GitHub account:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/source/app/web/statics/oauth/redirect.html b/source/app/web/statics/oauth/redirect.html
new file mode 100644
index 00000000000..9bd39fb0b9a
--- /dev/null
+++ b/source/app/web/statics/oauth/redirect.html
@@ -0,0 +1,26 @@
+
+
+
+
+
Metrics
+
+
+
+
+
+
+
+ Redirecting...
+
+
+
\ No newline at end of file
diff --git a/source/app/web/statics/oauth/script.js b/source/app/web/statics/oauth/script.js
new file mode 100644
index 00000000000..9687bffba69
--- /dev/null
+++ b/source/app/web/statics/oauth/script.js
@@ -0,0 +1,79 @@
+;(async function() {
+ //App
+ return new Vue({
+ //Initialization
+ el: "main",
+ async mounted() {
+ //Palette
+ try {
+ this.palette = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
+ if (localStorage.getItem("session.metrics")) {
+ this.session = localStorage.getItem("session.metrics")
+ axios.defaults.headers.common["x-metrics-session"] = localStorage.getItem("session.metrics")
+ }
+ }
+ catch (error) {}
+ //Init
+ await Promise.all([
+ //GitHub limit tracker
+ (async () => {
+ const {data: requests} = await axios.get("/.requests")
+ this.requests = requests
+ })(),
+ //Version
+ (async () => {
+ const {data: version} = await axios.get("/.version")
+ this.version = `v${version}`
+ })(),
+ //Hosted
+ (async () => {
+ const {data: hosted} = await axios.get("/.hosted")
+ this.hosted = hosted
+ })(),
+ //OAuth
+ (async () => {
+ const {data: enabled} = await axios.get("/.oauth/enabled")
+ this.oauth = enabled
+ })(),
+ //OAuth
+ (async () => {
+ const {data: extras} = await axios.get("/.extras.logged")
+ this.extras = extras
+ })(),
+ ])
+ },
+ //Watchers
+ watch: {
+ palette: {
+ immediate: true,
+ handler(current, previous) {
+ document.querySelector("body").classList.remove(previous)
+ document.querySelector("body").classList.add(current)
+ },
+ },
+ },
+ //Computed properties
+ computed: {
+ params() {
+ return new URLSearchParams({from:new URLSearchParams(location.search).get("from"), scopes:this.scopes.join(" ")})
+ },
+ preview() {
+ return /-preview$/.test(this.version)
+ },
+ beta() {
+ return /-beta$/.test(this.version)
+ },
+ },
+ //Data initialization
+ data: {
+ version: "",
+ hosted: null,
+ 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}},
+ palette: "light",
+ oauth: false,
+ scopes:[],
+ extras: [],
+ session: null,
+ },
+ })
+})()
diff --git a/source/app/web/statics/style.css b/source/app/web/statics/style.css
index 31a1f998ec2..6168f0c51f2 100644
--- a/source/app/web/statics/style.css
+++ b/source/app/web/statics/style.css
@@ -46,6 +46,10 @@
font-size: 1.5rem;
}
+ header .grow {
+ flex-grow: 1;
+ }
+
/* Interface */
.ui {
display: flex;
@@ -128,11 +132,22 @@
}
.configuration {
+ background-image: linear-gradient(var(--color-alert-info-bg),var(--color-alert-info-bg));
+ color: var(--color-alert-info-text);
+ border: 1px solid var(--color-alert-info-border);
+ border-radius: 6px;
+ margin-top: .5rem;
display: flex;
flex-direction: column;
- padding-top: 1rem;
- margin: 1rem .5rem 0;
- border-top: 1px solid var(--color-border-primary);
+ }
+
+ .configuration .options:not(:empty) {
+ margin-top: .25rem;
+ border-top: 1px solid var(--color-alert-info-border);
+ }
+
+ .configuration.plugins {
+ width: 100%;
}
.configuration.plugins label {
@@ -140,16 +155,32 @@
align-items: flex-start;
}
- .configuration .not-available {
+ .configuration.plugins .name {
+ font-weight: 500;
+ }
+
+ .configuration.not-available {
color: var(--color-text-secondary);
}
- .configuration details {
+ .configuration.deprecated {
+ background-image: linear-gradient(var(--color-alert-warn-bg),var(--color-alert-info-bg));
+ color: var(--color-alert-warn-text);
+ border: 1px solid var(--color-alert-warn-border);
+ opacity: .8;
+ }
+
+ .configuration .not-available.deprecated {
+ display: none;
+ }
+
+ .category details {
display: flex;
flex-direction: column;
+ width: 100%;
}
- .configuration summary {
+ .category summary {
font-weight: bold;
text-transform: capitalize;
}
@@ -157,6 +188,20 @@
.option {
display: flex;
flex-direction: column;
+ padding: .25rem;
+ overflow: hidden;
+ }
+
+ .option input[type=text], .option input[type=number], .option select {
+ width: 100%;
+ }
+
+ .option.unsupported {
+ background-image: linear-gradient(var(--color-bg-secondary),var(--color-bg-tertiary));
+ color: var(--color-text-secondary);
+ border-top: 1px solid var(--color-border-secondary);
+ border-bottom: 1px solid var(--color-border-secondary);
+ opacity: .8;
}
/* Preview */
@@ -211,7 +256,6 @@
}
label:hover {
background-color: var(--color-input-contrast-bg);
- border-radius: 6px;
}
input[type=text], input[type=number], select {
@@ -260,6 +304,32 @@
outline: none;
}
+ .oauth-github {
+ color: var(--color-btn-primary-text);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: .4rem .6rem;
+ border-radius: 6px;
+ font-weight: 500;
+ background-color: var(--color-input-bg);
+ border: 1px solid var(--color-input-border);
+ cursor: pointer;
+ font-size: 1rem;
+ }
+ .oauth-github.disabled {
+ opacity: .5;
+ pointer-events: none;
+ }
+ .oauth-github:hover {
+ text-decoration: none;
+ }
+ .oauth-github svg {
+ height: 1rem;
+ width: 1rem;
+ margin-right: .5rem;
+ }
+
/* Links */
a, a:hover, a:visited {
color: var(--color-text-link);
@@ -386,6 +456,43 @@
margin-bottom: 1rem;
}
+/* */
+ .badges-oauth {
+ display: flex;
+ align-items: center ;
+ }
+ .badges-oauth .border {
+ width: 4rem;
+ border: 3px dashed var(--color-border-secondary);
+ }
+ .badge-oauth {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ box-shadow: var(--color-shadow-medium);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: #0D1117;
+ }
+ .oauth-scopes {
+ display: flex;
+ }
+ .oauth-scopes label {
+ margin: .5rem;
+ }
+ .oauth-revoke {
+ color: var(--color-text-danger);
+ background-color: var(--color-bg-danger);
+ border-color: var(--color-border-danger);
+ cursor: pointer;
+ }
+ .oauth small {
+ font-size: .8rem;
+ color: var(--color-text-secondary);
+ max-width: 80%;
+ }
+
/* Search */
.search {
display: flex;