Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions lib/controllers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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 };
Expand All @@ -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),
Expand Down
44 changes: 29 additions & 15 deletions library.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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;
};
Expand Down
3 changes: 3 additions & 0 deletions plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
84 changes: 82 additions & 2 deletions static/lib/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export function init() {
message,
size: 'xl',
callback: handleEditStrategy,
onShown: handleAutoDiscovery,
onShown: function () {
handleAutoDiscovery.call(this);
handleIconUpload.call(this);
},
});

break;
Expand All @@ -39,7 +42,10 @@ export function init() {
message,
size: 'xl',
callback: handleEditStrategy,
onShown: handleAutoDiscovery,
onShown: function () {
handleAutoDiscovery.call(this);
handleIconUpload.call(this);
},
});

break;
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions static/lib/client.js
Original file line number Diff line number Diff line change
@@ -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);
})();
16 changes: 16 additions & 0 deletions static/templates/partials/edit-oauth2-strategy.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@
If none is set, then <i class="fa fa-right-to-bracket"></i> will be used.
</p>
</div>
<div class="mb-3">
<label class="form-label" for="iconFile">Custom Icon Upload</label>
<input type="hidden" name="iconUrl" value="{./iconUrl}">
<input type="file" id="iconFile" class="form-control" accept="image/*">
<p class="form-text">
<strong>Optional</strong> — upload a small image (PNG, SVG, etc.) to use instead of the FontAwesome icon.
Max size 100KB.
</p>
<div class="d-flex align-items-center gap-2">
<img id="iconPreview" src="{./iconUrl}" alt="Custom icon preview" class="rounded d-none" style="width: 28px; height: 28px; object-fit: contain;">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="removeIcon" name="removeIcon">
<label for="removeIcon" class="form-check-label">Remove custom icon</label>
</div>
</div>
</div>

<div class="mb-3">
<label class="form-label" for="loginLabel">&quot;Login&quot; Label Text</label>
Expand Down