From 490e9adbd8a89d6815dcf0a8b84431527547ce82 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Mon, 20 Jul 2026 22:55:47 +0200 Subject: [PATCH] feat: Add custom branding functionality with CSS and JSON configuration - 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. --- README.md | 196 +++++++- ToDo.md | 120 +++++ build-classic.ps1 | 25 + classic/classic-deployment.md | 30 ++ classic/custom-branding-classic.js | 263 +++++++++++ config/deploy-azure-storage.json | 7 - config/package-solution.json | 17 +- config/serve.json | 182 ++----- deployment/add-custombranding.ps1 | 204 +++++--- examples/custom-branding.css | 44 ++ examples/custom-branding.example.json | 41 ++ package-lock.json | 2 +- package.json | 15 +- sharepoint/assets/elements.xml | 8 - .../customBranding/BrandingConfig.ts | 306 ++++++++++++ .../customBranding/BrandingCssLoader.ts | 97 ++++ .../customBranding/BrandingDomRenderer.ts | 44 ++ .../customBranding/BrandingTypes.ts | 45 ++ .../CustomBrandingApplicationCustomizer.ts | 446 +++++------------- src/extensions/customBranding/loc/de-de.js | 7 + src/extensions/customBranding/loc/en-us.js | 6 +- .../customBranding/loc/myStrings.d.ts | 2 + src/index.ts | 4 +- tests/BrandingConfig.test.js | 85 ++++ tests/BrandingCssLoader.test.js | 56 +++ tests/BrandingDomRenderer.test.js | 59 +++ tests/tsconfig.json | 17 + tests/validate-project.ps1 | 23 + tests/validate-static-assets.js | 46 ++ 29 files changed, 1809 insertions(+), 588 deletions(-) create mode 100644 ToDo.md create mode 100644 build-classic.ps1 create mode 100644 classic/classic-deployment.md create mode 100644 classic/custom-branding-classic.js delete mode 100644 config/deploy-azure-storage.json create mode 100644 examples/custom-branding.css create mode 100644 examples/custom-branding.example.json delete mode 100644 sharepoint/assets/elements.xml create mode 100644 src/extensions/customBranding/BrandingConfig.ts create mode 100644 src/extensions/customBranding/BrandingCssLoader.ts create mode 100644 src/extensions/customBranding/BrandingDomRenderer.ts create mode 100644 src/extensions/customBranding/BrandingTypes.ts create mode 100644 src/extensions/customBranding/loc/de-de.js create mode 100644 tests/BrandingConfig.test.js create mode 100644 tests/BrandingCssLoader.test.js create mode 100644 tests/BrandingDomRenderer.test.js create mode 100644 tests/tsconfig.json create mode 100644 tests/validate-project.ps1 create mode 100644 tests/validate-static-assets.js diff --git a/README.md b/README.md index dd833af..9031cdd 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,110 @@ -# Custom Branding +# CustomBranding -Custom Branding ist ein SPFx-1.4.1-Application-Customizer fuer SharePoint Server Subscription Edition. Die Solution laedt zentral konfigurierte Stylesheets und kann strukturierte Inhalte in den oberen und unteren SharePoint-Placeholder rendern. +CustomBranding 3.0 ist ein zentraler SPFx-1.4.1-Application-Customizer für SharePoint Server Subscription Edition. Die Solution lädt freigegebene Stylesheets und rendert eine kontrollierte Komponentenstruktur im oberen oder unteren SharePoint-Placeholder. Moderne und klassische Seiten verwenden dieselben `ClientSideComponentProperties`. -## Version +Die Konfiguration liegt in genau einer `SPSite.UserCustomAction` pro Site Collection. Es werden weder eine versteckte Liste noch ein Property Bag benötigt. Dadurch gilt das Branding automatisch für das Root Web, vorhandene Subwebs und später angelegte Subwebs. -`1.0.4.0` +## Architektur + +```text +App Catalog +└── zentral bereitgestelltes SPFx-Bundle + +Site Collection +├── SPSite.UserCustomAction mit ClientSideComponentProperties +├── moderne Seiten: SPFx Application Customizer +└── klassische Seiten: optionaler SPSite ScriptLink + +CustomHeader +├── CustomBrandingTopHost +└── MegaMenuHost (wird nicht verändert) + +CustomFooter +└── CustomBrandingBottomHost +``` + +CustomBranding verändert nur seine eigenen Host-Elemente. Das MegaMenu und andere Erweiterungen im selben Placeholder bleiben bei Navigation und erneutem Rendern erhalten. + +## Voraussetzungen und Build + +- SharePoint Server Subscription Edition +- SPFx 1.4.1 +- Node.js 8.17.0 +- npm 6.13.4 +- lokale Gulp-Version 3.9.1 + +```powershell +npm install +npm test +npm run package +``` + +`npm run package` führt zuerst die Tests und den Classic-Build aus. Das SharePoint-Paket entsteht unter `sharepoint/solution/custom-branding.sppkg`. Die Classic-Dateien liegen anschließend unter `classic/dist`. + +Die alte SPFx-Toolchain lässt sich mit aktuellen Node-Versionen nicht zuverlässig paketieren. Der abschließende Ship-Build muss deshalb mit Node.js 8.17.0 erfolgen. + +## Installation und zentrale Registrierung + +1. `custom-branding.sppkg` im App Catalog hochladen oder ersetzen und zentral bereitstellen. +2. Das Skript in der SharePoint Management Shell ausführen. +3. Eine moderne Seite mit `Strg+F5` neu laden. + +```powershell +.\deployment\add-custombranding.ps1 ` + -SiteUrl 'http://clshp001/sites/portal' ` + -CssPath '~sitecollection/SiteAssets/branding/custom-branding.css' +``` + +Das Skript arbeitet idempotent: Es erhält vorhandene Properties, entfernt doppelte oder alte web-scoped Registrierungen und erzeugt genau eine site-scoped Action. Ein bewusstes Zurücksetzen erfolgt nur mit `-ResetConfiguration`. + +Weitere Optionen: + +```powershell +# Debug-Ausgaben einschalten und einen externen HTTPS-CSS-Host freigeben +.\deployment\add-custombranding.ps1 ` + -SiteUrl 'http://clshp001/sites/portal' ` + -EnableDebug ` + -AllowedCssHost 'cdn.example.org' + +# Extension deaktivieren, Konfiguration aber erhalten +.\deployment\add-custombranding.ps1 ` + -SiteUrl 'http://clshp001/sites/portal' ` + -Disable +``` ## Konfiguration -Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCustomAction gespeichert und zentral ueber PortalSettings gepflegt: +Das aktuelle Konfigurationsschema hat die Version 2: ```json { - "cssfiles": [], + "schemaVersion": 2, + "enabled": true, + "debug": false, + "allowedCssHosts": [], + "cssfiles": [ + { + "path": "~sitecollection/SiteAssets/branding/custom-branding.css", + "media": "all" + } + ], "placeholdertop": { - "elements": [] + "elements": [ + { + "type": "section", + "attributes": { + "class": "custom-branding-banner", + "role": "region", + "aria-label": "Portalhinweis" + }, + "children": [ + { + "type": "strong", + "content": "Willkommen im Portal" + } + ] + } + ] }, "placeholderbottom": { "elements": [] @@ -22,22 +112,92 @@ Die Konfiguration wird in den `ClientSideComponentProperties` der aktiven UserCu } ``` -## Build +Bestehende 1.x-Konfigurationen mit einem Root-Array `elements` werden weiterhin als Top-Inhalt gelesen. Unbekannte Properties ignoriert die Runtime. Das vollständige Beispiel liegt unter `examples/custom-branding.example.json`. -Die Legacy-SPFx-Toolchain benoetigt Node.js 8.17.0: +### Sicherheitsgrenzen + +Erlaubte Elemente: + +`div`, `span`, `p`, `a`, `button`, `img`, `h1`, `h2`, `h3`, `strong`, `em`, `nav`, `section` + +Attribute und Styles werden pro Element über feste Allowlisten geprüft. Insbesondere gelten folgende Regeln: + +- `script`, `iframe`, alle `on*`-Attribute, `javascript:`, `data:` und CSS mit `url()` oder `expression()` werden verworfen. +- Bilder benötigen immer ein `alt`; dekorative Bilder verwenden `alt: ""`. +- Leere Links und Buttons sind nicht erlaubt; Buttons erhalten immer `type="button"`. +- Links mit `target="_blank"` erhalten automatisch `rel="noopener noreferrer"`. +- Relative CSS-Pfade und CSS derselben Origin sind erlaubt. Fremde Quellen benötigen HTTPS und einen Eintrag in `allowedCssHosts`. +- Maximal 20 Stylesheets, 200 Elemente, acht Ebenen und 100.000 Zeichen Konfiguration werden verarbeitet. + +Unsichere Teilwerte werden kontrolliert verworfen, ohne die SharePoint-Seite zu blockieren. Details erscheinen nur bei `debug: true` in der Browserkonsole mit dem Präfix `[CustomBranding]`. + +## Classic SharePoint + +Der SPFx Application Customizer selbst läuft nicht auf klassischen Seiten. Version 3.0 enthält deshalb eine separate ES5-Runtime mit demselben Sicherheits- und Konfigurationsmodell. ```powershell -npm install -npm run package +npm run build:classic ``` -Das Paket wird unter `sharepoint/solution/custom-branding.sppkg` erzeugt. +Danach `classic/dist/custom-branding-classic.js` nach beispielsweise `/SiteAssets/custom-branding/` hochladen und zentral registrieren: -## Installation +```powershell +.\deployment\add-custombranding.ps1 ` + -SiteUrl 'http://clshp001/sites/portal' ` + -ClassicScriptUrl '~sitecollection/SiteAssets/custom-branding/custom-branding-classic.js' +``` -1. `custom-branding.sppkg` in den App Catalog laden beziehungsweise ersetzen. -2. Die App im Root Web der Site Collection installieren oder aktualisieren. -3. Beim Aktivieren wird der Custom Branding Application Customizer automatisch web-scoped registriert. -4. PortalSettings mit `Strg+F5` neu laden und den Tab **Custom Branding** konfigurieren. +Der site-scoped ScriptLink gilt auch für später angelegte Subwebs. Entfernen lässt er sich mit `-RemoveClassicScriptLink`. Weitere Hinweise stehen in `classic/classic-deployment.md`. -Das Skript `deployment/add-custombranding.ps1` bleibt fuer automatisierte Rollouts oder eine ausdruecklich site-scoped Registrierung verfuegbar. +## PortalSettings v3 + +CustomBranding funktioniert unabhängig von PortalSettings. Das geplante PortalSettings-v3-Webpart kann die Action anhand folgender Werte erkennen und die Properties schemaerhaltend bearbeiten: + +- Component ID: `035ba968-6488-4d42-86b3-0470ffcc95b9` +- Location: `ClientSideExtension.ApplicationCustomizer` +- Scope: `SPSite.UserCustomActions` +- Schema: `schemaVersion: 2` + +Ein Editor muss unbekannte Properties erhalten und vor dem Speichern dieselben Element-, Attribut-, URL- und CSS-Grenzen beachten. + +## Diagnose + +Zentrale Registrierung prüfen: + +```powershell +$site = Get-SPSite 'http://clshp001/sites/portal' +$site.UserCustomActions | Where-Object { + $_.ClientSideComponentId -eq [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9' +} | Select-Object Id, Title, Location, ClientSideComponentProperties +$site.Dispose() +``` + +Browserkonsole auf einer modernen Seite: + +```javascript +performance.getEntriesByType('resource') + .map(function (entry) { return entry.name; }) + .filter(function (url) { return url.toLowerCase().indexOf('custom-branding') >= 0; }); + +({ + header: !!document.getElementById('CustomHeader'), + branding: !!document.getElementById('CustomBrandingTopHost'), + megaMenu: !!document.getElementById('MegaMenuHost') +}); +``` + +Classic-Diagnose: + +```javascript +typeof window.CustomBrandingClassic +window.CustomBrandingClassic.reload() +``` + +## Upgrade von 1.x + +1. Paket im App Catalog durch Version `3.0.0.0` ersetzen und bereitstellen. +2. `add-custombranding.ps1` einmal pro Site Collection ausführen; bestehende Properties bleiben erhalten. +3. Moderne und gegebenenfalls klassische Seiten testen. +4. Erst nach erfolgreicher Abnahme alte web-scoped Aktionen als bereinigt bestätigen. + +Ein Downgrade sollte nur zusammen mit einer Sicherung der `ClientSideComponentProperties` erfolgen. diff --git a/ToDo.md b/ToDo.md new file mode 100644 index 0000000..bf44ce7 --- /dev/null +++ b/ToDo.md @@ -0,0 +1,120 @@ +# CustomBranding – Umsetzungsstand + +Analysestand: 20.07.2026 +Umgesetzt: 20.07.2026 +Ausgangsversion: 1.0.4 +Zielversion: 3.0.0 +Status: Implementierung und lokale Qualitätssicherung abgeschlossen + +## Zielbild + +CustomBranding wird pro Site Collection genau einmal über eine `SPSite.UserCustomAction` registriert und konfiguriert. Moderne und klassische Seiten lesen dieselben versionierten `ClientSideComponentProperties`. Es werden weder eine versteckte Liste noch ein Property Bag eingesetzt. PortalSettings bleibt in diesem Durchlauf unverändert; der Vertrag für das spätere PortalSettings-v3-Webpart ist in der README dokumentiert. + +## Sicherheit und Konfigurationsmodell + +- [x] Stringbasiertes HTML und `innerHTML` durch einen DOM-Renderer ersetzt. +- [x] Laufzeit-Whitelist für Elementtypen umgesetzt. +- [x] Globale und elementspezifische Attribut-Allowlisten umgesetzt. +- [x] Alle Ereignisattribute `on*` gesperrt. +- [x] Relative URLs, HTTP/HTTPS und `mailto` kontrolliert; `javascript:` und `data:` gesperrt. +- [x] `target="_blank"` automatisch mit `noopener noreferrer` abgesichert. +- [x] Style-Property-Allowlist und Prüfung gefährlicher CSS-Werte umgesetzt. +- [x] Externe Stylesheets nur per HTTPS und expliziter Host-Allowlist ermöglicht. +- [x] Limits für Konfigurationsgröße, Rekursion, Elemente und Stylesheets umgesetzt. +- [x] Versioniertes Schema 2 eingeführt. +- [x] Bestehende 1.x-Konfigurationen mit Root-Property `elements` weiter unterstützt. +- [x] `enabled`, `debug`, `allowedCssHosts`, `cssfiles`, Top und Bottom konsistent typisiert. +- [x] Unbekannte Properties werden ignoriert; Warnungen erscheinen nur im Debug-Modus. +- [x] Keine feste Abhängigkeit oder Verlinkung zu PortalSettings mehr vorhanden. + +## Lifecycle und Stylesheet-Management + +- [x] Top- und Bottom-Placeholder werden unabhängig angefordert und gerendert. +- [x] Nach dem Dispose eines einzelnen Placeholders kann dieser später erneut erworben werden. +- [x] `changedEvent` wird beim Extension-Dispose deregistriert. +- [x] Dispose ist idempotent und setzt alle eigenen Referenzen zurück. +- [x] Eigene Hosts werden entfernt, fremde Knoten bleiben erhalten. +- [x] CSS-URLs werden absolut normalisiert und instanzübergreifend dedupliziert. +- [x] Reihenfolge und optionale `media`-Angabe bleiben erhalten. +- [x] Ladefehler und Timeouts werden debug-gesteuert protokolliert. +- [x] Fremde vorhandene Stylesheets werden wiederverwendet und nie entfernt. +- [x] Nur Stylesheets der letzten eigenen Referenz werden beim Dispose entfernt. + +## Barrierefreiheit und Mehrsprachigkeit + +- [x] Bilder ohne `alt` werden verworfen; dekorative Bilder mit leerem `alt` sind möglich. +- [x] Buttons erhalten immer `type="button"`; leere Links und Buttons werden verworfen. +- [x] ARIA-Attribute sind auf eine feste Allowlist begrenzt. +- [x] Fehlermeldungen verwenden `role="status"`. +- [x] Deutsches und englisches Localized Resource Bundle ergänzt und verwendet. +- [x] Debug-gesteuerter Logger mit einheitlichem Präfix umgesetzt. +- [x] Beispiel-CSS mit sichtbaren Fokuszuständen, responsivem Layout und Forced-Colors-Regel ergänzt. +- [ ] Tastaturbedienung, 200-Prozent-Zoom und Windows-Hochkontrast in der Zielumgebung manuell abnehmen. + +## Bereitstellung und Scope + +- [x] Paket auf `skipFeatureDeployment` und zentrale `SPSite.UserCustomAction` umgestellt. +- [x] Alte web-scoped Feature-Registrierung und `sharepoint/assets/elements.xml` entfernt. +- [x] Nicht verwendete Azure-Storage-Konfiguration entfernt. +- [x] SharePoint-SE-Deploymentskript verwendet zuerst das PowerShell-Modul und nur als Fallback das Snap-in. +- [x] Doppelte Site- und alte Web-Actions werden idempotent bereinigt. +- [x] Vorhandene Properties bleiben standardmäßig erhalten. +- [x] Bewusste Optionen für Reset, Debug, Disable und externe CSS-Hosts ergänzt. +- [ ] Root Web, vorhandenes Subweb und anschließend neu angelegtes Subweb auf dem Zielserver abnehmen. + +## MegaMenu-Integration + +- [x] Stabile Hosts `CustomHeader`, `CustomBrandingTopHost`, `MegaMenuHost`, `CustomFooter` und `CustomBrandingBottomHost` definiert. +- [x] Renderer verändert ausschließlich eigene Host-Inhalte. +- [x] Wiederholtes Rendern löscht den `MegaMenuHost` oder fremde DOM-Knoten nicht. +- [x] Gemeinsamer Betrieb und Ownership in der README dokumentiert. +- [ ] Beide realen Lade-Reihenfolgen mit installiertem MegaMenu auf dem Zielserver abnehmen. + +## Classic SharePoint + +- [x] Classic-Unterstützung als fachliches Ziel bestätigt. +- [x] Separate ES5-Runtime ohne SPFx-Abhängigkeit erstellt. +- [x] Classic-Runtime verwendet dasselbe zentrale Schema und dieselben Sicherheitsgrenzen. +- [x] Automatischer Classic-Build über `npm run build:classic` umgesetzt. +- [x] Optionaler site-scoped ScriptLink im Deploymentskript ergänzt. +- [x] Bereitstellung, Entfernung und Browserdiagnose dokumentiert. +- [ ] Classic-Listen-, Bibliotheks- und Publishing-Seite auf dem Zielserver visuell abnehmen. + +## Projektbereinigung und Dokumentation + +- [x] Node-Engine auf `>=6.9.0 <9.0.0` gesetzt und Node.js 8.17.0 dokumentiert. +- [x] Package und Solution konsistent auf Version 3.0.0 angehoben. +- [x] Nicht verwendete Abhängigkeit `@microsoft/sp-dialog` entfernt. +- [x] Localized Resources aktiviert und ergänzt. +- [x] `serve.json` auf das echte, sichere Schema korrigiert. +- [x] Nicht verwendete Dateien entfernt. +- [x] README mit Architektur, Schema, Sicherheitsgrenzen, Deployment, Classic, Upgrade und Diagnose neu erstellt. +- [x] Sichere JSON- und CSS-Beispiele ergänzt. + +## Automatisierte Qualitätssicherung + +- [x] Testbare Module für Normalisierung, URLs, Attribute, Styles, DOM und CSS-Loading ausgelagert. +- [x] Positivtests für alle erlaubten Elementtypen ergänzt. +- [x] Negativtests für Tags, Event-Attribute, URLs, Styles und fehlendes `alt` ergänzt. +- [x] Tests für Legacy-Schema, Schema 2, Tiefen- und Größenlimit ergänzt. +- [x] DOM-Test für sichere Elementerzeugung und Cleanup ergänzt. +- [x] CSS-Tests für Deduplizierung, Reihenfolge, Referenzen und fremde Stylesheets ergänzt. +- [x] Statische Prüfungen für JSON, Konfiguration, Lifecycle-Regeln, Classic-JavaScript und PowerShell ergänzt. +- [x] `npm test` als zwingendes Gate vor `npm run package` eingebaut. +- [x] Classic-Build als Teil der Paketierung automatisiert. +- [x] Ship-Build mit Node.js 8.17.0 als abschließender Zielserver-Schritt dokumentiert. + +## Lokale Prüfergebnisse + +- [x] Hauptprojekt kompiliert mit TypeScript ohne Ausgabe. +- [x] `npm test`: 32 Config-, 6 DOM-, 8 CSS- und 20 statische Prüfungen erfolgreich. +- [x] PowerShell-Syntax für Deployment und Classic-Build erfolgreich geprüft. +- [x] Classic-Build erzeugt beide Distributionsdateien erfolgreich. + +## Noch offene Abnahme auf SharePoint SE + +1. Ship-Paket mit Node.js 8.17.0 bauen und im App Catalog aktualisieren. +2. Zentrale Registrierung mit `deployment/add-custombranding.ps1` herstellen. +3. Moderne Seiten in Root Web, bestehendem und neuem Subweb prüfen. +4. MegaMenu-Ladereihenfolgen sowie Tastatur, Zoom und Hochkontrast prüfen. +5. Optional Classic-Runtime bereitstellen und klassische Seitentypen prüfen. diff --git a/build-classic.ps1 b/build-classic.ps1 new file mode 100644 index 0000000..b537d2b --- /dev/null +++ b/build-classic.ps1 @@ -0,0 +1,25 @@ +param( + [string]$OutputPath = '' +) + +$sourcePath = Join-Path $PSScriptRoot 'classic' +$resolvedOutputPath = if ([string]::IsNullOrWhiteSpace($OutputPath)) { + Join-Path $sourcePath 'dist' +} +else { + $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath) +} + +if (-not (Test-Path -LiteralPath $resolvedOutputPath)) { + New-Item -ItemType Directory -Path $resolvedOutputPath -Force | Out-Null +} + +foreach ($file in @('custom-branding-classic.js', 'classic-deployment.md')) { + $source = Join-Path $sourcePath $file + if (-not (Test-Path -LiteralPath $source)) { throw ('Classic-Datei fehlt: ' + $source) } + Copy-Item -LiteralPath $source -Destination (Join-Path $resolvedOutputPath $file) -Force + Write-Host ('Kopiert: ' + $file) -ForegroundColor Green +} + +Write-Host ('Classic-Paket erstellt: ' + $resolvedOutputPath) -ForegroundColor Green + diff --git a/classic/classic-deployment.md b/classic/classic-deployment.md new file mode 100644 index 0000000..9617b76 --- /dev/null +++ b/classic/classic-deployment.md @@ -0,0 +1,30 @@ +# CustomBranding für klassische SharePoint-Seiten + +Die Classic-Runtime liest dieselbe zentrale `SPSite.UserCustomAction` wie der moderne Application Customizer. Sie rendert das abgesicherte Konfigurationsschema ohne SPFx-Abhängigkeit und lädt die freigegebenen Stylesheets. + +## Bereitstellung + +1. `npm run build:classic` ausführen. +2. `classic/dist/custom-branding-classic.js` beispielsweise nach `/SiteAssets/custom-branding/` hochladen. +3. Den site-scoped ScriptLink zusammen mit der zentralen Application-Customizer-Action registrieren: + +```powershell +.\deployment\add-custombranding.ps1 ` + -SiteUrl 'http://clshp001/sites/portal' ` + -ClassicScriptUrl '~sitecollection/SiteAssets/custom-branding/custom-branding-classic.js' +``` + +Der ScriptLink gilt für Root Web, vorhandene Subwebs und später angelegte Subwebs der Site Collection. Zum Entfernen: + +```powershell +.\deployment\add-custombranding.ps1 ` + -SiteUrl 'http://clshp001/sites/portal' ` + -RemoveClassicScriptLink +``` + +## Diagnose + +```javascript +typeof window.CustomBrandingClassic +window.CustomBrandingClassic.reload() +``` diff --git a/classic/custom-branding-classic.js b/classic/custom-branding-classic.js new file mode 100644 index 0000000..e44196e --- /dev/null +++ b/classic/custom-branding-classic.js @@ -0,0 +1,263 @@ +/* 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)); diff --git a/config/deploy-azure-storage.json b/config/deploy-azure-storage.json deleted file mode 100644 index 782a3a6..0000000 --- a/config/deploy-azure-storage.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json", - "workingDir": "./temp/deploy/", - "account": "", - "container": "custom-branding", - "accessKey": "" -} \ No newline at end of file diff --git a/config/package-solution.json b/config/package-solution.json index 968912e..c230f50 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,22 +3,9 @@ "solution": { "name": "custom-branding-client-side-solution", "id": "03a7c4de-e031-4b83-a683-5ca5c364166e", - "version": "1.0.4.0", + "version": "3.0.0.0", "includeClientSideAssets": true, - "skipFeatureDeployment": false, - "features": [ - { - "title": "Custom Branding extension registration", - "description": "Registers the Custom Branding Application Customizer in the host web.", - "id": "2eb6deaa-a4a0-40c5-9f15-6bdaff36094f", - "version": "1.0.4.0", - "assets": { - "elementManifests": [ - "elements.xml" - ] - } - } - ] + "skipFeatureDeployment": true }, "paths": { "zippedPackage": "solution/custom-branding.sppkg" diff --git a/config/serve.json b/config/serve.json index 2bdd465..41ca43d 100644 --- a/config/serve.json +++ b/config/serve.json @@ -9,161 +9,43 @@ "035ba968-6488-4d42-86b3-0470ffcc95b9": { "location": "ClientSideExtension.ApplicationCustomizer", "properties": { - "elements": [ - { - "type": "div", - "styles": { - "background-color": "#0078d4", - "color": "white", - "padding": "15px 20px", - "text-align": "center", - "font-family": "Segoe UI, sans-serif", - "font-size": "14px" - }, - "content": "Willkommen auf unserem SharePoint Portal!" - } - ] - } - } - } - }, - "warning": { - "pageUrl": "http://clshp001/", - "customActions": { - "035ba968-6488-4d42-86b3-0470ffcc95b9": { - "location": "ClientSideExtension.ApplicationCustomizer", - "properties": { - "elements": [ - { - "type": "div", - "styles": { - "background-color": "#d83b01", - "color": "white", - "padding": "15px", - "text-align": "center", - "font-weight": "bold" - }, - "content": "⚠️ Achtung: Wartungsarbeiten am Wochenende" - } - ] - } - } - } - }, - "withLink": { - "pageUrl": "http://clshp001/", - "customActions": { - "035ba968-6488-4d42-86b3-0470ffcc95b9": { - "location": "ClientSideExtension.ApplicationCustomizer", - "properties": { - "elements": [ - { - "type": "div", - "styles": { - "background-color": "#0078d4", - "color": "white", - "padding": "12px 20px", - "text-align": "center" - }, - "children": [ - { - "type": "span", - "content": "Wichtige Mitteilung: Systemwartung geplant. ", - "styles": { - "font-weight": "bold" - } + "schemaVersion": 2, + "enabled": true, + "debug": true, + "allowedCssHosts": [], + "cssfiles": [], + "placeholdertop": { + "elements": [ + { + "type": "section", + "attributes": { + "aria-label": "Portalhinweis" }, - { - "type": "a", - "content": "Mehr Informationen", - "attributes": { - "href": "/sites/it/SitePages/Wartung.aspx", - "target": "_blank" + "styles": { + "background-color": "#0078d4", + "color": "#ffffff", + "padding": "12px 20px", + "text-align": "center" + }, + "children": [ + { + "type": "strong", + "content": "Willkommen im SharePoint-Portal" }, - "styles": { - "color": "white", - "text-decoration": "underline", - "margin-left": "5px" + { + "type": "span", + "content": " – aktuelle Informationen finden Sie im Intranet." } - } - ] - } - ] - } - } - } - }, - "gradient": { - "pageUrl": "http://clshp001/", - "customActions": { - "035ba968-6488-4d42-86b3-0470ffcc95b9": { - "location": "ClientSideExtension.ApplicationCustomizer", - "properties": { - "elements": [ - { - "type": "div", - "styles": { - "background": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", - "color": "white", - "padding": "20px" - }, - "children": [ - { - "type": "div", - "styles": { - "display": "flex", - "justify-content": "space-between", - "align-items": "center", - "max-width": "1200px", - "margin": "0 auto" - }, - "children": [ - { - "type": "div", - "children": [ - { - "type": "p", - "styles": { - "margin": "0", - "font-size": "18px", - "font-weight": "bold" - }, - "content": "Neue Funktionen verfügbar!" - }, - { - "type": "p", - "styles": { - "margin": "5px 0 0 0", - "font-size": "14px" - }, - "content": "Entdecken Sie die neuesten Updates" - } - ] - }, - { - "type": "button", - "content": "Mehr erfahren", - "attributes": { - "onclick": "window.location.href='/sites/news/SitePages/Updates.aspx'" - }, - "styles": { - "background-color": "white", - "color": "#667eea", - "border": "none", - "padding": "10px 20px", - "border-radius": "5px", - "cursor": "pointer", - "font-weight": "bold" - } - } - ] - } - ] - } - ] + ] + } + ] + }, + "placeholderbottom": { + "elements": [] + } } } } } } -} \ No newline at end of file +} diff --git a/deployment/add-custombranding.ps1 b/deployment/add-custombranding.ps1 index aef28d8..055b1fd 100644 --- a/deployment/add-custombranding.ps1 +++ b/deployment/add-custombranding.ps1 @@ -5,100 +5,190 @@ param( [string]$CssPath = '', + [string[]]$AllowedCssHost = @(), + + [switch]$EnableDebug, + + [switch]$Disable, + + [switch]$ResetConfiguration, + + [string]$ClassicScriptUrl = '', + + [switch]$RemoveClassicScriptLink, + [string]$Description = 'MSFT-Custom-Solution:CustomBranding' ) -Add-PSSnapin Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue | Out-Null +if (-not (Get-Command -Name Get-SPSite -ErrorAction SilentlyContinue)) { + if (Get-Module -ListAvailable -Name Microsoft.SharePoint.PowerShell) { + Import-Module Microsoft.SharePoint.PowerShell -DisableNameChecking + } + elseif (Get-PSSnapin -Registered -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue) { + Add-PSSnapin Microsoft.SharePoint.PowerShell + } + else { + throw 'Die SharePoint PowerShell-Komponenten wurden nicht gefunden. Bitte in der SharePoint Management Shell ausfuehren.' + } +} -$componentId = '035ba968-6488-4d42-86b3-0470ffcc95b9' -$location = 'ClientSideExtension.ApplicationCustomizer' -$name = 'CustomBranding' -$title = 'Custom Branding' +$componentId = [Guid]'035ba968-6488-4d42-86b3-0470ffcc95b9' +$componentIdText = $componentId.ToString().ToLowerInvariant() +$componentLocation = 'ClientSideExtension.ApplicationCustomizer' +$componentName = 'CustomBranding' +$componentTitle = 'Custom Branding Application Customizer' +$classicActionName = 'CustomBranding.Classic.ScriptLink' -function Get-CustomBrandingPropertiesJson { - param( - [string]$Path - ) - - $config = @{ +function New-DefaultCustomBrandingConfiguration { + return @{ + schemaVersion = 2 + enabled = $true + debug = $false + allowedCssHosts = @() cssfiles = @() placeholdertop = @{ elements = @() } placeholderbottom = @{ elements = @() } } +} - if ($Path -and $Path.Trim().Length -gt 0) { - $config.cssfiles += @{ path = $Path.Trim() } +function ConvertTo-Hashtable { + param([object]$InputObject) + if ($null -eq $InputObject) { return $null } + if ($InputObject -is [System.Collections.IDictionary]) { + $result = @{} + foreach ($key in $InputObject.Keys) { $result[$key] = ConvertTo-Hashtable $InputObject[$key] } + return $result } + if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) { + return @($InputObject | ForEach-Object { ConvertTo-Hashtable $_ }) + } + if ($InputObject.PSObject -and $InputObject.PSObject.Properties.Count -gt 0 -and + -not ($InputObject -is [string]) -and -not ($InputObject.GetType().IsPrimitive)) { + $result = @{} + foreach ($property in $InputObject.PSObject.Properties) { $result[$property.Name] = ConvertTo-Hashtable $property.Value } + return $result + } + return $InputObject +} - return ($config | ConvertTo-Json -Depth 10 -Compress) +function Get-ExistingConfiguration { + param([Microsoft.SharePoint.SPUserCustomAction]$Action) + if ($ResetConfiguration -or -not $Action -or [string]::IsNullOrWhiteSpace($Action.ClientSideComponentProperties)) { + return New-DefaultCustomBrandingConfiguration + } + try { + $parsed = $Action.ClientSideComponentProperties | ConvertFrom-Json + $configuration = ConvertTo-Hashtable $parsed + if (-not $configuration) { return New-DefaultCustomBrandingConfiguration } + return $configuration + } + catch { + Write-Warning 'Vorhandene ClientSideComponentProperties waren ungueltig und werden durch Standardwerte ersetzt.' + return New-DefaultCustomBrandingConfiguration + } } function Remove-WebScopedComponentActions { - param( - [Microsoft.SharePoint.SPSite]$CurrentSite, - [string]$CurrentComponentId, - [string]$CurrentLocation - ) - - $removedCount = 0 - + param([Microsoft.SharePoint.SPSite]$CurrentSite) + $removed = 0 foreach ($web in $CurrentSite.AllWebs) { try { - $webActions = @($web.UserCustomActions | Where-Object { - $_.Location -eq $CurrentLocation -and + $actions = @($web.UserCustomActions | Where-Object { + $_.Location -eq $componentLocation -and $_.ClientSideComponentId -and - $_.ClientSideComponentId.ToString().ToLower() -eq $CurrentComponentId + $_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText }) - - foreach ($webAction in $webActions) { - $web.UserCustomActions.Delete($webAction.Id) - $removedCount++ - } - - if ($webActions.Count -gt 0) { - $web.Update() + foreach ($action in $actions) { + $action.Delete() + $removed++ } + if ($actions.Count -gt 0) { $web.Update() } } finally { $web.Dispose() } } - - return $removedCount + return $removed } $site = Get-SPSite -Identity $SiteUrl try { - $removedWebScopedActions = Remove-WebScopedComponentActions -CurrentSite $site -CurrentComponentId $componentId -CurrentLocation $location + $siteActions = @($site.UserCustomActions | Where-Object { + $_.Location -eq $componentLocation -and + $_.ClientSideComponentId -and + $_.ClientSideComponentId.ToString().ToLowerInvariant() -eq $componentIdText + }) + $action = $siteActions | Select-Object -First 1 + $configuration = Get-ExistingConfiguration -Action $action - $existingAction = $site.UserCustomActions | Where-Object { - $_.Location -eq $location -and $_.ClientSideComponentId -and $_.ClientSideComponentId.ToString().ToLower() -eq $componentId - } | Select-Object -First 1 + $configuration.schemaVersion = 2 + if (-not $configuration.ContainsKey('placeholdertop')) { $configuration.placeholdertop = @{ elements = @() } } + if (-not $configuration.ContainsKey('placeholderbottom')) { $configuration.placeholderbottom = @{ elements = @() } } + if (-not $configuration.ContainsKey('cssfiles')) { $configuration.cssfiles = @() } + if (-not $configuration.ContainsKey('allowedCssHosts')) { $configuration.allowedCssHosts = @() } + if ($PSBoundParameters.ContainsKey('EnableDebug')) { $configuration.debug = [bool]$EnableDebug.IsPresent } + if ($PSBoundParameters.ContainsKey('Disable')) { $configuration.enabled = -not [bool]$Disable.IsPresent } - if ($existingAction) { - $action = $existingAction - Write-Host 'Aktualisiere vorhandene site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow + if (-not [string]::IsNullOrWhiteSpace($CssPath)) { + $trimmedPath = $CssPath.Trim() + $existingPaths = @($configuration.cssfiles | ForEach-Object { + if ($_ -is [System.Collections.IDictionary]) { [string]$_['path'] } else { [string]$_.path } + }) + if ($existingPaths -notcontains $trimmedPath) { + $configuration.cssfiles = @($configuration.cssfiles) + @(@{ path = $trimmedPath; media = 'all' }) + } + } + if ($AllowedCssHost.Count -gt 0) { + $configuration.allowedCssHosts = @($AllowedCssHost | ForEach-Object { $_.Trim().ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique) + } + + if (-not $action) { + $action = $site.UserCustomActions.Add() + Write-Host 'Erstelle zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow } else { - $action = $site.UserCustomActions.Add() - Write-Host 'Erstelle neue site-scoped Custom Branding UserCustomAction...' -ForegroundColor Yellow + Write-Host 'Aktualisiere zentrale CustomBranding Site-Collection-Action...' -ForegroundColor Yellow } - - $action.Name = $name - $action.Title = $title + $action.Name = $componentName + $action.Title = $componentTitle $action.Description = $Description - $action.Location = $location - $action.ClientSideComponentId = [Guid]$componentId - $action.ClientSideComponentProperties = Get-CustomBrandingPropertiesJson -Path $CssPath + $action.Location = $componentLocation + $action.Sequence = 100 + $action.ClientSideComponentId = $componentId + $action.ClientSideComponentProperties = [string]($configuration | ConvertTo-Json -Depth 20 -Compress) $action.Update() - Write-Host 'Custom Branding wurde site-scoped registriert.' -ForegroundColor Green - Write-Host ('Entfernte web-scoped Eintraege: ' + $removedWebScopedActions) - Write-Host ('Beschreibung: ' + $Description) + @($siteActions | Select-Object -Skip 1) | ForEach-Object { + Write-Host ('Entferne doppelte Site-Collection-Action: ' + $_.Id) -ForegroundColor Yellow + $_.Delete() + } + $removedWebActions = Remove-WebScopedComponentActions -CurrentSite $site + + $classicActions = @($site.UserCustomActions | Where-Object { + $_.Location -eq 'ScriptLink' -and $_.Name -eq $classicActionName + }) + if ($RemoveClassicScriptLink) { + foreach ($classicAction in $classicActions) { $classicAction.Delete() } + Write-Host 'Classic ScriptLink wurde entfernt.' -ForegroundColor Yellow + } + elseif (-not [string]::IsNullOrWhiteSpace($ClassicScriptUrl)) { + $classicAction = $classicActions | Select-Object -First 1 + if (-not $classicAction) { $classicAction = $site.UserCustomActions.Add() } + $classicAction.Name = $classicActionName + $classicAction.Title = 'CustomBranding Classic Runtime' + $classicAction.Description = $Description + $classicAction.Location = 'ScriptLink' + $classicAction.Sequence = 101 + $classicAction.ScriptSrc = $ClassicScriptUrl.Trim() + $classicAction.Update() + @($classicActions | Select-Object -Skip 1) | ForEach-Object { $_.Delete() } + Write-Host ('Classic ScriptLink: ' + $classicAction.ScriptSrc) -ForegroundColor Green + } + + Write-Host 'CustomBranding wurde zentral fuer die Site Collection registriert.' -ForegroundColor Green + Write-Host ('Entfernte web-scoped Actions: ' + $removedWebActions) Write-Host ('Properties: ' + $action.ClientSideComponentProperties) } finally { - if ($site) { - $site.Dispose() - } -} \ No newline at end of file + if ($site) { $site.Dispose() } +} diff --git a/examples/custom-branding.css b/examples/custom-branding.css new file mode 100644 index 0000000..6e85290 --- /dev/null +++ b/examples/custom-branding.css @@ -0,0 +1,44 @@ +.custom-branding-banner { + align-items: center; + background: #f3f2f1; + border-bottom: 1px solid #edebe9; + box-sizing: border-box; + color: #323130; + display: flex; + font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif; + font-size: 14px; + gap: 16px; + min-height: 40px; + padding: 8px 24px; +} + +.custom-branding-link { + color: #005a9e; + text-decoration: none; +} + +.custom-branding-link:hover { + color: #004578; + text-decoration: underline; +} + +.custom-branding-link:focus-visible, +.custom-branding-banner button:focus-visible { + outline: 2px solid #005a9e; + outline-offset: 2px; +} + +@media screen and (max-width: 640px) { + .custom-branding-banner { + align-items: flex-start; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + } +} + +@media (forced-colors: active) { + .custom-branding-banner { + border-bottom: 1px solid CanvasText; + } +} diff --git a/examples/custom-branding.example.json b/examples/custom-branding.example.json new file mode 100644 index 0000000..db826e0 --- /dev/null +++ b/examples/custom-branding.example.json @@ -0,0 +1,41 @@ +{ + "schemaVersion": 2, + "enabled": true, + "debug": false, + "allowedCssHosts": [], + "cssfiles": [ + { + "path": "~sitecollection/SiteAssets/branding/custom-branding.css", + "media": "all" + } + ], + "placeholdertop": { + "elements": [ + { + "type": "section", + "attributes": { + "class": "custom-branding-banner", + "role": "region", + "aria-label": "Portalhinweis" + }, + "children": [ + { + "type": "strong", + "content": "Willkommen im Portal" + }, + { + "type": "a", + "content": "Zur Startseite", + "attributes": { + "href": "~sitecollection/SitePages/Home.aspx", + "class": "custom-branding-link" + } + } + ] + } + ] + }, + "placeholderbottom": { + "elements": [] + } +} diff --git a/package-lock.json b/package-lock.json index 214c824..0221819 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "custom-branding", - "version": "1.0.4", + "version": "3.0.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index a17f3f3..86ea43f 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,23 @@ { "name": "custom-branding", - "version": "1.0.4", + "version": "3.0.0", "private": true, "main": "lib/index.js", "engines": { - "node": ">=0.10.0" + "node": ">=6.9.0 <9.0.0" }, "scripts": { - "build": "gulp bundle", + "build": "npm run build:classic && gulp bundle", + "build:classic": "powershell -NoProfile -ExecutionPolicy Bypass -File ./build-classic.ps1", "clean": "gulp clean", - "test": "gulp test", - "package": "gulp clean && gulp bundle --ship && gulp package-solution --ship" + "test": "tsc -p tests/tsconfig.json && node tests/BrandingConfig.test.js && node tests/BrandingDomRenderer.test.js && node tests/BrandingCssLoader.test.js && node tests/validate-static-assets.js && powershell -NoProfile -ExecutionPolicy Bypass -File ./tests/validate-project.ps1", + "package": "npm test && npm run build:classic && gulp clean && gulp bundle --ship && gulp package-solution --ship" }, "dependencies": { "@microsoft/sp-core-library": "~1.4.0", "@microsoft/decorators": "~1.4.0", "@types/webpack-env": "1.13.1", "@types/es6-promise": "0.0.33", - "@microsoft/sp-dialog": "~1.4.0", "@microsoft/sp-application-base": "~1.4.0" }, "devDependencies": { @@ -27,6 +27,7 @@ "gulp": "~3.9.1", "@types/chai": "3.4.34", "@types/mocha": "2.2.38", - "ajv": "~5.2.2" + "ajv": "~5.2.2", + "jsdom": "9.12.0" } } diff --git a/sharepoint/assets/elements.xml b/sharepoint/assets/elements.xml deleted file mode 100644 index 067af54..0000000 --- a/sharepoint/assets/elements.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/src/extensions/customBranding/BrandingConfig.ts b/src/extensions/customBranding/BrandingConfig.ts new file mode 100644 index 0000000..5c36196 --- /dev/null +++ b/src/extensions/customBranding/BrandingConfig.ts @@ -0,0 +1,306 @@ +// tslint:disable:no-any max-line-length +import { + BrandingElementType, + IBrandingElement, + IBrandingNormalizationResult, + ICssFile, + ICustomBrandingApplicationCustomizerProperties, + ICustomBrandingConfig +} from './BrandingTypes'; + +export const BrandingSchemaVersion: number = 2; +export const MaxBrandingDepth: number = 8; +export const MaxBrandingElements: number = 200; +export const MaxBrandingConfigurationLength: number = 100000; + +const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']; +const GlobalAttributes: string[] = ['id', 'class', 'title', 'role', 'aria-label', 'aria-hidden', 'aria-current', 'aria-live']; +const AllowedStyles: string[] = [ + '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' +]; + +interface INormalizationState { + count: number; + warnings: string[]; + siteCollectionUrl: string; +} + +export function normalizeBrandingConfig( + rawProperties: ICustomBrandingApplicationCustomizerProperties | any, + siteCollectionUrl: string +): IBrandingNormalizationResult { + const warnings: string[] = []; + const raw: any = rawProperties && typeof rawProperties === 'object' ? rawProperties : {}; + let serializedLength: number = 0; + try { + serializedLength = JSON.stringify(raw).length; + } catch (error) { + warnings.push('Configuration could not be serialized and was ignored.'); + } + if (serializedLength > MaxBrandingConfigurationLength) { + warnings.push('Configuration exceeds the maximum size and was ignored.'); + return { config: createEmptyConfig(), warnings: warnings }; + } + + const allowedCssHosts: string[] = normalizeHosts(raw.allowedCssHosts); + const state: INormalizationState = { count: 0, warnings: warnings, siteCollectionUrl: siteCollectionUrl || '' }; + const topSource: any[] = raw.placeholdertop && Array.isArray(raw.placeholdertop.elements) + ? raw.placeholdertop.elements + : (Array.isArray(raw.elements) ? raw.elements : []); + const bottomSource: any[] = raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements) + ? raw.placeholderbottom.elements + : []; + + const config: ICustomBrandingConfig = { + schemaVersion: BrandingSchemaVersion, + enabled: raw.enabled !== false, + debug: raw.debug === true, + allowedCssHosts: allowedCssHosts, + cssfiles: normalizeCssFiles(raw.cssfiles, siteCollectionUrl, allowedCssHosts, warnings), + placeholdertop: { elements: normalizeElements(topSource, 1, state) }, + placeholderbottom: { elements: normalizeElements(bottomSource, 1, state) } + }; + return { config: config, warnings: warnings }; +} + +export function sanitizeNavigationUrl(value: string, siteCollectionUrl: string, allowMailto: boolean): string | undefined { + const raw: string = String(value || '').trim(); + if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) { + return undefined; + } + const resolved: string = raw.toLowerCase().indexOf('~sitecollection') === 0 + ? String(siteCollectionUrl || '').replace(/\/+$/, '') + raw.substring('~sitecollection'.length) + : raw; + const protocolMatch: RegExpMatchArray | null = resolved.match(/^([a-z][a-z0-9+.-]*):/i); + if (!protocolMatch) { + return resolved; + } + const protocol: string = protocolMatch[1].toLowerCase(); + return protocol === 'http' || protocol === 'https' || (allowMailto && protocol === 'mailto') + ? resolved + : undefined; +} + +export function sanitizeStylesheetUrl( + value: string, + siteCollectionUrl: string, + allowedCssHosts: string[] +): string | undefined { + const resolved: string = sanitizeNavigationUrl(value, siteCollectionUrl, false); + if (!resolved) { + return undefined; + } + const absolute: RegExpMatchArray | null = resolved.match(/^(https?):\/\/([^/]+)/i); + if (!absolute) { + return resolved; + } + const site: RegExpMatchArray | null = String(siteCollectionUrl || '').match(/^(https?):\/\/([^/]+)/i); + const protocol: string = absolute[1].toLowerCase(); + const host: string = absolute[2].toLowerCase(); + if (site && protocol === site[1].toLowerCase() && host === site[2].toLowerCase()) { + return resolved; + } + if (protocol !== 'https' || allowedCssHosts.indexOf(host) < 0) { + return undefined; + } + return resolved; +} + +export function sanitizeStyle(propertyName: string, value: string): string | undefined { + const property: string = String(propertyName || '').trim().toLowerCase(); + const styleValue: string = String(value || '').trim(); + if (AllowedStyles.indexOf(property) < 0 || !styleValue || styleValue.length > 512) { + return undefined; + } + if (/[\u0000-\u001f\u007f]/.test(styleValue) + || /(url\s*\(|expression\s*\(|javascript\s*:|@import|behavior\s*:|-moz-binding)/i.test(styleValue)) { + return undefined; + } + return styleValue; +} + +function createEmptyConfig(): ICustomBrandingConfig { + return { + schemaVersion: BrandingSchemaVersion, + enabled: true, + debug: false, + allowedCssHosts: [], + cssfiles: [], + placeholdertop: { elements: [] }, + placeholderbottom: { elements: [] } + }; +} + +function normalizeHosts(value: any): string[] { + if (!Array.isArray(value)) { + return []; + } + const result: string[] = []; + for (let i: number = 0; i < value.length; i++) { + const host: string = String(value[i] || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, ''); + if (/^[a-z0-9.-]+(?::\d+)?$/.test(host) && result.indexOf(host) < 0) { + result.push(host); + } + } + return result; +} + +function normalizeCssFiles( + value: any, + siteCollectionUrl: string, + allowedCssHosts: string[], + warnings: string[] +): ICssFile[] { + if (!Array.isArray(value)) { + return []; + } + const result: ICssFile[] = []; + const seen: string[] = []; + for (let i: number = 0; i < value.length && result.length < 20; i++) { + const item: any = value[i]; + const path: string = sanitizeStylesheetUrl(item && item.path, siteCollectionUrl, allowedCssHosts); + if (!path) { + warnings.push('Stylesheet at index ' + i + ' was rejected.'); + continue; + } + const normalizedKey: string = path.toLowerCase(); + if (seen.indexOf(normalizedKey) >= 0) { + continue; + } + seen.push(normalizedKey); + const media: string = item && typeof item.media === 'string' && /^[a-z0-9 (),.:/-]{1,80}$/i.test(item.media) + ? item.media.trim() + : 'all'; + result.push({ path: path, media: media }); + } + return result; +} + +function normalizeElements(value: any[], depth: number, state: INormalizationState): IBrandingElement[] { + const result: IBrandingElement[] = []; + if (!Array.isArray(value) || depth > MaxBrandingDepth) { + if (depth > MaxBrandingDepth) { + state.warnings.push('Maximum element depth exceeded.'); + } + return result; + } + for (let i: number = 0; i < value.length && state.count < MaxBrandingElements; i++) { + const normalized: IBrandingElement = normalizeElement(value[i], depth, state); + if (normalized) { + result.push(normalized); + } + } + if (state.count >= MaxBrandingElements) { + state.warnings.push('Maximum element count reached.'); + } + return result; +} + +function normalizeElement(value: any, depth: number, state: INormalizationState): IBrandingElement | undefined { + if (!value || typeof value !== 'object') { + state.warnings.push('Invalid element was ignored.'); + return undefined; + } + const tag: string = String(value.type || '').trim().toLowerCase(); + if (AllowedTags.indexOf(tag) < 0) { + state.warnings.push('Element type "' + tag + '" was rejected.'); + return undefined; + } + state.count++; + const attributes: { [key: string]: string } = normalizeAttributes(tag, value.attributes, state); + if (tag === 'img' && attributes.alt === undefined) { + state.warnings.push('Image without alt attribute was rejected.'); + return undefined; + } + + const styles: { [key: string]: string } = {}; + if (value.styles && typeof value.styles === 'object') { + for (const styleName in value.styles) { + if (value.styles.hasOwnProperty(styleName)) { + const safeStyle: string = sanitizeStyle(styleName, value.styles[styleName]); + if (safeStyle) { + styles[styleName.toLowerCase()] = safeStyle; + } else { + state.warnings.push('Style "' + styleName + '" was rejected.'); + } + } + } + } + + const content: string = value.content === undefined || value.content === null + ? '' + : String(value.content).substring(0, 4000); + const children: IBrandingElement[] = tag === 'img' + ? [] + : normalizeElements(value.children, depth + 1, state); + if ((tag === 'a' || tag === 'button') && !content && children.length === 0 && !attributes['aria-label']) { + state.warnings.push('Empty interactive element was rejected.'); + return undefined; + } + + return { + type: tag as BrandingElementType, + content: content || undefined, + attributes: hasKeys(attributes) ? attributes : undefined, + styles: hasKeys(styles) ? styles : undefined, + children: children.length > 0 ? children : undefined + }; +} + +function normalizeAttributes(tag: string, value: any, state: INormalizationState): { [key: string]: string } { + const result: { [key: string]: string } = {}; + if (!value || typeof value !== 'object') { + if (tag === 'button') { result.type = 'button'; } + return result; + } + for (const rawName in value) { + if (!value.hasOwnProperty(rawName)) { continue; } + const name: string = String(rawName || '').trim().toLowerCase(); + const rawValue: string = String(value[rawName] === undefined ? '' : value[rawName]).substring(0, 2048); + if (name.indexOf('on') === 0 || !isAttributeAllowed(tag, name)) { + state.warnings.push('Attribute "' + name + '" was rejected.'); + continue; + } + if (name === 'href' || name === 'src') { + const safeUrl: string = sanitizeNavigationUrl(rawValue, state.siteCollectionUrl, name === 'href'); + if (safeUrl) { result[name] = safeUrl; } else { state.warnings.push('URL attribute was rejected.'); } + } else if (name === 'target') { + if (rawValue === '_blank' || rawValue === '_self') { result[name] = rawValue; } + } else if (name === 'id' || name === 'class') { + if (/^[a-z0-9 _-]{1,256}$/i.test(rawValue)) { result[name] = rawValue; } + } else if (name === 'width' || name === 'height') { + if (/^\d{1,4}$/.test(rawValue)) { result[name] = rawValue; } + } else if (name === 'aria-hidden') { + if (rawValue === 'true' || rawValue === 'false') { result[name] = rawValue; } + } else if (name === 'type' && tag === 'button') { + result.type = 'button'; + } else { + result[name] = rawValue; + } + } + if (tag === 'button') { result.type = 'button'; } + if (tag === 'a' && result.target === '_blank') { result.rel = 'noopener noreferrer'; } + return result; +} + +function isAttributeAllowed(tag: string, name: string): boolean { + if (GlobalAttributes.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 hasKeys(value: { [key: string]: string }): boolean { + for (const key in value) { + if (value.hasOwnProperty(key)) { return true; } + } + return false; +} + diff --git a/src/extensions/customBranding/BrandingCssLoader.ts b/src/extensions/customBranding/BrandingCssLoader.ts new file mode 100644 index 0000000..a1914a6 --- /dev/null +++ b/src/extensions/customBranding/BrandingCssLoader.ts @@ -0,0 +1,97 @@ +import { ICssFile } from './BrandingTypes'; + +interface ISharedCssEntry { + element: HTMLLinkElement; + references: number; + owned: boolean; +} + +export class BrandingCssLoader { + private static _registry: { [url: string]: ISharedCssEntry } = {}; + private _loadedKeys: string[] = []; + + constructor(private ownerId: string, private debugLog: (message: string, data?: any) => void) { } // tslint:disable-line:no-any + + public load(files: ICssFile[]): void { + for (let i: number = 0; i < files.length; i++) { + this.loadOne(files[i]); + } + } + + public dispose(): void { + for (let i: number = 0; i < this._loadedKeys.length; i++) { + const key: string = this._loadedKeys[i]; + const entry: ISharedCssEntry = BrandingCssLoader._registry[key]; + if (!entry) { continue; } + entry.references--; + if (entry.references <= 0) { + if (entry.owned && entry.element.parentNode) { + entry.element.parentNode.removeChild(entry.element); + } + delete BrandingCssLoader._registry[key]; + } + } + this._loadedKeys = []; + } + + private loadOne(file: ICssFile): void { + const absoluteUrl: string = this.toAbsoluteUrl(file.path); + const key: string = absoluteUrl.toLowerCase(); + if (this._loadedKeys.indexOf(key) >= 0) { return; } + + let entry: ISharedCssEntry = BrandingCssLoader._registry[key]; + if (entry) { + entry.references++; + this._loadedKeys.push(key); + return; + } + + const existing: HTMLLinkElement = this.findExistingStylesheet(key); + if (existing) { + BrandingCssLoader._registry[key] = { element: existing, references: 1, owned: false }; + this._loadedKeys.push(key); + return; + } + + const link: HTMLLinkElement = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = absoluteUrl; + link.media = file.media || 'all'; + link.setAttribute('data-custom-branding-owner', this.ownerId); + link.setAttribute('data-custom-branding-url', key); + const timeout: number = window.setTimeout((): void => { + this.debugLog('Stylesheet load timed out.', { path: file.path }); + }, 10000); + link.onload = (): void => { + window.clearTimeout(timeout); + this.debugLog('Stylesheet loaded.', { path: file.path }); + }; + link.onerror = (): void => { + window.clearTimeout(timeout); + this.debugLog('Stylesheet failed to load.', { path: file.path }); + }; + document.head.appendChild(link); + + entry = { element: link, references: 1, owned: true }; + BrandingCssLoader._registry[key] = entry; + this._loadedKeys.push(key); + } + + private toAbsoluteUrl(path: string): string { + const anchor: HTMLAnchorElement = document.createElement('a'); + anchor.href = path; + return anchor.href; + } + + private findExistingStylesheet(key: string): HTMLLinkElement | undefined { + const links: NodeListOf = document.getElementsByTagName('link'); + for (let i: number = 0; i < links.length; i++) { + if (String(links[i].rel || '').toLowerCase() === 'stylesheet' + && String(links[i].href || '').toLowerCase() === key) { + return links[i]; + } + } + return undefined; + } +} diff --git a/src/extensions/customBranding/BrandingDomRenderer.ts b/src/extensions/customBranding/BrandingDomRenderer.ts new file mode 100644 index 0000000..6ac26c8 --- /dev/null +++ b/src/extensions/customBranding/BrandingDomRenderer.ts @@ -0,0 +1,44 @@ +import { IBrandingElement } from './BrandingTypes'; + +export class BrandingDomRenderer { + public render(container: HTMLElement, elements: IBrandingElement[]): void { + this.clear(container); + for (let i: number = 0; i < elements.length; i++) { + container.appendChild(this.createElement(elements[i])); + } + } + + public clear(container: HTMLElement): void { + while (container.firstChild) { + container.removeChild(container.firstChild); + } + } + + private createElement(config: IBrandingElement): HTMLElement { + const element: HTMLElement = document.createElement(config.type); + if (config.attributes) { + for (const name in config.attributes) { + if (config.attributes.hasOwnProperty(name)) { + element.setAttribute(name, config.attributes[name]); + } + } + } + if (config.styles) { + for (const propertyName in config.styles) { + if (config.styles.hasOwnProperty(propertyName)) { + element.style.setProperty(propertyName, config.styles[propertyName]); + } + } + } + if (config.content) { + element.appendChild(document.createTextNode(config.content)); + } + if (config.children) { + for (let i: number = 0; i < config.children.length; i++) { + element.appendChild(this.createElement(config.children[i])); + } + } + return element; + } +} + diff --git a/src/extensions/customBranding/BrandingTypes.ts b/src/extensions/customBranding/BrandingTypes.ts new file mode 100644 index 0000000..e02317c --- /dev/null +++ b/src/extensions/customBranding/BrandingTypes.ts @@ -0,0 +1,45 @@ +export type BrandingElementType = 'div' | 'span' | 'p' | 'a' | 'button' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em' | 'nav' | 'section'; + +export interface ICssFile { + path: string; + media?: string; +} + +export interface IBrandingElement { + type: BrandingElementType; + content?: string; + attributes?: { [key: string]: string }; + styles?: { [key: string]: string }; + children?: IBrandingElement[]; +} + +export interface IPlaceholderConfig { + elements: IBrandingElement[]; +} + +export interface ICustomBrandingConfig { + schemaVersion: number; + enabled: boolean; + debug: boolean; + allowedCssHosts: string[]; + cssfiles: ICssFile[]; + placeholdertop: IPlaceholderConfig; + placeholderbottom: IPlaceholderConfig; +} + +export interface ICustomBrandingApplicationCustomizerProperties { + schemaVersion?: number; + enabled?: boolean; + debug?: boolean; + allowedCssHosts?: string[]; + cssfiles?: ICssFile[]; + placeholdertop?: IPlaceholderConfig; + placeholderbottom?: IPlaceholderConfig; + elements?: IBrandingElement[]; +} + +export interface IBrandingNormalizationResult { + config: ICustomBrandingConfig; + warnings: string[]; +} + diff --git a/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts b/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts index 9ddb728..c005356 100644 --- a/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts +++ b/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts @@ -1,373 +1,175 @@ -import { override } from '@microsoft/decorators'; -// Legacy SPFx 1.4 implementation; incremental modernization is tracked separately. -// tslint:disable:max-line-length no-consecutive-blank-lines no-function-expression no-trailing-whitespace typedef - +// tslint:disable:no-any max-line-length +import { override } from '@microsoft/decorators'; import { Log } from '@microsoft/sp-core-library'; import { BaseApplicationCustomizer, PlaceholderContent, PlaceholderName } from '@microsoft/sp-application-base'; +import * as strings from 'CustomBrandingApplicationCustomizerStrings'; +import { BrandingCssLoader } from './BrandingCssLoader'; +import { normalizeBrandingConfig } from './BrandingConfig'; +import { BrandingDomRenderer } from './BrandingDomRenderer'; +import { + IBrandingNormalizationResult, + IBrandingElement, + ICustomBrandingApplicationCustomizerProperties, + ICustomBrandingConfig +} from './BrandingTypes'; const LOG_SOURCE: string = 'CustomBrandingApplicationCustomizer'; +const COMPONENT_ID: string = '035ba968-6488-4d42-86b3-0470ffcc95b9'; -/** - * CSS-Datei Definition - */ -export interface ICssFile { - path: string; -} - -/** - * Definition eines HTML-Elements - */ -export interface IBrandingElement { - type: 'div' | 'span' | 'p' | 'a' | 'button' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em'; - content?: string; - attributes?: { [key: string]: string }; - styles?: { [key: string]: string }; - children?: IBrandingElement[]; -} - -export interface IPlaceholderConfig { - elements?: IBrandingElement[]; -} - -/** - * Hauptkonfiguration für das Branding - */ -export interface IBrandingConfig { - cssfiles?: ICssFile[]; - placeholdertop?: IBrandingElement[]; - placeholderbottom?: IBrandingElement[]; -} - -/** - * Properties für den CustomBranding Application Customizer - */ -export interface ICustomBrandingApplicationCustomizerProperties { - /** - * Array von CSS-Dateien die geladen werden sollen - */ - cssfiles?: ICssFile[]; - - /** - * Array von HTML-Elementen für den Placeholder Top - */ - placeholdertop?: IPlaceholderConfig; - - /** - * Array von HTML-Elementen für den Placeholder Bottom - */ - placeholderbottom?: IPlaceholderConfig; -} - -/** - * CustomBranding Application Customizer - * Kompiliert JSON-Konfiguration zu HTML und fügt es in den Top Placeholder ein - * Lädt optional CSS-Dateien - */ export default class CustomBrandingApplicationCustomizer extends BaseApplicationCustomizer { private _topPlaceholder: PlaceholderContent | undefined; private _bottomPlaceholder: PlaceholderContent | undefined; - private _loadedCssFiles: string[] = []; + private _topHost: HTMLElement | undefined; + private _bottomHost: HTMLElement | undefined; + private _renderer: BrandingDomRenderer = new BrandingDomRenderer(); + private _cssLoader: BrandingCssLoader | undefined; + private _config: ICustomBrandingConfig | undefined; + private _isDisposed: boolean = false; @override public onInit(): Promise { - Log.info(LOG_SOURCE, 'Initialized CustomBrandingApplicationCustomizer'); + const result: IBrandingNormalizationResult = normalizeBrandingConfig( + this.properties, + this.context.pageContext.site.absoluteUrl + ); + this._config = result.config; + this.debug(strings.Initialized + ' 3.0.0.', { + schemaVersion: this._config.schemaVersion, + cssFileCount: this._config.cssfiles.length, + warningCount: result.warnings.length + }); + for (let i: number = 0; i < result.warnings.length; i++) { + this.debug('Configuration warning: ' + result.warnings[i]); + } - // CSS-Dateien laden - this._loadCssFiles(); - - // Auf Placeholder-Änderungen reagieren - this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceHolders); - - // Initial rendern - this._renderPlaceHolders(); + if (!this._config.enabled) { + this.debug('CustomBranding is disabled by configuration.'); + return Promise.resolve(); + } + this._cssLoader = new BrandingCssLoader(COMPONENT_ID, this.debug.bind(this)); + this._cssLoader.load(this._config.cssfiles); + this.context.placeholderProvider.changedEvent.add(this, this._renderPlaceholders); + this._renderPlaceholders(); return Promise.resolve(); } - /** - * Lädt CSS-Dateien aus der Konfiguration - */ - private _loadCssFiles(): void { - if (this.properties && this.properties.cssfiles && Array.isArray(this.properties.cssfiles)) { - console.log('CustomBranding: Loading CSS files...'); + private _renderPlaceholders(): void { + if (this._isDisposed || !this._config || !this._config.enabled) { return; } - for (let i = 0; i < this.properties.cssfiles.length; i++) { - const cssFile = this.properties.cssfiles[i]; - - if (cssFile && cssFile.path) { - this._injectCssFile(cssFile.path); - } - } - } - } - - /** - * Fügt eine CSS-Datei in den Head ein - */ - private _injectCssFile(cssPath: string): void { - // Prüfen ob die Datei bereits geladen wurde - for (let i = 0; i < this._loadedCssFiles.length; i++) { - if (this._loadedCssFiles[i] === cssPath) { - console.log('CustomBranding: CSS file already loaded: ' + cssPath); - return; - } - } - - try { - // Link-Element erstellen - const linkElement: HTMLLinkElement = document.createElement('link'); - linkElement.rel = 'stylesheet'; - linkElement.type = 'text/css'; - linkElement.href = cssPath; - linkElement.setAttribute('data-custom-branding', 'true'); - - // Event-Handler für erfolgreiches Laden - linkElement.onload = function () { - console.log('CustomBranding: CSS loaded successfully: ' + cssPath); - }; - - // Event-Handler für Fehler - linkElement.onerror = function () { - console.error('CustomBranding: Failed to load CSS: ' + cssPath); - }; - - // In Head einfügen - document.head.appendChild(linkElement); - - // Zur Liste hinzufügen - this._loadedCssFiles.push(cssPath); - - console.log('CustomBranding: CSS file injected: ' + cssPath); - } catch (error) { - console.error('CustomBranding: Error injecting CSS file: ' + cssPath, error); - } - } - - private _renderPlaceHolders(): void { - console.log('CustomBrandingApplicationCustomizer._renderPlaceHolders()'); - - // Prüfen ob Top Placeholder verfügbar ist - if (!this._topPlaceholder && !this._bottomPlaceholder) { + if (!this._topPlaceholder) { this._topPlaceholder = this.context.placeholderProvider.tryCreateContent( PlaceholderName.Top, - { onDispose: this._onDispose } + { onDispose: this._onTopPlaceholderDisposed } ); - + } + if (!this._bottomPlaceholder) { this._bottomPlaceholder = this.context.placeholderProvider.tryCreateContent( PlaceholderName.Bottom, - { onDispose: this._onDispose } + { onDispose: this._onBottomPlaceholderDisposed } ); + } - // Falls Placeholder nicht verfügbar, abbrechen - if (!this._topPlaceholder) { - console.error('CustomBranding: Top placeholder not found'); - return; - } - - // Falls Placeholder nicht verfügbar, abbrechen - if (!this._bottomPlaceholder) { - console.error('CustomBranding: Bottom placeholder not found'); - return; - } - - if (this._topPlaceholder.domElement && this._bottomPlaceholder.domElement) { - // Container erstellen mit hoher Priorität - this.renderPlaceHolder(this._topPlaceholder.domElement, this._bottomPlaceholder.domElement); - - console.log('CustomBranding: HTML injected successfully'); - } + if (this._topPlaceholder && this._topPlaceholder.domElement) { + this._topPlaceholder.domElement.id = 'CustomHeader'; + this._topHost = this.getOrCreateOwnedHost( + this._topPlaceholder.domElement, + 'CustomBrandingTopHost' + ); + this.renderSafely(this._topHost, this._config.placeholdertop.elements); + } + if (this._bottomPlaceholder && this._bottomPlaceholder.domElement) { + this._bottomPlaceholder.domElement.id = 'CustomFooter'; + this._bottomHost = this.getOrCreateOwnedHost( + this._bottomPlaceholder.domElement, + 'CustomBrandingBottomHost' + ); + this.renderSafely(this._bottomHost, this._config.placeholderbottom.elements); } } - private renderPlaceHolder(topcontainer: HTMLElement, bottomcontainer: HTMLElement) { - - if (!this.properties) { - console.log('CustomBranding: No properties provided'); - return; + private getOrCreateOwnedHost(parent: HTMLElement, id: string): HTMLElement { + let host: HTMLElement = parent.querySelector('#' + id) as HTMLElement; + if (!host) { + host = document.createElement('div'); + host.id = id; + host.setAttribute('data-custom-branding-owner', COMPONENT_ID); + parent.insertBefore(host, parent.firstChild); } + return host; + } + private renderSafely(host: HTMLElement, elements: IBrandingElement[]): void { try { - // Top-Konfiguration unverändert übernehmen - const topConfig: IPlaceholderConfig | undefined = - this.properties.placeholdertop; - - // Bottom-Konfiguration klonen oder initialisieren - const bottomConfig: IPlaceholderConfig = - this.properties.placeholderbottom - ? { ...this.properties.placeholderbottom } - : { elements: [] }; - - // Admin-Link nur für Site Collection Admins ergänzen - if (this._isSiteAdmin()) { - bottomConfig.elements = bottomConfig.elements || []; - bottomConfig.elements.push(this._getAdminFooterElement()); - } - - const config: IBrandingConfig = { - cssfiles: this.properties.cssfiles, - placeholdertop: topConfig.elements, - placeholderbottom: bottomConfig.elements - }; - - console.log('CustomBranding: Compiling JSON to HTML...'); - const compiled = this._compileToHtml(config); - - topcontainer.id = 'CustomHeader'; - topcontainer.innerHTML = compiled.top; - - bottomcontainer.id = 'CustomFooter'; - bottomcontainer.innerHTML = compiled.bottom; - + this._renderer.render(host, elements); } catch (error) { - console.error('CustomBranding: Error compiling configuration', error); + this._renderer.clear(host); + const status: HTMLElement = document.createElement('div'); + status.className = 'custom-branding-status'; + status.setAttribute('role', 'status'); + status.textContent = strings.RenderError; + host.appendChild(status); + this.debug('Rendering failed.', error); } } - - /** - * Kompiliert die JSON-Konfiguration zu HTML - */ - private _compileToHtml(config: IBrandingConfig): { top: string; bottom: string } { - // Compile Top and Bottom separately - let topHtml: string = ''; - let bottomHtml: string = ''; - if (config.placeholdertop && Array.isArray(config.placeholdertop)) { - for (let i = 0; i < config.placeholdertop.length; i++) { topHtml += this._createElement(config.placeholdertop[i]); } - } - if (config.placeholderbottom && Array.isArray(config.placeholderbottom)) { - for (let i = 0; i < config.placeholderbottom.length; i++) { bottomHtml += this._createElement(config.placeholderbottom[i]); } - } - return { top: topHtml, bottom: bottomHtml }; + private _onTopPlaceholderDisposed = (): void => { + this.removeHost(this._topHost); + this._topHost = undefined; + this._topPlaceholder = undefined; + this.debug('Top placeholder disposed; waiting for a new placeholder.'); } - /** - * Erstellt HTML für ein einzelnes Element - */ - private _createElement(element: IBrandingElement): string { - const tag = element.type || 'div'; - let html = '<' + tag; - - // Attribute hinzufügen - if (element.attributes) { - for (const key in element.attributes) { - if (element.attributes.hasOwnProperty(key)) { - const value = element.attributes[key]; - html += ' ' + key + '="' + this._escapeHtml(value) + '"'; - } - } - } - - // Styles hinzufügen - if (element.styles) { - const styleArray: string[] = []; - for (const key in element.styles) { - if (element.styles.hasOwnProperty(key)) { - const value = element.styles[key]; - styleArray.push(key + ':' + value); - } - } - if (styleArray.length > 0) { - const styleString = styleArray.join(';'); - html += ' style="' + styleString + '"'; - } - } - - html += '>'; - - // Content hinzufügen - if (element.content) { - html += this._escapeHtml(element.content); - } - - // Kinder hinzufügen - if (element.children && Array.isArray(element.children)) { - for (let i = 0; i < element.children.length; i++) { - html += this._createElement(element.children[i]); - } - } - - // Self-closing Tags behandeln - const selfClosingTags = ['img', 'br', 'hr', 'input']; - let isSelfClosing = false; - for (let i = 0; i < selfClosingTags.length; i++) { - if (selfClosingTags[i] === tag) { - isSelfClosing = true; - break; - } - } - - if (!isSelfClosing) { - html += ''; - } - - return html; + private _onBottomPlaceholderDisposed = (): void => { + this.removeHost(this._bottomHost); + this._bottomHost = undefined; + this._bottomPlaceholder = undefined; + this.debug('Bottom placeholder disposed; waiting for a new placeholder.'); } - private _isSiteAdmin(): boolean { - return this.context.pageContext.legacyPageContext.isSiteAdmin === true; + @override + protected onDispose(): void { + if (this._isDisposed) { return; } + this._isDisposed = true; + this.context.placeholderProvider.changedEvent.remove(this, this._renderPlaceholders); + this.removeHost(this._topHost); + this.removeHost(this._bottomHost); + if (this._cssLoader) { + this._cssLoader.dispose(); + } + this._topHost = undefined; + this._bottomHost = undefined; + this._topPlaceholder = undefined; + this._bottomPlaceholder = undefined; + this._cssLoader = undefined; + this.debug('CustomBranding disposed.'); + this._config = undefined; } - private _getAdminFooterElement(): IBrandingElement { - const siteUrl = this.context.pageContext.site.absoluteUrl; - - return { - type: 'div', - styles: { - 'text-align': 'right', - 'padding': '8px 16px', - 'border-top': '1px solid #e1e1e1', - 'background-color': '#f8f8f8', - 'font-size': '13px' - }, - children: [ - { - type: 'a', - content: 'Einstellungen', - attributes: { - href: `${siteUrl}/SitePages/PortalSettings.aspx` - }, - styles: { - 'text-decoration': 'none', - 'font-weight': '600' - } - } - ] - }; + private removeHost(host: HTMLElement | undefined): void { + if (host && host.parentNode) { + host.parentNode.removeChild(host); + } } - /** - * Escaped HTML-Zeichen für Sicherheit - */ - private _escapeHtml(text: string): string { - const map: { [key: string]: string } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - - return text.replace(/[&<>"']/g, function (m) { - return map[m]; - }); - } - - private _onDispose(): void { - console.log('CustomBrandingApplicationCustomizer._onDispose()'); - - // CSS-Dateien beim Dispose entfernen - const cssLinks = document.querySelectorAll('link[data-custom-branding="true"]'); - for (let i = 0; i < cssLinks.length; i++) { - const link = cssLinks[i]; - if (link.parentNode) { - link.parentNode.removeChild(link); - } + private debug(message: string, data?: any): void { + if (!this._config || !this._config.debug) { return; } + Log.info(LOG_SOURCE, message); + if (window.console && window.console.log) { + console.log('[CustomBranding] ' + message, data || ''); } } } + +export { + IBrandingElement, + ICssFile, + ICustomBrandingApplicationCustomizerProperties, + IPlaceholderConfig +} from './BrandingTypes'; diff --git a/src/extensions/customBranding/loc/de-de.js b/src/extensions/customBranding/loc/de-de.js new file mode 100644 index 0000000..2c5d985 --- /dev/null +++ b/src/extensions/customBranding/loc/de-de.js @@ -0,0 +1,7 @@ +define([], function() { + return { + "Title": "CustomBrandingApplicationCustomizer", + "Initialized": "CustomBranding wurde initialisiert", + "RenderError": "Das konfigurierte Branding konnte nicht dargestellt werden." + }; +}); diff --git a/src/extensions/customBranding/loc/en-us.js b/src/extensions/customBranding/loc/en-us.js index 23031a1..de1e4ac 100644 --- a/src/extensions/customBranding/loc/en-us.js +++ b/src/extensions/customBranding/loc/en-us.js @@ -1,5 +1,7 @@ define([], function() { return { - "Title": "CustomBrandingApplicationCustomizer" + "Title": "CustomBrandingApplicationCustomizer", + "Initialized": "CustomBranding initialized", + "RenderError": "The configured branding could not be rendered." } -}); \ No newline at end of file +}); diff --git a/src/extensions/customBranding/loc/myStrings.d.ts b/src/extensions/customBranding/loc/myStrings.d.ts index 2083380..316d002 100644 --- a/src/extensions/customBranding/loc/myStrings.d.ts +++ b/src/extensions/customBranding/loc/myStrings.d.ts @@ -1,5 +1,7 @@ declare interface ICustomBrandingApplicationCustomizerStrings { Title: string; + Initialized: string; + RenderError: string; } declare module 'CustomBrandingApplicationCustomizerStrings' { diff --git a/src/index.ts b/src/index.ts index fb81db1..bd886b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,3 @@ -// A file is required to be in the root of the /src directory by the TypeScript compiler +export * from './extensions/customBranding/BrandingTypes'; +export * from './extensions/customBranding/BrandingConfig'; +export * from './extensions/customBranding/BrandingDomRenderer'; diff --git a/tests/BrandingConfig.test.js b/tests/BrandingConfig.test.js new file mode 100644 index 0000000..591d816 --- /dev/null +++ b/tests/BrandingConfig.test.js @@ -0,0 +1,85 @@ +'use strict'; + +var configModule = require('../temp/tests/BrandingConfig'); +var normalize = configModule.normalizeBrandingConfig; +var siteUrl = 'http://sharepoint/sites/portal'; +var passed = 0; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } + passed++; +} + +function element(raw) { + return normalize({ placeholdertop: { elements: [raw] } }, siteUrl).config.placeholdertop.elements[0]; +} + +var empty = normalize({}, siteUrl); +assert(empty.config.schemaVersion === 2, 'Das aktuelle Schema muss Version 2 verwenden.'); +assert(empty.config.enabled === true, 'Branding muss standardmaessig aktiviert sein.'); + +var legacy = normalize({ elements: [{ type: 'span', content: 'Alt' }] }, siteUrl); +assert(legacy.config.placeholdertop.elements.length === 1, 'Das Legacy-Root-Array muss weiter funktionieren.'); + +['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'] + .forEach(function (tag) { + var attributes = tag === 'img' ? { alt: '' } : undefined; + var content = tag === 'a' || tag === 'button' ? tag : undefined; + assert(!!element({ type: tag, content: content, attributes: attributes }), 'Erlaubter Tag fehlt: ' + tag); + }); + +assert(!element({ type: 'script', content: 'alert(1)' }), 'script darf nicht gerendert werden.'); +assert(!element({ type: 'iframe' }), 'iframe darf nicht gerendert werden.'); + +var safeLink = element({ + type: 'a', + content: 'Portal', + attributes: { href: '~sitecollection/SitePages/Home.aspx', target: '_blank', onclick: 'alert(1)' } +}); +assert(safeLink.attributes.href === siteUrl + '/SitePages/Home.aspx', '~sitecollection wurde nicht aufgeloest.'); +assert(safeLink.attributes.rel === 'noopener noreferrer', 'Externe Fenster brauchen noopener/noreferrer.'); +assert(safeLink.attributes.onclick === undefined, 'Event-Attribute duerfen nicht uebernommen werden.'); + +var unsafeLink = element({ type: 'a', content: 'Unsicher', attributes: { href: 'javascript:alert(1)' } }); +assert(unsafeLink.attributes === undefined || unsafeLink.attributes.href === undefined, 'javascript: muss blockiert werden.'); + +var styled = element({ + type: 'div', + styles: { color: '#fff', position: 'fixed', background: 'url(javascript:alert(1))' } +}); +assert(styled.styles.color === '#fff', 'Erlaubte Styles muessen erhalten bleiben.'); +assert(styled.styles.position === undefined, 'Nicht freigegebene Styles muessen entfernt werden.'); +assert(styled.styles.background === undefined, 'CSS url() muss blockiert werden.'); + +assert(!element({ type: 'img', attributes: { src: '/logo.png' } }), 'Bilder ohne alt muessen verworfen werden.'); +assert(!!element({ type: 'img', attributes: { src: '/logo.png', alt: '' } }), 'Dekorative Bilder mit leerem alt sind erlaubt.'); +assert(!element({ type: 'button' }), 'Leere Buttons muessen verworfen werden.'); + +var css = normalize({ + allowedCssHosts: ['cdn.example.org'], + cssfiles: [ + { path: '/SiteAssets/site.css' }, + { path: 'https://cdn.example.org/portal.css', media: 'screen' }, + { path: 'https://evil.example.org/evil.css' }, + { path: 'http://cdn.example.org/mixed.css' }, + { path: '/SiteAssets/site.css' } + ] +}, siteUrl).config.cssfiles; +assert(css.length === 2, 'CSS-Allowlist oder Deduplizierung ist fehlerhaft.'); +assert(css[0].media === 'all' && css[1].media === 'screen', 'CSS-Media wurde nicht normalisiert.'); + +var deep = { type: 'div' }; +var cursor = deep; +for (var depth = 0; depth < 12; depth++) { + cursor.children = [{ type: 'div' }]; + cursor = cursor.children[0]; +} +var deepResult = normalize({ placeholdertop: { elements: [deep] } }, siteUrl); +assert(deepResult.warnings.some(function (warning) { return warning.indexOf('depth') >= 0; }), 'Das Tiefenlimit greift nicht.'); + +var huge = { placeholdertop: { elements: [] }, padding: new Array(100002).join('x') }; +assert(normalize(huge, siteUrl).config.placeholdertop.elements.length === 0, 'Zu grosse Konfiguration muss ignoriert werden.'); + +console.log('BrandingConfig: ' + passed + ' Pruefungen erfolgreich.'); diff --git a/tests/BrandingCssLoader.test.js b/tests/BrandingCssLoader.test.js new file mode 100644 index 0000000..411900a --- /dev/null +++ b/tests/BrandingCssLoader.test.js @@ -0,0 +1,56 @@ +'use strict'; + +var jsdom = require('jsdom'); +var document = jsdom.jsdom('', { + url: 'http://sharepoint/sites/portal/SitePages/Home.aspx' +}); +global.document = document; +global.window = document.defaultView; +var scheduled = []; +window.setTimeout = function (callback) { scheduled.push(callback); return scheduled.length; }; +window.clearTimeout = function () {}; + +var BrandingCssLoader = require('../temp/tests/BrandingCssLoader').BrandingCssLoader; +var passed = 0; +function assert(condition, message) { + if (!condition) { throw new Error(message); } + passed++; +} + +var first = new BrandingCssLoader('first', function () {}); +var second = new BrandingCssLoader('second', function () {}); +first.load([{ path: '/SiteAssets/a.css', media: 'screen' }, { path: '/SiteAssets/b.css', media: 'all' }]); +second.load([{ path: '/SiteAssets/a.css', media: 'screen' }]); + +var links = document.querySelectorAll('link[rel="stylesheet"]'); +assert(links.length === 2, 'Identische Stylesheets muessen instanzuebergreifend dedupliziert werden.'); +assert(links[0].href.indexOf('/SiteAssets/a.css') >= 0 && links[1].href.indexOf('/SiteAssets/b.css') >= 0, + 'Die konfigurierte CSS-Reihenfolge muss stabil bleiben.'); +first.dispose(); +assert(document.querySelectorAll('link[rel="stylesheet"]').length === 1, + 'Eine noch referenzierte Datei darf beim ersten Dispose nicht entfernt werden.'); +second.dispose(); +assert(document.querySelectorAll('link[rel="stylesheet"]').length === 0, + 'Eigene Stylesheets muessen nach der letzten Referenz entfernt werden.'); + +var existing = document.createElement('link'); +existing.rel = 'stylesheet'; +existing.href = '/SiteAssets/existing.css'; +document.head.appendChild(existing); +var third = new BrandingCssLoader('third', function () {}); +third.load([{ path: '/SiteAssets/existing.css', media: 'all' }]); +assert(document.querySelectorAll('link[rel="stylesheet"]').length === 1, 'Vorhandenes fremdes CSS darf nicht dupliziert werden.'); +third.dispose(); +assert(existing.parentNode === document.head, 'Fremdes CSS darf beim Dispose nicht entfernt werden.'); + +var messages = []; +var fourth = new BrandingCssLoader('fourth', function (message) { messages.push(message); }); +fourth.load([{ path: '/SiteAssets/error.css', media: 'all' }]); +var errorLink = document.querySelector('link[href$="error.css"]'); +errorLink.onerror(); +assert(messages.indexOf('Stylesheet failed to load.') >= 0, 'CSS-Ladefehler muss protokolliert werden.'); +scheduled[scheduled.length - 1](); +assert(messages.indexOf('Stylesheet load timed out.') >= 0, 'CSS-Timeout muss protokolliert werden.'); +fourth.dispose(); + +console.log('BrandingCssLoader: ' + passed + ' Pruefungen erfolgreich.'); diff --git a/tests/BrandingDomRenderer.test.js b/tests/BrandingDomRenderer.test.js new file mode 100644 index 0000000..daa45c8 --- /dev/null +++ b/tests/BrandingDomRenderer.test.js @@ -0,0 +1,59 @@ +'use strict'; + +var BrandingDomRenderer = require('../temp/tests/BrandingDomRenderer').BrandingDomRenderer; +var passed = 0; + +function assert(condition, message) { + if (!condition) { throw new Error(message); } + passed++; +} + +function FakeNode(name, text) { + this.nodeName = name; + this.text = text || ''; + this.children = []; + this.attributes = {}; + this.parentNode = null; + this.style = { + values: {}, + setProperty: function (key, value) { this.values[key] = value; } + }; +} +Object.defineProperty(FakeNode.prototype, 'firstChild', { + get: function () { return this.children.length ? this.children[0] : null; } +}); +FakeNode.prototype.appendChild = function (child) { + child.parentNode = this; + this.children.push(child); + return child; +}; +FakeNode.prototype.removeChild = function (child) { + this.children.splice(this.children.indexOf(child), 1); + child.parentNode = null; +}; +FakeNode.prototype.setAttribute = function (name, value) { this.attributes[name] = value; }; + +global.document = { + createElement: function (name) { return new FakeNode(name); }, + createTextNode: function (text) { return new FakeNode('#text', text); } +}; + +var host = new FakeNode('host'); +host.appendChild(new FakeNode('old')); +new BrandingDomRenderer().render(host, [{ + type: 'section', + attributes: { class: 'portal-header' }, + styles: { color: '#123456' }, + children: [{ type: 'strong', content: 'Inhalt' }] +}]); + +assert(host.children.length === 1, 'Vorhandener eigener Inhalt muss ersetzt werden.'); +assert(host.children[0].nodeName === 'section', 'Der DOM-Tag wurde nicht erzeugt.'); +assert(host.children[0].attributes.class === 'portal-header', 'Attribute fehlen.'); +assert(host.children[0].style.values.color === '#123456', 'Styles fehlen.'); +assert(host.children[0].children[0].children[0].text === 'Inhalt', 'Textknoten fehlt.'); + +new BrandingDomRenderer().clear(host); +assert(host.children.length === 0, 'clear muss alle eigenen Knoten entfernen.'); + +console.log('BrandingDomRenderer: ' + passed + ' Pruefungen erfolgreich.'); diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..74cd7bb --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "moduleResolution": "node", + "outDir": "../temp/tests", + "skipLibCheck": true, + "types": [], + "lib": ["es5", "dom", "es2015.collection"] + }, + "files": [ + "../src/extensions/customBranding/BrandingTypes.ts", + "../src/extensions/customBranding/BrandingConfig.ts", + "../src/extensions/customBranding/BrandingDomRenderer.ts", + "../src/extensions/customBranding/BrandingCssLoader.ts" + ] +} diff --git a/tests/validate-project.ps1 b/tests/validate-project.ps1 new file mode 100644 index 0000000..fa95bac --- /dev/null +++ b/tests/validate-project.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = 'Stop' + +$root = Split-Path -Parent $PSScriptRoot +$files = @( + (Join-Path $root 'deployment\add-custombranding.ps1'), + (Join-Path $root 'build-classic.ps1') +) + +foreach ($file in $files) { + $tokens = $null + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile($file, [ref]$tokens, [ref]$errors) + if ($errors.Count -gt 0) { + throw "PowerShell-Syntaxfehler in $file`: $($errors[0].Message)" + } +} + +$localizationFiles = Get-ChildItem (Join-Path $root 'src\extensions\customBranding\loc') -Filter '*.js' +if ($localizationFiles.Count -lt 2) { + throw 'Deutsche und englische Lokalisierungsdateien werden erwartet.' +} + +Write-Host "Projektvalidierung: $($files.Count) PowerShell- und $($localizationFiles.Count) Lokalisierungsdateien erfolgreich geprueft." diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js new file mode 100644 index 0000000..c1af3b0 --- /dev/null +++ b/tests/validate-static-assets.js @@ -0,0 +1,46 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var root = path.resolve(__dirname, '..'); +var passed = 0; + +function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); } +function json(file) { return JSON.parse(read(file)); } +function assert(condition, message) { + if (!condition) { throw new Error(message); } + passed++; +} + +['config/config.json', 'config/package-solution.json', 'config/serve.json', + 'src/extensions/customBranding/CustomBrandingApplicationCustomizer.manifest.json', + 'examples/custom-branding.example.json'] + .forEach(function (file) { json(file); passed++; }); + +var packageJson = json('package.json'); +var solution = json('config/package-solution.json').solution; +var serveText = read('config/serve.json').toLowerCase(); +var appSource = read('src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts'); +var rendererSource = read('src/extensions/customBranding/BrandingDomRenderer.ts'); +var classicSource = read('classic/custom-branding-classic.js'); + +assert(packageJson.version === '3.0.0', 'package.json hat nicht Version 3.0.0.'); +assert(solution.version === '3.0.0.0', 'Solution-Version ist inkonsistent.'); +assert(solution.skipFeatureDeployment === true, 'Tenantweite Bereitstellung ist nicht aktiviert.'); +assert(!solution.features, 'Die alte web-scoped Feature-Registrierung ist noch vorhanden.'); +assert(!fs.existsSync(path.join(root, 'sharepoint/assets/elements.xml')), 'elements.xml muss entfernt sein.'); +assert(serveText.indexOf('onclick') < 0, 'serve.json enthaelt ein Event-Attribut.'); +assert(serveText.indexOf('placeholdertop') >= 0, 'serve.json verwendet nicht das echte Schema.'); +assert(rendererSource.indexOf('innerHTML') < 0 && appSource.indexOf('innerHTML') < 0, 'Der moderne Renderer darf innerHTML nicht verwenden.'); +assert(classicSource.indexOf('innerHTML') < 0, 'Die Classic-Runtime darf innerHTML nicht verwenden.'); +assert(appSource.indexOf('changedEvent.remove') >= 0, 'Lifecycle-Cleanup fuer changedEvent fehlt.'); +assert(appSource.indexOf('_onTopPlaceholderDisposed') >= 0 && appSource.indexOf('_onBottomPlaceholderDisposed') >= 0, + 'Top- und Bottom-Placeholder brauchen unabhaengige Dispose-Handler.'); +assert(appSource.indexOf("'CustomBrandingTopHost'") >= 0 && appSource.indexOf("'CustomBrandingBottomHost'") >= 0, + 'Stabile Branding-Hosts fehlen.'); +assert(packageJson.scripts.package.indexOf('npm test') === 0, 'Paketierung muss mit Tests beginnen.'); +assert(!packageJson.dependencies['@microsoft/sp-dialog'], 'Nicht verwendete Dialog-Abhaengigkeit ist noch vorhanden.'); + +new Function(classicSource); +passed++; +console.log('Statische Assets: ' + passed + ' Pruefungen erfolgreich.');