From 8cd21a10d7347b284ccb856778251e1aad4b198d Mon Sep 17 00:00:00 2001
From: Elias Hackradt
Date: Thu, 15 Jan 2026 11:30:36 +0100
Subject: [PATCH 1/2] Initial commit
---
lib/controllers.js | 125 ++++++++++++++++++
library.js | 44 +++---
plugin.json | 3 +
static/lib/admin.js | 84 +++++++++++-
static/lib/client.js | 20 +++
.../partials/edit-oauth2-strategy.tpl | 16 +++
6 files changed, 275 insertions(+), 17 deletions(-)
create mode 100644 static/lib/client.js
diff --git a/lib/controllers.js b/lib/controllers.js
index b2a4a9d..ddbfe2d 100644
--- a/lib/controllers.js
+++ b/lib/controllers.js
@@ -9,6 +9,9 @@ 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 nconf = require.main.require('nconf');
+const path = require('path');
+const fs = require('fs/promises');
const main = require('../library');
@@ -62,6 +65,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 +111,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.
+
+
+

+
+
+
+
+
+
From 9b5a47f6d4225aaee28ceb16db9f2c78ee050006 Mon Sep 17 00:00:00 2001
From: Elias Hackradt
Date: Thu, 15 Jan 2026 11:39:42 +0100
Subject: [PATCH 2/2] Fixup nconf
---
lib/controllers.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/lib/controllers.js b/lib/controllers.js
index ddbfe2d..1f5d273 100644
--- a/lib/controllers.js
+++ b/lib/controllers.js
@@ -9,7 +9,6 @@ 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 nconf = require.main.require('nconf');
const path = require('path');
const fs = require('fs/promises');