- Introduced custom branding CSS styles in `custom-branding.css`. - Created example JSON configuration for custom branding in `custom-branding.example.json`. - Implemented branding configuration logic in `BrandingConfig.ts` to normalize and validate branding settings. - Developed CSS loader to manage loading and unloading of custom stylesheets in `BrandingCssLoader.ts`. - Added DOM rendering capabilities for branding elements in `BrandingDomRenderer.ts`. - Defined types and interfaces for branding elements and configurations in `BrandingTypes.ts`. - Included localization support for German in `de-de.js`. - Added unit tests for branding configuration, CSS loader, and DOM renderer. - Validated project structure and static assets with new validation scripts.
264 lines
13 KiB
JavaScript
264 lines
13 KiB
JavaScript
/* CustomBranding 2.0.0 - safe Classic SharePoint runtime */
|
|
(function (global) {
|
|
'use strict';
|
|
|
|
var COMPONENT_ID = '035ba968-6488-4d42-86b3-0470ffcc95b9';
|
|
var OWNER = 'CustomBranding.Classic';
|
|
var MAX_DEPTH = 8;
|
|
var MAX_ELEMENTS = 200;
|
|
var allowedTags = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
|
|
var allowedStyles = ('align-items background background-color border border-bottom border-color border-left border-radius border-right border-style border-top border-width box-sizing color display flex flex-basis flex-direction flex-grow flex-shrink flex-wrap font-family font-size font-style font-weight gap grid-template-columns height justify-content line-height margin margin-bottom margin-left margin-right margin-top max-height max-width min-height min-width opacity overflow padding padding-bottom padding-left padding-right padding-top text-align text-decoration text-transform white-space width').split(' ');
|
|
var state = { debug: false, hosts: [], css: [] };
|
|
|
|
function log(message, data) {
|
|
if (state.debug && global.console && console.log) {
|
|
console.log('[CustomBranding Classic] ' + message, data || '');
|
|
}
|
|
}
|
|
|
|
function strings() {
|
|
var german = global._spPageContextInfo && Number(_spPageContextInfo.currentLanguage) === 1031;
|
|
return german ? {
|
|
loadError: 'Die CustomBranding-Konfiguration konnte nicht geladen werden.',
|
|
renderError: 'Das konfigurierte Branding konnte nicht dargestellt werden.'
|
|
} : {
|
|
loadError: 'The CustomBranding configuration could not be loaded.',
|
|
renderError: 'The configured branding could not be rendered.'
|
|
};
|
|
}
|
|
|
|
function siteUrl() {
|
|
return global._spPageContextInfo ? _spPageContextInfo.siteAbsoluteUrl : '';
|
|
}
|
|
|
|
function sanitizeUrl(value, allowMailto) {
|
|
var raw = String(value || '').replace(/^\s+|\s+$/g, '');
|
|
if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) { return null; }
|
|
var resolved = raw.toLowerCase().indexOf('~sitecollection') === 0
|
|
? siteUrl().replace(/\/+$/, '') + raw.substring('~sitecollection'.length)
|
|
: raw;
|
|
var match = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
if (!match) { return resolved; }
|
|
var protocol = match[1].toLowerCase();
|
|
return protocol === 'http' || protocol === 'https' || (allowMailto && protocol === 'mailto') ? resolved : null;
|
|
}
|
|
|
|
function normalizeHosts(value) {
|
|
var result = [];
|
|
if (!Array.isArray(value)) { return result; }
|
|
for (var i = 0; i < value.length; i++) {
|
|
var host = String(value[i] || '').toLowerCase().replace(/^\s+|\s+$/g, '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
if (/^[a-z0-9.-]+(?::\d+)?$/.test(host) && result.indexOf(host) < 0) { result.push(host); }
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function sanitizeCssUrl(value, hosts) {
|
|
var resolved = sanitizeUrl(value, false);
|
|
if (!resolved) { return null; }
|
|
var absolute = resolved.match(/^(https?):\/\/([^/]+)/i);
|
|
if (!absolute) { return resolved; }
|
|
var current = siteUrl().match(/^(https?):\/\/([^/]+)/i);
|
|
var protocol = absolute[1].toLowerCase();
|
|
var host = absolute[2].toLowerCase();
|
|
if (current && protocol === current[1].toLowerCase() && host === current[2].toLowerCase()) { return resolved; }
|
|
return protocol === 'https' && hosts.indexOf(host) >= 0 ? resolved : null;
|
|
}
|
|
|
|
function sanitizeStyle(name, value) {
|
|
var property = String(name || '').toLowerCase().replace(/^\s+|\s+$/g, '');
|
|
var styleValue = String(value || '').replace(/^\s+|\s+$/g, '');
|
|
if (allowedStyles.indexOf(property) < 0 || !styleValue || styleValue.length > 512) { return null; }
|
|
if (/[\u0000-\u001f\u007f]/.test(styleValue) || /(url\s*\(|expression\s*\(|javascript\s*:|@import|behavior\s*:|-moz-binding)/i.test(styleValue)) { return null; }
|
|
return styleValue;
|
|
}
|
|
|
|
function allowedAttribute(tag, name) {
|
|
if (['id', 'class', 'title', 'role', 'aria-label', 'aria-hidden', 'aria-current', 'aria-live'].indexOf(name) >= 0) { return true; }
|
|
if (tag === 'a') { return ['href', 'target'].indexOf(name) >= 0; }
|
|
if (tag === 'img') { return ['src', 'alt', 'width', 'height'].indexOf(name) >= 0; }
|
|
if (tag === 'button') { return ['type', 'disabled', 'aria-expanded', 'aria-controls'].indexOf(name) >= 0; }
|
|
return false;
|
|
}
|
|
|
|
function createElement(config, depth, counter) {
|
|
if (!config || typeof config !== 'object' || depth > MAX_DEPTH || counter.value >= MAX_ELEMENTS) { return null; }
|
|
var tag = String(config.type || '').toLowerCase();
|
|
if (allowedTags.indexOf(tag) < 0) { return null; }
|
|
counter.value++;
|
|
var element = document.createElement(tag);
|
|
var attributes = config.attributes && typeof config.attributes === 'object' ? config.attributes : {};
|
|
var hasAlt = false;
|
|
for (var rawName in attributes) {
|
|
if (!Object.prototype.hasOwnProperty.call(attributes, rawName)) { continue; }
|
|
var name = String(rawName).toLowerCase();
|
|
var value = String(attributes[rawName] === undefined ? '' : attributes[rawName]).substring(0, 2048);
|
|
if (name.indexOf('on') === 0 || !allowedAttribute(tag, name)) { continue; }
|
|
if (name === 'href' || name === 'src') {
|
|
var safeUrl = sanitizeUrl(value, name === 'href');
|
|
if (safeUrl) { element.setAttribute(name, safeUrl); }
|
|
} else if (name === 'target') {
|
|
if (value === '_blank' || value === '_self') { element.setAttribute(name, value); }
|
|
} else if ((name === 'id' || name === 'class') && !/^[a-z0-9 _-]{1,256}$/i.test(value)) {
|
|
continue;
|
|
} else if ((name === 'width' || name === 'height') && !/^\d{1,4}$/.test(value)) {
|
|
continue;
|
|
} else {
|
|
element.setAttribute(name, value);
|
|
}
|
|
if (name === 'alt') { hasAlt = true; }
|
|
}
|
|
if (tag === 'img' && !hasAlt) { return null; }
|
|
if (tag === 'button') { element.setAttribute('type', 'button'); }
|
|
if (tag === 'a' && element.getAttribute('target') === '_blank') { element.setAttribute('rel', 'noopener noreferrer'); }
|
|
|
|
var styles = config.styles && typeof config.styles === 'object' ? config.styles : {};
|
|
for (var styleName in styles) {
|
|
if (!Object.prototype.hasOwnProperty.call(styles, styleName)) { continue; }
|
|
var safeStyle = sanitizeStyle(styleName, styles[styleName]);
|
|
if (safeStyle) { element.style.setProperty(String(styleName).toLowerCase(), safeStyle); }
|
|
}
|
|
if (config.content !== undefined && config.content !== null) {
|
|
element.appendChild(document.createTextNode(String(config.content).substring(0, 4000)));
|
|
}
|
|
if (tag !== 'img' && Array.isArray(config.children)) {
|
|
for (var i = 0; i < config.children.length; i++) {
|
|
var child = createElement(config.children[i], depth + 1, counter);
|
|
if (child) { element.appendChild(child); }
|
|
}
|
|
}
|
|
if ((tag === 'a' || tag === 'button') && !element.textContent && !element.getAttribute('aria-label')) { return null; }
|
|
return element;
|
|
}
|
|
|
|
function clear(element) {
|
|
while (element && element.firstChild) { element.removeChild(element.firstChild); }
|
|
}
|
|
|
|
function renderHost(id, parent, elements, beforeNode) {
|
|
var host = document.getElementById(id);
|
|
if (!host) {
|
|
host = document.createElement('div');
|
|
host.id = id;
|
|
host.setAttribute('data-custom-branding-owner', OWNER);
|
|
if (beforeNode) { parent.insertBefore(host, beforeNode); } else { parent.appendChild(host); }
|
|
}
|
|
clear(host);
|
|
var counter = { value: 0 };
|
|
for (var i = 0; i < elements.length; i++) {
|
|
var element = createElement(elements[i], 1, counter);
|
|
if (element) { host.appendChild(element); }
|
|
}
|
|
state.hosts.push(host);
|
|
}
|
|
|
|
function showStatus(message) {
|
|
var host = document.getElementById('CustomBrandingClassicTopHost') || document.createElement('div');
|
|
host.id = 'CustomBrandingClassicTopHost';
|
|
clear(host);
|
|
var status = document.createElement('div');
|
|
status.setAttribute('role', 'status');
|
|
status.textContent = message;
|
|
host.appendChild(status);
|
|
if (!host.parentNode) { document.body.insertBefore(host, document.body.firstChild); }
|
|
}
|
|
|
|
function loadCss(files, hosts) {
|
|
var seen = {};
|
|
for (var i = 0; i < files.length && i < 20; i++) {
|
|
var path = sanitizeCssUrl(files[i] && files[i].path, hosts);
|
|
var key = String(path || '').toLowerCase();
|
|
if (!path || seen[key]) { continue; }
|
|
seen[key] = true;
|
|
var link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = path;
|
|
link.media = files[i].media && /^[a-z0-9 (),.:\/-]{1,80}$/i.test(files[i].media)
|
|
? files[i].media
|
|
: 'all';
|
|
link.setAttribute('data-custom-branding-owner', OWNER);
|
|
document.getElementsByTagName('head')[0].appendChild(link);
|
|
state.css.push(link);
|
|
}
|
|
}
|
|
|
|
function normalize(raw) {
|
|
raw = raw && typeof raw === 'object' ? raw : {};
|
|
return {
|
|
enabled: raw.enabled !== false,
|
|
debug: raw.debug === true,
|
|
allowedCssHosts: normalizeHosts(raw.allowedCssHosts),
|
|
cssfiles: Array.isArray(raw.cssfiles) ? raw.cssfiles : [],
|
|
top: raw.placeholdertop && Array.isArray(raw.placeholdertop.elements) ? raw.placeholdertop.elements : (Array.isArray(raw.elements) ? raw.elements : []),
|
|
bottom: raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements) ? raw.placeholderbottom.elements : []
|
|
};
|
|
}
|
|
|
|
function cleanup() {
|
|
for (var i = 0; i < state.hosts.length; i++) {
|
|
if (state.hosts[i].parentNode) { state.hosts[i].parentNode.removeChild(state.hosts[i]); }
|
|
}
|
|
for (var j = 0; j < state.css.length; j++) {
|
|
if (state.css[j].parentNode) { state.css[j].parentNode.removeChild(state.css[j]); }
|
|
}
|
|
state.hosts = [];
|
|
state.css = [];
|
|
}
|
|
|
|
function render(config) {
|
|
cleanup();
|
|
state.debug = config.debug;
|
|
if (!config.enabled) { return; }
|
|
loadCss(config.cssfiles, config.allowedCssHosts);
|
|
var titleRow = document.getElementById('s4-titlerow');
|
|
var topParent = titleRow && titleRow.parentNode ? titleRow.parentNode : document.body;
|
|
var topBefore = titleRow ? titleRow.nextSibling : document.body.firstChild;
|
|
renderHost('CustomBrandingClassicTopHost', topParent, config.top, topBefore);
|
|
var workspace = document.getElementById('s4-workspace') || document.body;
|
|
renderHost('CustomBrandingClassicBottomHost', workspace, config.bottom, null);
|
|
log('Branding rendered.');
|
|
}
|
|
|
|
function readConfiguration(callback, errorCallback) {
|
|
var url = siteUrl() + "/_api/site/UserCustomActions?$filter=ClientSideComponentId eq guid'" + COMPONENT_ID + "'&$select=ClientSideComponentProperties";
|
|
var request = new XMLHttpRequest();
|
|
request.open('GET', url, true);
|
|
request.setRequestHeader('Accept', 'application/json;odata=verbose');
|
|
request.onreadystatechange = function () {
|
|
if (request.readyState !== 4) { return; }
|
|
if (request.status < 200 || request.status >= 300) { errorCallback(); return; }
|
|
try {
|
|
var data = JSON.parse(request.responseText);
|
|
var actions = data && data.d && data.d.results ? data.d.results : [];
|
|
var serialized = actions.length && actions[0].ClientSideComponentProperties
|
|
? actions[0].ClientSideComponentProperties
|
|
: '{}';
|
|
if (serialized.length > 100000) { throw new Error('Configuration exceeds the maximum size.'); }
|
|
var properties = JSON.parse(serialized);
|
|
callback(normalize(properties));
|
|
} catch (error) { errorCallback(error); }
|
|
};
|
|
request.send();
|
|
}
|
|
|
|
function init() {
|
|
readConfiguration(render, function (error) {
|
|
log('Configuration load failed.', error);
|
|
showStatus(strings().loadError);
|
|
});
|
|
}
|
|
|
|
function start() {
|
|
if (!global._spPageContextInfo) { global.setTimeout(start, 100); return; }
|
|
init();
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start);
|
|
} else {
|
|
global.setTimeout(start, 0);
|
|
}
|
|
|
|
global.CustomBrandingClassic = { init: init, reload: init, dispose: cleanup };
|
|
}(window));
|