From 66d7c79acb39e0beac3c1005efba6b09eb922306 Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Sat, 20 Feb 2021 19:50:15 +0100 Subject: [PATCH] Improve web instance (#149) --- .github/readme/partials/setup/web.md | 16 -------- source/app/web/instance.mjs | 47 +++++++++++++++-------- source/app/web/settings.example.json | 4 ++ source/app/web/statics/app.js | 28 ++++++++------ source/app/web/statics/app.placeholder.js | 2 +- source/app/web/statics/index.html | 35 +++++++++++++++-- source/app/web/statics/style.css | 40 +++++++++++++++++++ source/plugins/core/metadata.yml | 1 + 8 files changed, 124 insertions(+), 49 deletions(-) diff --git a/.github/readme/partials/setup/web.md b/.github/readme/partials/setup/web.md index 8911887f..40afeae2 100644 --- a/.github/readme/partials/setup/web.md +++ b/.github/readme/partials/setup/web.md @@ -101,22 +101,6 @@ systemctl status github_metrics - -⚠️ HTTP errors code - -Following error codes may be encountered on web instance: - -| Error code | Description | -| ------------------------- | -------------------------------------------------------------------------- | -| `400 Bad request` | Invalid query (e.g. unsupported template) | -| `403 Forbidden` | User not allowed in `restricted` users list | -| `404 Not found` | GitHub API did not found the requested user | -| `429 Too many requests` | Thrown when rate limiter is trigerred | -| `500 Internal error` | Server error while generating metrics images (check logs for more details) | -| `503 Service unavailable` | Maximum user capacity reached, only cached images can be accessed for now | - - - 🔗 HTTP parameters diff --git a/source/app/web/instance.mjs b/source/app/web/instance.mjs index c41cd10f..77a6b279 100644 --- a/source/app/web/instance.mjs +++ b/source/app/web/instance.mjs @@ -63,7 +63,8 @@ skip(req, _res) { return !!cache.get(req.params.login) }, - message:"Too many requests", + message:"Too many requests: retry later", + headers:true, ...ratelimiter, })) } @@ -74,11 +75,11 @@ }) //Base routes - const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000}) + const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000, headers:false}) const metadata = Object.fromEntries(Object.entries(conf.metadata.plugins) - .filter(([key]) => !["base", "core"].includes(key)) - .map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "categorie", "web", "supports"].includes(key)))])) - const enabled = Object.entries(metadata).map(([name]) => ({name, enabled:plugins[name]?.enabled ?? false})) + .map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "categorie", "web", "supports"].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, {categorie}]) => categorie !== "core").map(([name]) => ({name, 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()} let requests = {limit:0, used:0, remaining:0, reset:NaN} @@ -119,9 +120,10 @@ app.get("/.js/prism.markdown.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/components/prism-markdown.min.js`)) //Meta app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version)) - app.get("/.requests", limiter, async(req, res) => res.status(200).json(requests)) + app.get("/.requests", limiter, (req, res) => res.status(200).json(requests)) + app.get("/.hosted", limiter, (req, res) => res.status(200).json(conf.settings.hosted || null)) //Cache - app.get("/.uncache", limiter, async(req, res) => { + app.get("/.uncache", limiter, (req, res) => { const {token, user} = req.query if (token) { if (actions.flush.has(token)) { @@ -129,7 +131,7 @@ cache.del(actions.flush.get(token)) return res.sendStatus(200) } - return res.sendStatus(404) + return res.sendStatus(400) } { const token = `${Math.random().toString(16).replace("0.", "")}${Math.random().toString(16).replace("0.", "")}` @@ -139,25 +141,32 @@ }) //Metrics + const pending = new Set() + app.get("/:login/:repository", ...middlewares, (req, res) => res.redirect(`/${req.params.login}?template=repository&repo=${req.params.repository}&${Object.entries(req.query).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&")}`)) app.get("/:login", ...middlewares, async(req, res) => { //Request params const login = req.params.login?.replace(/[\n\r]/g, "") if ((restricted.length)&&(!restricted.includes(login))) { - console.debug(`metrics/app/${login} > 403 (not in whitelisted users)`) - return res.sendStatus(403) + console.debug(`metrics/app/${login} > 403 (not in allowed users)`) + return res.status(403).send(`Forbidden: "${login}" not in allowed users`) } //Read cached data if possible if ((!debug)&&(cached)&&(cache.get(login))) { const {rendered, mime} = cache.get(login) res.header("Content-Type", mime) - res.send(rendered) - return + return res.send(rendered) } //Maximum simultaneous users if ((maxusers)&&(cache.size()+1 > maxusers)) { console.debug(`metrics/app/${login} > 503 (maximum users reached)`) - return res.sendStatus(503) + return res.status(503).send("Service Unavailable: maximum users reached, only cached metrics are available") } + //Prevent multiples requests + if (pending.has(login)) { + console.debug(`metrics/app/${login} > 409 (multiple requests)`) + return res.status(409).send(`Conflict: a request for "${login}" is being process, retry later`) + } + pending.add(login) //Compute rendering try { @@ -175,23 +184,27 @@ cache.put(login, {rendered, mime}, cached) //Send response res.header("Content-Type", mime) - res.send(rendered) + return res.send(rendered) } //Internal error catch (error) { //Not found user if ((error instanceof Error)&&(/^user not found$/.test(error.message))) { console.debug(`metrics/app/${login} > 404 (user/organization not found)`) - return res.sendStatus(404) + return res.status(404).send(`Not found: unknown user or organization "${login}"`) } //Invalid template if ((error instanceof Error)&&(/^unsupported template$/.test(error.message))) { console.debug(`metrics/app/${login} > 400 (bad request)`) - return res.sendStatus(400) + return res.status(400).send(`Bad request: unsupported template "${req.query.template}"`) } //General error console.error(error) - res.sendStatus(500) + return res.status(500).send("Internal Server Error: failed to process metrics correctly") + } + //After rendering + finally { + pending.delete(login) } }) diff --git a/source/app/web/settings.example.json b/source/app/web/settings.example.json index a954aedf..f2593045 100644 --- a/source/app/web/settings.example.json +++ b/source/app/web/settings.example.json @@ -13,6 +13,10 @@ "mocked": false, "//": "Use mocked data instead of live APIs (use 'force' to use mocked token even if real token are defined)", "repositories": 100, "//": "Number of repositories to use", "padding": ["6%", "15%"], "//": "Image padding (default)", + "hosted": { + "by": "", "//": "Web instance host (displayed in footer)", + "link": "", "//": "Web instance host link (displayed in footer)" + }, "community": { "templates": [], "//": "Additional community templates to setup" }, diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js index fcb90e31..df2740f8 100644 --- a/source/app/web/statics/app.js +++ b/source/app/web/statics/app.js @@ -5,7 +5,11 @@ const {data:metadata} = await axios.get("/.plugins.metadata") const {data:base} = await axios.get("/.plugins.base") const {data:version} = await axios.get("/.version") + const {data:hosted} = await axios.get("/.hosted") templates.sort((a, b) => (a.name.startsWith("@") ^ b.name.startsWith("@")) ? (a.name.startsWith("@") ? 1 : -1) : a.name.localeCompare(b.name)) + //Disable unsupported options + delete metadata.core.web.output + delete metadata.core.web.twemojis //App return new Vue({ //Initialization @@ -52,10 +56,9 @@ palette:"light", requests:{limit:0, used:0, remaining:0, reset:0}, cached:new Map(), - config:{ - timezone:"", - animated:true, - }, + 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, plugins:{ base, list:plugins, @@ -118,12 +121,14 @@ .filter(([key, value]) => `${value}`.length) .filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]) .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + //Base options + const base = Object.entries(this.plugins.options).filter(([key, value]) => (key in metadata.base.web)&&(value !== metadata.base.web[key]?.defaulted)).map(([key, value]) => `${key}=${encodeURIComponent(value)}`) //Config - const config = Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`) + const config = Object.entries(this.config).filter(([key, value]) => (value)&&(value !== metadata.core.web[key]?.defaulted)).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`) //Template const template = (this.templates.selected !== templates[0]) ? [`template=${this.templates.selected}`] : [] //Generated url - const params = [...template, ...plugins, ...options, ...config].join("&") + const params = [...template, ...base, ...plugins, ...options, ...config].join("&") return `${window.location.protocol}//${window.location.host}/${this.user}${params.length ? `?${params}` : ""}` }, //Embedded generated code @@ -157,9 +162,10 @@ ` template: ${this.templates.selected}`, ` base: ${Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).map(([key]) => key).join(", ")||'""'}`, ...[ + ...Object.entries(this.plugins.options).filter(([key, value]) => (key in metadata.base.web)&&(value !== metadata.base.web[key]?.defaulted)).map(([key, value]) => ` ${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`), ...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base")&&(value)).map(([key]) => ` plugin_${key}: yes`), ...Object.entries(this.plugins.options).filter(([key, value]) => value).filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]).map(([key, value]) => ` plugin_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`), - ...Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => ` config_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`), + ...Object.entries(this.config).filter(([key, value]) => (value)&&(value !== metadata.core.web[key]?.defaulted)).map(([key, value]) => ` config_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`), ].sort(), ].join("\n") }, @@ -185,7 +191,7 @@ this.templates.placeholder.timeout = setTimeout(async () => { this.templates.placeholder.image = await placeholder(this) this.generated.content = "" - this.generated.error = false + this.generated.error = null }, timeout) }, //Resize mock image @@ -207,9 +213,9 @@ try { await axios.get(`/.uncache?&token=${(await axios.get(`/.uncache?user=${this.user}`)).data.token}`) this.generated.content = (await axios.get(this.url)).data - this.generated.error = false - } catch { - this.generated.error = true + this.generated.error = null + } catch (error) { + this.generated.error = {code:error.response.status, message:error.response.data} } finally { this.generated.pending = false diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js index 02156fab..6f1d00de 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -30,7 +30,7 @@ const data = { //Template elements style, fonts, errors:[], - partials:new Set(partials), + partials:new Set([...(set.config.order||"").split(",").map(x => x.trim()).filter(x => partials.includes(x)), ...partials]), //Plural helper s(value, end = "") { return value !== 1 ? {y:"ies", "":"s"}[end] : end diff --git a/source/app/web/statics/index.html b/source/app/web/statics/index.html index e1ec79a7..de8e8e53 100644 --- a/source/app/web/statics/index.html +++ b/source/app/web/statics/index.html @@ -16,7 +16,7 @@ - + Metrics v{{ version }} @@ -45,7 +45,7 @@ - + {{ generated.pending ? 'Working on it :)' : 'Generate your metrics!' }} @@ -77,7 +77,6 @@ 🔧 Configure plugins - {{ input }} @@ -90,7 +89,25 @@ + + + + ⚙️ Additional settings + + + + {{ input.text }} + + + + {{ value }} + + + + + + @@ -103,7 +120,10 @@ - An error occurred while generating your metrics :( Please try again later. + + An error occurred while generating your metrics :( + {{ generated.error.message }} + @@ -122,6 +142,13 @@ + + diff --git a/source/app/web/statics/style.css b/source/app/web/statics/style.css index 219d8b6e..897b7c3b 100644 --- a/source/app/web/statics/style.css +++ b/source/app/web/statics/style.css @@ -232,6 +232,46 @@ body { text-align: center; } +/* Details */ + details summary:hover { + background-color: var(--color-input-contrast-bg); + border-radius: 6px; + cursor: pointer; + } + + details summary:focus { + outline: none; + } + +/* Error */ + .error { + padding: 1.25rem 1rem; + background-image: linear-gradient(var(--color-alert-error-bg),var(--color-alert-error-bg)); + color: var(--color-alert-error-text); + border: 1px solid var(--color-alert-error-border); + border-radius: 6px; + } + +/* Footer */ + main > footer { + margin: 1rem; + margin-top: 2rem; + padding-top: 1rem; + font-size: .8rem; + color: var(--color-text-secondary); + border-top: 1px solid var(--color-border-secondary); + font-style: normal; + display: flex; + justify-content: center; + align-items: center; + flex-direction: row; + flex-wrap: wrap; + } + + main > footer > * { + margin: 0 1rem; + } + /* Media screen */ @media only screen and (min-width: 740px) { .ui { diff --git a/source/plugins/core/metadata.yml b/source/plugins/core/metadata.yml index 7acad11c..e4c54c8b 100644 --- a/source/plugins/core/metadata.yml +++ b/source/plugins/core/metadata.yml @@ -87,6 +87,7 @@ inputs: type: array format: comma-separated default: "" + example: base.header, base.repositories # Use twemojis instead of emojis # May increase filesize but emojis will be rendered the same across all platforms