diff --git a/lib/controllers.js b/lib/controllers.js index b2a4a9d..1f5d273 100644 --- a/lib/controllers.js +++ b/lib/controllers.js @@ -9,6 +9,8 @@ const groups = require.main.require('./src/groups'); const slugify = require.main.require('./src/slugify'); const helpers = require.main.require('./src/controllers/helpers'); const userController = require.main.require('./src/controllers/user'); +const path = require('path'); +const fs = require('fs/promises'); const main = require('../library'); @@ -62,6 +64,35 @@ Controllers.getStrategy = async (req, res) => { helpers.formatApiResponse(200, res, { strategy }); }; +Controllers.renderIconsCss = async (req, res) => { + const strategies = await main.listStrategies(true); + const iconStrategies = strategies.filter(strategy => strategy.iconUrl); + + const escapeCssUrl = (url) => url.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + + const baseStyles = ` +.sso-oauth2-icon { + display: inline-block; + width: 1em; + height: 1em; + background-position: center; + background-repeat: no-repeat; + background-size: contain; + vertical-align: middle; +} +.sso-oauth2-icon::before { + content: "" !important; +} +`.trim(); + + const iconRules = iconStrategies.map(strategy => ( + `.sso-oauth2-icon-${strategy.name} { background-image: url("${escapeCssUrl(strategy.iconUrl)}"); }` + )).join('\n'); + + res.set('Content-Type', 'text/css; charset=utf-8'); + res.send(`${baseStyles}\n${iconRules}\n`); +}; + Controllers.editStrategy = async (req, res) => { const name = slugify(req.params.name || req.body.name); const payload = { ...req.body }; @@ -79,6 +110,99 @@ Controllers.editStrategy = async (req, res) => { payload[prop] = payload.hasOwnProperty(prop) && payload[prop] === 'on' ? 1 : 0; }); + const baseDir = nconf.get('base_dir') || process.cwd(); + const rawUploadPath = nconf.get('upload_path') || path.join(baseDir, 'public', 'uploads'); + const uploadPath = path.isAbsolute(rawUploadPath) ? rawUploadPath : path.join(baseDir, rawUploadPath); + const uploadUrl = nconf.get('upload_url') || '/uploads'; + const relativePath = nconf.get('relative_path') || ''; + const iconsDir = path.join(uploadPath, 'plugins', 'sso-oauth2-multiple'); + + const normalizeUrlPrefix = (prefix) => { + if (!prefix.startsWith('/')) { + prefix = `/${prefix}`; + } + return prefix.replace(/\/+$/, ''); + }; + const normalizeRelativePath = (value) => { + if (!value) { + return ''; + } + if (!value.startsWith('/')) { + value = `/${value}`; + } + return value.replace(/\/+$/, ''); + }; + const isAbsoluteUploadUrl = /^https?:\/\//i.test(uploadUrl); + const baseUploadUrl = isAbsoluteUploadUrl + ? uploadUrl.replace(/\/+$/, '') + : `${normalizeRelativePath(relativePath)}${normalizeUrlPrefix(uploadUrl)}`; + const uploadUrlPrefix = `${baseUploadUrl}/plugins/sso-oauth2-multiple/`; + const getIconFilePath = (url) => { + if (!url || typeof url !== 'string') { + return null; + } + if (!url.startsWith(uploadUrlPrefix)) { + return null; + } + const filename = url.slice(uploadUrlPrefix.length); + if (!filename) { + return null; + } + return path.join(iconsDir, filename); + }; + + if (payload.removeIcon === 'on') { + payload.iconUrl = ''; + } + delete payload.removeIcon; + + const existing = await main.getStrategy(name); + const existingIconPath = existing ? getIconFilePath(existing.iconUrl) : null; + + if (payload.iconUrl) { + const maxBytes = 100 * 1024; + const isDataUrl = payload.iconUrl.startsWith('data:image/'); + if (isDataUrl) { + const match = payload.iconUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/); + if (!match) { + throw new Error('[[error:invalid-data]]'); + } + const mime = match[1]; + const data = match[2]; + const buffer = Buffer.from(data, 'base64'); + if (buffer.length > maxBytes) { + throw new Error('[[error:invalid-data]]'); + } + + const extMap = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + }; + const ext = extMap[mime]; + if (!ext) { + throw new Error('[[error:invalid-data]]'); + } + + await fs.mkdir(iconsDir, { recursive: true }); + const filename = `${name}-${Date.now()}.${ext}`; + const filePath = path.join(iconsDir, filename); + await fs.writeFile(filePath, buffer); + payload.iconUrl = `${uploadUrlPrefix}${filename}`; + + if (existingIconPath) { + await fs.unlink(existingIconPath).catch(() => null); + } + } else if (!payload.iconUrl.startsWith(uploadUrlPrefix)) { + throw new Error('[[error:invalid-data]]'); + } + } else if (!payload.iconUrl && existingIconPath) { + await fs.unlink(existingIconPath).catch(() => null); + } + await Promise.all([ db.sortedSetAdd('oauth2-multiple:strategies', Date.now(), name), db.setObject(`oauth2-multiple:strategies:${name}`, payload), diff --git a/library.js b/library.js index 0161c03..0679fc2 100644 --- a/library.js +++ b/library.js @@ -19,6 +19,7 @@ OAuth.init = async (params) => { const controllers = require('./lib/controllers'); routeHelpers.setupAdminPageRoute(router, '/admin/plugins/sso-oauth2-multiple', controllers.renderAdminPage); + router.get('/plugins/sso-oauth2-multiple/icons.css', controllers.renderIconsCss); }; OAuth.addRoutes = async ({ router, middleware }) => { @@ -120,22 +121,35 @@ OAuth.loadStrategies = async (strategies) => { passport.use(configured[idx].name, strategy); }); - strategies.push(...configured.map(({ name, scope, loginLabel, registerLabel, faIcon }) => ({ + strategies.push(...configured.map(({ name, - url: `/auth/${name}`, - callbackURL: `/auth/${name}/callback`, - icon: faIcon || 'fa-right-to-bracket', - icons: { - normal: `fa ${faIcon || 'fa-right-to-bracket'}`, - square: `fa ${faIcon || 'fa-right-to-bracket'}`, - }, - labels: { - login: loginLabel || 'Log In', - register: registerLabel || 'Register', - }, - color: '#666', - scope: scope || 'openid email profile', - }))); + scope, + loginLabel, + registerLabel, + faIcon, + iconUrl, + }) => { + const hasCustomIcon = Boolean(iconUrl); + const fallbackIcon = faIcon || 'fa-right-to-bracket'; + const iconClass = hasCustomIcon ? `sso-oauth2-icon sso-oauth2-icon-${name}` : `fa ${fallbackIcon}`; + + return { + name, + url: `/auth/${name}`, + callbackURL: `/auth/${name}/callback`, + icon: hasCustomIcon ? `sso-oauth2-icon sso-oauth2-icon-${name}` : fallbackIcon, + icons: { + normal: iconClass, + square: iconClass, + }, + labels: { + login: loginLabel || 'Log In', + register: registerLabel || 'Register', + }, + color: '#666', + scope: scope || 'openid email profile', + }; + })); return strategies; }; diff --git a/plugin.json b/plugin.json index 39b7360..56ad2e5 100644 --- a/plugin.json +++ b/plugin.json @@ -15,6 +15,9 @@ "modules": { "../admin/plugins/sso-oauth2-multiple.js": "./static/lib/admin.js" }, + "scripts": [ + "static/lib/client.js" + ], "acpScripts": [ "static/lib/acp.js" ], diff --git a/static/lib/admin.js b/static/lib/admin.js index 28de9fa..d9e51b5 100644 --- a/static/lib/admin.js +++ b/static/lib/admin.js @@ -23,7 +23,10 @@ export function init() { message, size: 'xl', callback: handleEditStrategy, - onShown: handleAutoDiscovery, + onShown: function () { + handleAutoDiscovery.call(this); + handleIconUpload.call(this); + }, }); break; @@ -39,7 +42,10 @@ export function init() { message, size: 'xl', callback: handleEditStrategy, - onShown: handleAutoDiscovery, + onShown: function () { + handleAutoDiscovery.call(this); + handleIconUpload.call(this); + }, }); break; @@ -205,6 +211,80 @@ function handleAutoDiscovery() { }); } +function handleIconUpload() { + const modalEl = this; + const fileEl = modalEl.querySelector('#iconFile'); + const iconUrlEl = modalEl.querySelector('input[name="iconUrl"]'); + const previewEl = modalEl.querySelector('#iconPreview'); + const removeEl = modalEl.querySelector('#removeIcon'); + + if (!fileEl || !iconUrlEl) { + return; + } + + const updatePreview = (url) => { + if (!previewEl) { + return; + } + if (url) { + previewEl.src = url; + previewEl.classList.remove('d-none'); + } else { + previewEl.removeAttribute('src'); + previewEl.classList.add('d-none'); + } + }; + + updatePreview(iconUrlEl.value); + + fileEl.addEventListener('change', () => { + const file = fileEl.files && fileEl.files[0]; + if (!file) { + return; + } + + if (!file.type.startsWith('image/')) { + alert({ + type: 'danger', + message: 'Icon upload must be an image file.', + }); + fileEl.value = ''; + return; + } + + const maxBytes = 100 * 1024; + if (file.size > maxBytes) { + alert({ + type: 'danger', + message: 'Icon upload must be 100KB or smaller.', + }); + fileEl.value = ''; + return; + } + + const reader = new FileReader(); + reader.onload = () => { + iconUrlEl.value = reader.result; + if (removeEl) { + removeEl.checked = false; + } + updatePreview(reader.result); + }; + reader.readAsDataURL(file); + }); + + if (removeEl) { + removeEl.addEventListener('change', () => { + if (!removeEl.checked) { + return; + } + fileEl.value = ''; + iconUrlEl.value = ''; + updatePreview(''); + }); + } +} + function handleDeleteStrategy(ok, name) { if (!ok) { return; diff --git a/static/lib/client.js b/static/lib/client.js new file mode 100644 index 0000000..555ffd7 --- /dev/null +++ b/static/lib/client.js @@ -0,0 +1,20 @@ +'use strict'; + +(() => { + if (typeof document === 'undefined') { + return; + } + + const existing = document.querySelector('link[data-sso-oauth2-icons]'); + if (existing) { + return; + } + + const relativePath = (window.config && window.config.relative_path) ? window.config.relative_path : ''; + const cacheBuster = (window.config && (window.config['cache-buster'] || window.config.cacheBuster)) || ''; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = `${relativePath}/plugins/sso-oauth2-multiple/icons.css${cacheBuster ? `?v=${cacheBuster}` : ''}`; + link.setAttribute('data-sso-oauth2-icons', '1'); + document.head.appendChild(link); +})(); diff --git a/static/templates/partials/edit-oauth2-strategy.tpl b/static/templates/partials/edit-oauth2-strategy.tpl index dcc4f65..09a977e 100644 --- a/static/templates/partials/edit-oauth2-strategy.tpl +++ b/static/templates/partials/edit-oauth2-strategy.tpl @@ -36,6 +36,22 @@ If none is set, then will be used.
++ Optional — upload a small image (PNG, SVG, etc.) to use instead of the FontAwesome icon. + Max size 100KB. +
+