Files
PortalSettings/src/providers/CustomBrandingSettingsProvider.ts
T

187 lines
8.8 KiB
TypeScript

import { ISettingsProvider } from '../core/ProviderContracts';
export interface ICustomBrandingSettings {
schemaVersion: number;
portalSettings: {
providerKey: string;
contractVersion: number;
minimumPortalSettingsVersion: string;
};
enabled: boolean;
debug: boolean;
allowedCssHosts: string[];
cssfiles: Array<{ path: string; media?: string }>;
placeholdertop: { elements: any[] }; // tslint:disable-line:no-any
placeholderbottom: { elements: any[] }; // tslint:disable-line:no-any
[key: string]: any; // tslint:disable-line:no-any
}
export const CustomBrandingSettingsProvider: ISettingsProvider<ICustomBrandingSettings> = {
key: 'custombranding',
displayName: 'Custom Branding',
description: 'Stylesheets und strukturierte Inhalte für Header und Footer.',
iconName: 'Color',
schemaVersion: 2,
minimumPortalSettingsVersion: '3.0.0',
componentIds: ['035ba968-6488-4d42-86b3-0470ffcc95b9'],
storage: {
kind: 'siteUserCustomAction',
action: {
name: 'CustomBranding',
title: 'Custom Branding Application Customizer',
description: 'MSFT-Custom-Solution:CustomBranding',
location: 'ClientSideExtension.ApplicationCustomizer',
sequence: 100
}
},
sections: [
{
key: 'general', title: 'Allgemein', layout: 'twoColumns', iconName: 'Settings',
elements: [
{
type: 'boolean', key: 'enabled', path: 'enabled', label: 'CustomBranding aktivieren',
description: 'Deaktiviert Rendering und Stylesheets, erhält aber die Konfiguration.', columnSpan: 6
},
{
type: 'boolean', key: 'debug', path: 'debug', label: 'Debug-Ausgaben aktivieren',
description: 'Protokolliert Validierungs- und Ladeinformationen.', columnSpan: 6
}
]
},
{
key: 'stylesheets', title: 'Stylesheets', layout: 'twoColumns', iconName: 'FileCSS',
description: 'Lokale Stylesheets sind direkt erlaubt; externe Hosts benötigen eine Freigabe.',
elements: [
{
type: 'json', key: 'cssfiles', path: 'cssfiles', label: 'CSS-Dateien', columnSpan: 8,
description: 'Array mit path und optional media.'
},
{
type: 'stringList', key: 'allowedCssHosts', path: 'allowedCssHosts', label: 'Freigegebene CSS-Hosts',
description: 'Nur Hostnamen; externe Stylesheets müssen HTTPS verwenden.', columnSpan: 4
},
{
type: 'notice', appearance: 'warning', title: 'Sicherheitsgrenze', columnSpan: 12,
text: 'Freigaben gelten für die gesamte Site Collection. Nehmen Sie nur kontrollierte Hosts auf.'
}
]
},
{
key: 'content', title: 'Header und Footer', layout: 'twoColumns', iconName: 'WebAppBuilderFragment',
description: 'Die Elementstrukturen werden vor dem Speichern als JSON geprüft.',
elements: [
{ type: 'json', key: 'placeholdertop', path: 'placeholdertop.elements', label: 'Header-Elemente', columnSpan: 6 },
{ type: 'json', key: 'placeholderbottom', path: 'placeholderbottom.elements', label: 'Footer-Elemente', columnSpan: 6 }
]
},
{
key: 'preview', title: 'Zusammenfassung', layout: 'singleColumn',
elements: [
{ type: 'propertySummary', title: 'Aktuelle Werte', columnSpan: 12 },
{ type: 'jsonPreview', title: 'ClientSideComponentProperties', collapsedByDefault: true, columnSpan: 12 }
]
}
],
createDefault: (): ICustomBrandingSettings => ({
schemaVersion: 2,
portalSettings: {
providerKey: 'custombranding',
contractVersion: 1,
minimumPortalSettingsVersion: '3.0.0'
},
enabled: true,
debug: false,
allowedCssHosts: [],
cssfiles: [],
placeholdertop: { elements: [] },
placeholderbottom: {
elements: [{
type: 'div',
attributes: { role: 'contentinfo', 'aria-label': 'Portalverwaltung' },
styles: {
'background-color': '#323130', color: '#ffffff', padding: '12px 16px',
'text-align': 'center', width: '100%', 'box-sizing': 'border-box'
},
children: [{
type: 'a', content: 'Portal Settings',
attributes: { href: '~sitecollection/SitePages/PortalSettings.aspx' },
styles: { color: '#ffffff', 'text-decoration': 'underline' }
}]
}]
}
}),
normalize: (value: any): ICustomBrandingSettings => { // tslint:disable-line:no-any
const source: any = value && typeof value === 'object' ? value : {}; // tslint:disable-line:no-any
const result: any = {}; // tslint:disable-line:no-any
Object.keys(source).forEach((key: string): void => { result[key] = source[key]; });
result.schemaVersion = 2;
result.portalSettings = {
providerKey: 'custombranding',
contractVersion: 1,
minimumPortalSettingsVersion: '3.0.0'
};
result.enabled = source.enabled !== false;
result.debug = source.debug === true;
result.allowedCssHosts = Array.isArray(source.allowedCssHosts) ? source.allowedCssHosts : [];
result.cssfiles = Array.isArray(source.cssfiles) ? source.cssfiles : [];
result.placeholdertop = source.placeholdertop && Array.isArray(source.placeholdertop.elements)
? source.placeholdertop : { elements: Array.isArray(source.elements) ? source.elements : [] };
result.placeholderbottom = source.placeholderbottom && Array.isArray(source.placeholderbottom.elements)
? source.placeholderbottom : { elements: [] };
delete result.elements;
return result as ICustomBrandingSettings;
},
validate: (value: ICustomBrandingSettings): string[] => {
const errors: string[] = [];
if (!Array.isArray(value.cssfiles)) { errors.push('CSS-Dateien müssen ein Array sein.'); }
if (!Array.isArray(value.allowedCssHosts)) { errors.push('CSS-Hosts müssen ein Array sein.'); }
if (!value.placeholdertop || !Array.isArray(value.placeholdertop.elements)) { errors.push('Header-Elemente sind ungültig.'); }
if (!value.placeholderbottom || !Array.isArray(value.placeholderbottom.elements)) { errors.push('Footer-Elemente sind ungültig.'); }
const cssFiles: Array<{ path: string; media?: string }> = Array.isArray(value.cssfiles) ? value.cssfiles : [];
if (cssFiles.length > 20) { errors.push('Es sind maximal 20 Stylesheets erlaubt.'); }
for (let i: number = 0; i < cssFiles.length; i++) {
if (!cssFiles[i] || typeof cssFiles[i].path !== 'string' || !cssFiles[i].path.trim()) {
errors.push('CSS-Datei ' + (i + 1) + ' besitzt keinen gültigen Pfad.');
}
}
if (value.placeholdertop && Array.isArray(value.placeholdertop.elements)) {
validateElements(value.placeholdertop.elements, 'Header', 1, { count: 0 }, errors);
}
if (value.placeholderbottom && Array.isArray(value.placeholderbottom.elements)) {
validateElements(value.placeholderbottom.elements, 'Footer', 1, { count: 0 }, errors);
}
return errors;
}
};
const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
function validateElements(elements: any[], path: string, depth: number, state: { count: number }, errors: string[]): void { // tslint:disable-line:no-any
if (depth > 8) { errors.push(path + ' überschreitet die maximale Tiefe von 8.'); return; }
if (!Array.isArray(elements)) { errors.push(path + ' muss ein Array sein.'); return; }
for (let i: number = 0; i < elements.length; i++) {
const element: any = elements[i]; // tslint:disable-line:no-any
state.count++; if (state.count > 200) { errors.push(path + ' enthält mehr als 200 Elemente.'); return; }
const tag: string = String(element && element.type || '').toLowerCase();
if (!element || AllowedTags.indexOf(tag) < 0) {
errors.push(path + '[' + i + '] verwendet einen nicht erlaubten Elementtyp.'); continue;
}
if (tag === 'img' && (!element.attributes || typeof element.attributes.alt !== 'string')) {
errors.push(path + '[' + i + '] ist ein Bild ohne alt-Attribut.');
}
if (element.attributes && typeof element.attributes === 'object') {
Object.keys(element.attributes).forEach((name: string): void => {
const lower: string = name.toLowerCase(); const attributeValue: string = String(element.attributes[name] || '');
if (lower.indexOf('on') === 0) { errors.push(path + '[' + i + '] enthält ein Ereignisattribut.'); }
if (lower === 'href' || lower === 'src') {
const protocol: RegExpMatchArray = attributeValue.trim().match(/^([a-z][a-z0-9+.-]*):/i);
const allowed: string[] = lower === 'href' ? ['http', 'https', 'mailto'] : ['http', 'https'];
if (protocol && allowed.indexOf(protocol[1].toLowerCase()) < 0) {
errors.push(path + '[' + i + '] enthält eine unsichere URL.');
}
}
});
}
if (element.children) { validateElements(element.children, path + '[' + i + '].children', depth + 1, state, errors); }
}
}