diff --git a/install/package.json b/install/package.json index 070294dc0e..8366db322b 100644 --- a/install/package.json +++ b/install/package.json @@ -35,6 +35,7 @@ "@isaacs/ttlcache": "1.4.1", "@nodebb/spider-detector": "2.0.3", "@popperjs/core": "2.11.8", + "@resvg/resvg-js": "^2.6.2", "@textcomplete/contenteditable": "0.1.13", "@textcomplete/core": "0.1.13", "@textcomplete/textarea": "0.1.13", @@ -130,6 +131,7 @@ "rtlcss": "4.3.0", "sanitize-html": "2.13.0", "sass": "1.79.3", + "satori": "^0.11.1", "semver": "7.6.3", "serve-favicon": "2.5.0", "sharp": "0.32.6", diff --git a/src/activitypub/mocks.js b/src/activitypub/mocks.js index cb02ae12be..374a31d3cd 100644 --- a/src/activitypub/mocks.js +++ b/src/activitypub/mocks.js @@ -224,12 +224,12 @@ Mocks.actors.category = async (cid) => { }; } - let icon = meta.config['brand:logo'] || `${nconf.get('relative_path')}/assets/logo.png`; - const filename = path.basename(utils.decodeHTMLEntities(icon)); + let icon = await categories.icons.get(cid); + icon = icon.get('png'); icon = { type: 'Image', - mediaType: mime.getType(filename), - url: `${nconf.get('url')}${utils.decodeHTMLEntities(icon)}`, + mediaType: 'image/png', + url: `${nconf.get('url')}${icon}`, }; return { diff --git a/src/categories/icon.js b/src/categories/icon.js new file mode 100644 index 0000000000..93f5614630 --- /dev/null +++ b/src/categories/icon.js @@ -0,0 +1,109 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs/promises'); +const nconf = require('nconf'); +const winston = require('winston'); +const { default: satori } = require('satori'); +const { Resvg } = require('@resvg/resvg-js'); + +const utils = require('../utils'); + +const categories = module.parent.exports; +const Icons = module.exports; + +Icons._constants = Object.freeze({ + extensions: ['svg', 'png'], +}); + +Icons.get = async (cid) => { + try { + const paths = Icons._constants.extensions.map(extension => path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.${extension}`)); + await Promise.all(paths.map(async (path) => { + await fs.access(path); + })); + + return new Map(Object.entries({ + svg: `${nconf.get('upload_url')}/category/category-${cid}-icon.svg`, + png: `${nconf.get('upload_url')}/category/category-${cid}-icon.png`, + })); + } catch (e) { + return await Icons.regenerate(cid); + } +}; + +Icons.flush = async (cid) => { + winston.verbose(`[categories/icons] Flushing ${cid}.`); + const paths = Icons._constants.extensions.map(extension => path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.${extension}`)); + + await Promise.all(paths.map((async path => await fs.rm(path, { force: true })))); +}; + +Icons.regenerate = async (cid) => { + winston.verbose(`[categories/icons] Regenerating ${cid}.`); + const { icon, color, bgColor } = await categories.getCategoryData(cid); + + const fontPaths = new Map(Object.entries({ + regular: path.join(utils.getFontawesomePath(), 'webfonts/fa-regular-400.ttf'), + solid: path.join(utils.getFontawesomePath(), 'webfonts/fa-solid-900.ttf'), + })); + const fontBuffers = new Map(Object.entries({ + regular: await fs.readFile(fontPaths.get('regular')), + solid: await fs.readFile(fontPaths.get('solid')), + })); + + // Retrieve unicode codepoint (hex) and weight + let metadata = await fs.readFile(path.join(utils.getFontawesomePath(), 'metadata/icon-families.json'), 'utf-8'); + metadata = JSON.parse(metadata); // needs try..catch wrapper + let iconString = icon.slice(3); + iconString = iconString.split(' ').shift(); // sometimes multiple classes saved; use first + const fontWeight = iconString.endsWith('-o') ? 400 : 900; + iconString = iconString.endsWith('-o') ? iconString.slice(0, -2) : iconString; + const { unicode } = metadata[iconString] || metadata.comments; // fall back to fa-comments + + // Generate and save SVG + const svg = await satori({ + type: 'div', + props: { + children: String.fromCodePoint(`0x${unicode}`), + style: { + width: '128px', + height: '128px', + color, + background: bgColor, + fontSize: '64px', + fontWeight, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + }, + }, { + width: 128, + height: 128, + fonts: [{ + name: 'Font Awesome 6 Free', + data: fontBuffers.get('regular'), + weight: 400, + style: 'normal', + }, { + name: 'Font Awesome 6 Free', + data: fontBuffers.get('solid'), + weight: 900, + style: 'normal', + }], + }); + await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.svg`), svg); + + // Generate and save PNG + const resvg = new Resvg(Buffer.from(svg)); + const pngData = resvg.render(); + const pngBuffer = pngData.asPng(); + + await fs.writeFile(path.resolve(nconf.get('upload_path'), 'category', `category-${cid}-icon.png`), pngBuffer); + + return new Map(Object.entries({ + svg: `${nconf.get('upload_url')}/category/category-${cid}-icon.svg`, + png: `${nconf.get('upload_url')}/category/category-${cid}-icon.png`, + })); +}; diff --git a/src/categories/index.js b/src/categories/index.js index 3e0e0e4049..51eea283c0 100644 --- a/src/categories/index.js +++ b/src/categories/index.js @@ -24,6 +24,8 @@ require('./update')(Categories); require('./watch')(Categories); require('./search')(Categories); +Categories.icons = require('./icon'); + Categories.exists = async function (cids) { return await db.exists( Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}` diff --git a/src/categories/update.js b/src/categories/update.js index 517cbeb499..2f2effd96d 100644 --- a/src/categories/update.js +++ b/src/categories/update.js @@ -39,6 +39,11 @@ module.exports = function (Categories) { // eslint-disable-next-line no-await-in-loop await updateCategoryField(cid, key, category[key]); } + + if (['icon', 'color', 'bgColor'].some(prop => Object.keys(modifiedFields).includes(prop))) { + Categories.icons.flush(cid); + } + plugins.hooks.fire('action:category.update', { cid: cid, modified: category }); }