diff --git a/action.yml b/action.yml index 7feae256..b4211933 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,20 @@ inputs: description: Timezone used by metrics default: "" + # Specify output type + # Supported values are : + # - svg (support animations and transparency) + # - png (support transparency) + # - jpeg + config_output: + description: Output image type + default: svg + + # Enable or disable SVG animations + config_animations: + description: Enable or disable SVG animations + default: yes + # Number of repositories to use for metrics # A high number increase metrics accuracy, but will consume additional API requests when using plugins repositories: @@ -71,11 +85,17 @@ inputs: default: "header, activity, community, repositories, metadata" # Google PageSpeed plugin - # Enable it to compute the performance for the website attached to "user" + # Enable it to compute the performance of provided website plugin_pagespeed: description: Enable Google PageSpeed metrics for user's website default: no + # Website to audit with PageSpeed + # Leave empty to default to the website attached to "user"'s GitHub account + plugin_pagespeed_url: + description: Website to audit with PageSpeed + default: "" + # Display additional PageSpeed audit metrics # The following are displayed : # First Contentful Paint, Speed Index, Largest Contentful Paint, Time to Interactive, Total Blocking Time, Cumulative Layout Shift @@ -217,6 +237,12 @@ inputs: description: Posts external source default: "" + # Posts source username + # Leave empty to default to the login "user"'s GitHub account + plugin_posts_user: + description: Posts external source username + default: "" + # Number of posts to display plugin_posts_limit: description: Number of posts to display @@ -297,6 +323,12 @@ inputs: description: Display recent tweets default: no + # Twitter username + # Leave empty to default to the twitter account attached to "user"'s GitHub account + plugin_tweets_user: + description: Twitter username + default: "" + # Tweets API token (required when tweets plugin is enabled) # See https://apps.twitter.com for more informations plugin_tweets_token: diff --git a/source/app/action/index.mjs b/source/app/action/index.mjs index 826e21f3..c4d3c494 100644 --- a/source/app/action/index.mjs +++ b/source/app/action/index.mjs @@ -43,7 +43,7 @@ } //Load configuration - const {conf, Plugins, Templates} = await setup({log:false}) + const {conf, Plugins, Templates} = await setup({log:false, nosettings:true}) info("Setup", "complete") info("Version", conf.package.version) @@ -110,9 +110,13 @@ //Config const config = { - "config.timezone":input.string("config_timezone") + "config.timezone":input.string("config_timezone"), + "config.output":input.string("config_output"), + "config.animations":input.bool("config_animations"), } info("Timezone", config["config.timezone"] ?? "(system default)") + info("Convert SVG", config["config.output"] ?? "(no)") + info("Enable SVG animations", config["config.animations"]) //Additional plugins const plugins = { @@ -137,6 +141,8 @@ if (plugins.pagespeed.enabled) { plugins.pagespeed.token = input.string("plugin_pagespeed_token") info("Pagespeed token", plugins.pagespeed.token, {token:true}) + for (const option of ["url"]) + info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.string(`plugin_pagespeed_${option}`)) for (const option of ["detailed", "screenshot"]) info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.bool(`plugin_pagespeed_${option}`)) } @@ -163,7 +169,7 @@ } //Posts if (plugins.posts.enabled) { - for (const option of ["source"]) + for (const option of ["source", "user"]) info(`Posts ${option}`, q[`posts.${option}`] = input.string(`plugin_posts_${option}`)) for (const option of ["limit"]) info(`Posts ${option}`, q[`posts.${option}`] = input.number(`plugin_posts_${option}`)) @@ -191,6 +197,8 @@ if (plugins.tweets.enabled) { plugins.tweets.token = input.string("plugin_tweets_token") info("Tweets token", plugins.tweets.token, {token:true}) + for (const option of ["user"]) + info(`Tweets ${option}`, q[`tweets.${option}`] = input.string(`plugin_tweets_${option}`)) for (const option of ["limit"]) info(`Tweets ${option}`, q[`tweets.${option}`] = input.number(`plugin_tweets_${option}`)) } @@ -209,7 +217,7 @@ q = {...query, ...q, base:false, ...base, ...config, repositories, template} //Render metrics - const rendered = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates}) + const {rendered} = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates}) info("Rendering", "complete") //Commit to repository diff --git a/source/app/metrics.mjs b/source/app/metrics.mjs index 0fd37c88..1472d011 100644 --- a/source/app/metrics.mjs +++ b/source/app/metrics.mjs @@ -12,7 +12,7 @@ import SVGO from "svgo" //Setup - export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false}, {Plugins, Templates}) { + export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false, convert = null}, {Plugins, Templates}) { //Compute rendering try { @@ -27,7 +27,7 @@ throw new Error("unsupported template") const {image, style, fonts} = conf.templates[template] const queries = conf.queries - const data = {base:{}, config:{}, errors:[], plugins:{}, computed:{}} + const data = {animated:true, base:{}, config:{}, errors:[], plugins:{}, computed:{}} //Base parts { @@ -83,27 +83,32 @@ //Template rendering console.debug(`metrics/compute/${login} > render`) let rendered = await ejs.render(image, {...data, s, style, fonts}, {async:true}) + //Apply resizing + const {resized, mime} = await svgresize(rendered, {convert}) + rendered = resized - //Optimize rendering - if ((conf.optimize)&&(!q.raw)) { - console.debug(`metrics/compute/${login} > optimize`) - const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]}) - const {data:optimized} = await svgo.optimize(rendered) - rendered = optimized - } - - //Verify svg - if (verify) { - console.debug(`metrics/compute/${login} > verify SVG`) - const libxmljs = (await import("libxmljs")).default - const parsed = libxmljs.parseXml(rendered) - if (parsed.errors.length) - throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`) + //Additional SVG transformations + if (/svg/.test(mime)) { + //Optimize rendering + if ((conf.optimize)&&(!q.raw)) { + console.debug(`metrics/compute/${login} > optimize`) + const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]}) + const {data:optimized} = await svgo.optimize(rendered) + rendered = optimized + } + //Verify svg + if (verify) { + console.debug(`metrics/compute/${login} > verify SVG`) + const libxmljs = (await import("libxmljs")).default + const parsed = libxmljs.parseXml(rendered) + if (parsed.errors.length) + throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`) + } } //Result console.debug(`metrics/compute/${login} > success`) - return rendered + return {rendered, mime} } //Internal error catch (error) { @@ -174,6 +179,45 @@ }) } +/** Render svg */ + async function svgresize(svg, {convert} = {}) { + //Instantiate browser if needed + if (!svgresize.browser) { + svgresize.browser = await puppeteer.launch({headless:true, executablePath:process.env.PUPPETEER_BROWSER_PATH, args:["--no-sandbox", "--disable-extensions", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]}) + console.debug(`metrics/svgresize > started ${await svgresize.browser.version()}`) + } + //Render through browser and resize height + const page = await svgresize.browser.newPage() + await page.setContent(svg, {waitUntil:"load"}) + let mime = "image/svg+xml" + let {resized, width, height} = await page.evaluate(async () => { + //Disable animations + const animated = !document.querySelector("svg").classList.contains("no-animations") + if (animated) + document.querySelector("svg").classList.add("no-animations") + //Get bounds and resize + let {y:height, width} = document.querySelector("svg #metrics-end").getBoundingClientRect() + height = Math.ceil(height) + width = Math.ceil(width) + //Resize svg + document.querySelector("svg").setAttribute("height", height) + //Enable animations + if (animated) + document.querySelector("svg").classList.remove("no-animations") + //Result + return {resized:new XMLSerializer().serializeToString(document.querySelector("svg")), height, width} + }) + //Convert if required + if (convert) { + console.debug(`metrics/svgresize > convert to ${convert}`) + resized = await page.screenshot({type:convert, clip:{x:0, y:0, width, height}, omitBackground:true}) + mime = `image/${convert}` + } + //Result + await page.close() + return {resized, mime} + } + /** Placeholder generator */ function placeholder({data, conf, q}) { //Proxifier diff --git a/source/app/setup.mjs b/source/app/setup.mjs index 42f8d70b..ce37c143 100644 --- a/source/app/setup.mjs +++ b/source/app/setup.mjs @@ -7,7 +7,7 @@ const Plugins = {} /** Setup */ - export default async function ({log = true} = {}) { + export default async function ({log = true, nosettings = false} = {}) { //Paths const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../..") @@ -33,8 +33,12 @@ //Load settings logger(`metrics/setup > load settings.json`) if (fs.existsSync(__settings)) { - conf.settings = JSON.parse(`${await fs.promises.readFile(__settings)}`) - logger(`metrics/setup > load settings.json > success`) + if (nosettings) + logger(`metrics/setup > load settings.json > skipped because no settings is enabled`) + else { + conf.settings = JSON.parse(`${await fs.promises.readFile(__settings)}`) + logger(`metrics/setup > load settings.json > success`) + } } else logger(`metrics/setup > load settings.json > (missing)`) diff --git a/source/app/web/index.mjs b/source/app/web/index.mjs index 3d9e1b7e..689c4e78 100644 --- a/source/app/web/index.mjs +++ b/source/app/web/index.mjs @@ -2,4 +2,4 @@ import app from "./instance.mjs" //Start app - await app({mock:process.env.USE_MOCKED_DATA}) \ No newline at end of file + await app({mock:process.env.USE_MOCKED_DATA, nosettings:process.env.NO_SETTINGS}) \ No newline at end of file diff --git a/source/app/web/instance.mjs b/source/app/web/instance.mjs index e83415e9..fc49c7f2 100644 --- a/source/app/web/instance.mjs +++ b/source/app/web/instance.mjs @@ -11,11 +11,12 @@ import metrics from "../metrics.mjs" /** App */ - export default async function ({mock = false} = {}) { + export default async function ({mock = false, nosettings = false} = {}) { //Load configuration settings - const {conf, Plugins, Templates} = await setup() + const {conf, Plugins, Templates} = await setup({nosettings}) const {token, maxusers = 0, restricted = [], debug = false, cached = 30*60*1000, port = 3000, ratelimiter = null, plugins = null} = conf.settings + cache.placeholder = new Map() //Apply configuration mocking if needed if (mock) { @@ -66,8 +67,8 @@ //Base routes const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000}) - const templates = [...new Set([conf.settings.templates.default, ...(conf.settings.templates.enabled.length ? Object.keys(Templates).filter(key => conf.settings.templates.enabled.includes(key)) : Object.keys(Templates))])] - const enabled = Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key) + const templates = Object.entries(Templates).map(([name]) => ({name, enabled:(conf.settings.templates.enabled.length ? conf.settings.templates.enabled.includes(name) : true) ?? false})) + const enabled = Object.entries(Plugins).map(([name]) => ({name, enabled:plugins[name].enabled ?? false})) const actions = {flush:new Map()} app.get("/", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`)) app.get("/index.html", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`)) @@ -117,11 +118,20 @@ return res.sendStatus(403) } //Read cached data if possible - if ((!debug)&&(cached)&&(cache.get(login))) { - res.header("Content-Type", "image/svg+xml") - res.send(cache.get(login)) - return - } + //Placeholder + if ((login === "placeholder")&&(cache.placeholder.has(Object.keys(req.query).sort().join("-")))) { + const {rendered, mime} = cache.placeholder.get(Object.keys(req.query).sort().join("-")) + res.header("Content-Type", mime) + res.send(rendered) + return + } + //User cached + if ((!debug)&&(cached)&&(cache.get(login))) { + const {rendered, mime} = cache.get(login) + res.header("Content-Type", mime) + res.send(rendered) + return + } //Maximum simultaneous users if ((maxusers)&&(cache.size()+1 > maxusers)) { console.debug(`metrics/app/${login} > 503 (maximum users reached)`) @@ -133,12 +143,19 @@ //Render console.debug(`metrics/app/${login} > ${util.inspect(req.query, {depth:Infinity, maxStringLength:256})}`) const q = parse(req.query) - const rendered = await metrics({login, q}, {graphql, rest, plugins, conf, die:q["plugins.errors.fatal"] ?? false, verify:q["verify"] ?? false}, {Plugins, Templates}) + const {rendered, mime} = await metrics({login, q}, { + graphql, rest, plugins, conf, + die:q["plugins.errors.fatal"] ?? false, + verify:q["verify"] ?? false, + convert:["jpeg", "png"].includes(q["config.output"]) ? q["config.output"] : null + }, {Plugins, Templates}) //Cache - if ((!debug)&&(cached)&&(login !== "placeholder")) - cache.put(login, rendered, cached) + if (login === "placeholder") + cache.placeholder.set(Object.keys(req.query).sort().join("-"), rendered) + if ((!debug)&&(cached)) + cache.put(login, {rendered, mime}, cached) //Send response - res.header("Content-Type", "image/svg+xml") + res.header("Content-Type", mime) res.send(rendered) } //Internal error @@ -167,7 +184,7 @@ `Cached time │ ${cached} seconds`, `Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth:Infinity, maxStringLength:256}) : "(enabled)"}`, `Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`, - `Plugins enabled │ ${enabled.join(", ")}`, + `Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`, `Server ready !` ].join("\n"))) } diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js index beee8238..eeb5ab1e 100644 --- a/source/app/web/statics/app.js +++ b/source/app/web/statics/app.js @@ -75,7 +75,7 @@ }, templates:{ list:templates, - selected:url.get("template") || templates[0], + selected:url.get("template") || templates[0].name, loaded:{}, placeholder:"", descriptions:{ diff --git a/source/app/web/statics/index.html b/source/app/web/statics/index.html index b950e206..ca3b920c 100644 --- a/source/app/web/statics/index.html +++ b/source/app/web/statics/index.html @@ -28,9 +28,9 @@