187 lines
8.8 KiB
TypeScript
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); }
|
|
}
|
|
}
|