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 = { 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); } } }