diff --git a/source/app/metrics/metadata.mjs b/source/app/metrics/metadata.mjs index 63593762cb7..b9e7a6020a2 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 @@
Metrics Embed {{ version }} +
+ + + + +
@@ -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 - -
- -
- 🗃️ Base content - +
+
+ +
+ +
+
-
- 🧩 Additional plugins - -
- -
- 🔧 Configure plugins - -
- -
-
- ⚙️ Additional settings - +
+
+ +
+ +
+
+ Core +
+ +
+ + +
+
+
+ +
+ +
+
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 @@
Metrics {{ version }} +
+ + + + +
@@ -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 @@
Metrics Insights {{ version }} +
+ + + + +
@@ -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 + + + + + + + + + + + +
+ +
+ + + + + + + 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;