From 586b9c0f1de968c4cdb347de39695b13c4c40b12 Mon Sep 17 00:00:00 2001 From: YuryShkoda Date: Mon, 17 Aug 2026 12:04:42 +0300 Subject: [PATCH] feat(licensing): support combined-key paste, object-format features, and a live key preview - Accept a single pasted/uploaded combined licence key (base64+gzip of licence+activation key), auto-detected in either paste mode, alongside the legacy two-field format - Warn and block applying a key generated for the wrong protocol before it ever reaches the backend - Decode both the legacy positional features string and a new named features object, so already-issued keys keep working unchanged - Show a live "Key Details" preview (validity, users, site IDs, enabled features) as a key is typed, pasted, or uploaded - Fix cypress test setup broken by the paste-format default change, and an unreliable FileReader-based base64 conversion in the test helpers --- client/cypress/e2e/licensing.cy.ts | 311 +++++++++++++++++- client/cypress/support/commands.ts | 6 + client/cypress/util/helper-functions.ts | 44 ++- .../app/licensing/licensing.component.html | 185 ++++++++--- .../app/licensing/licensing.component.scss | 7 + .../src/app/licensing/licensing.component.ts | 141 +++++++- .../utils/combinedLicenceKey.spec.ts | 90 +++++ .../app/licensing/utils/combinedLicenceKey.ts | 59 ++++ .../utils/getEnabledFeatureLabels.spec.ts | 73 ++++ .../utils/getEnabledFeatureLabels.ts | 24 ++ client/src/app/models/LicenceState.ts | 5 + client/src/app/models/LicenseKeyData.ts | 4 +- client/src/app/services/licence.service.ts | 70 +--- .../utils/decodeLicenceFeatures.spec.ts | 79 +++++ .../services/utils/decodeLicenceFeatures.ts | 91 +++++ client/src/styles.scss | 3 +- 16 files changed, 1041 insertions(+), 151 deletions(-) create mode 100644 client/src/app/licensing/utils/combinedLicenceKey.spec.ts create mode 100644 client/src/app/licensing/utils/combinedLicenceKey.ts create mode 100644 client/src/app/licensing/utils/getEnabledFeatureLabels.spec.ts create mode 100644 client/src/app/licensing/utils/getEnabledFeatureLabels.ts create mode 100644 client/src/app/services/utils/decodeLicenceFeatures.spec.ts create mode 100644 client/src/app/services/utils/decodeLicenceFeatures.ts diff --git a/client/cypress/e2e/licensing.cy.ts b/client/cypress/e2e/licensing.cy.ts index a98e736..ce36d66 100644 --- a/client/cypress/e2e/licensing.cy.ts +++ b/client/cypress/e2e/licensing.cy.ts @@ -249,6 +249,10 @@ context('licensing tests: ', function () { cy.get('button').contains('Paste licence').click() + // Defaults to the combined single-field form - switch to the legacy + // two-field layout, since this test targets that path specifically. + cy.get('button').contains('Paste as two separate keys instead').click() + // HTTP-format keys are generated as the exact same string for both // fields (see detectLicenceKeyProtocolMismatch's own doc comment) - // any non-empty matching pair triggers the warning, no real key needed. @@ -273,6 +277,229 @@ context('licensing tests: ', function () { }) }) + it('7 | Combined single-string key (default paste format) activates successfully', (done) => { + let keyData = { + valid_until: moment().add(1, 'year').format('YYYY-MM-DD'), + users_allowed: 4, + hot_license_key: '', + demo: false, + site_id: site_id + } + + generateKeys(keyData, (keysGen: any) => { + cy.wait(2000) + + isLicensingPage((result: boolean) => { + cy.log(`DEBUG2 isLicensingPage result=${result}`) + + if (result) { + generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( + (combinedKey) => { + cy.log( + `DEBUG2 generateCombinedKey resolved len=${combinedKey.length}` + ) + inputCombinedKeyPage(combinedKey) + + cy.wait(2000) + + acceptTermsIfPresented((termsResult: boolean) => { + if (termsResult) { + cy.wait(10000) + } + + visitPage('home') + + cy.get('.nav-tree clr-tree > clr-tree-node', { + timeout: longerCommandTimeout + }).then(() => { + done() + }) + }) + } + ) + } else { + // Fails fast with the actual URL rather than silently hanging + // for 30s until Mocha's own "done() was never invoked" timeout. + cy.url().then((url: string) => { + done( + new Error(`Expected to be on licensing page but url was: ${url}`) + ) + }) + } + }) + }) + }) + + it('8 | Pasting a combined key into the legacy licence-key field alone still auto-detects and activates', (done) => { + let keyData = { + valid_until: moment().add(1, 'year').format('YYYY-MM-DD'), + users_allowed: 4, + hot_license_key: '', + demo: false, + site_id: site_id + } + + generateKeys(keyData, (keysGen: any) => { + cy.wait(2000) + + isLicensingPage((result: boolean) => { + if (result) { + generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( + (combinedKey) => { + cy.get('button').contains('Paste licence').click() + cy.get('button') + .contains('Paste as two separate keys instead') + .click() + + // Only the licence-key field gets the combined string - the + // activation-key field is left empty, proving the split + // populates both fields from this one paste. + cy.get('.license-key-form textarea', { + timeout: longerCommandTimeout + }) + .invoke('val', combinedKey) + .trigger('input') + .trigger('mouseleave') + + cy.get('.activation-key-form textarea', { + timeout: longerCommandTimeout + }).should(($textarea) => { + expect($textarea.val()).to.equal(keysGen.activationKey) + }) + + cy.get('button.apply-keys').click() + + cy.wait(2000) + + acceptTermsIfPresented((termsResult: boolean) => { + if (termsResult) { + cy.wait(10000) + } + + visitPage('home') + + cy.get('.nav-tree clr-tree > clr-tree-node', { + timeout: longerCommandTimeout + }).then(() => { + done() + }) + }) + } + ) + } + }) + }) + }) + + it('9 | Uploading a single-line combined-format file activates successfully', (done) => { + let keyData = { + valid_until: moment().add(1, 'year').format('YYYY-MM-DD'), + users_allowed: 4, + hot_license_key: '', + demo: false, + site_id: site_id + } + + generateKeys(keyData, (keysGen: any) => { + cy.wait(2000) + + isLicensingPage((result: boolean) => { + if (result) { + generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( + (combinedKey) => { + cy.get('input[type="file"]').attachFile({ + fileContent: new Blob([combinedKey], { type: 'text/plain' }), + fileName: 'datacontroller-licence-combined.txt', + mimeType: 'text/plain' + }) + + cy.get('button.apply-keys', { + timeout: longerCommandTimeout + }).click() + + cy.wait(2000) + + acceptTermsIfPresented((termsResult: boolean) => { + if (termsResult) { + cy.wait(10000) + } + + visitPage('home') + + cy.get('.nav-tree clr-tree > clr-tree-node', { + timeout: longerCommandTimeout + }).then(() => { + done() + }) + }) + } + ) + } + }) + }) + }) + + it('10 | Key details preview shows for a validly-pasted key and disappears once the key input is cleared', (done) => { + let keyData = { + valid_until: moment().add(1, 'year').format('YYYY-MM-DD'), + users_allowed: 4, + hot_license_key: '', + demo: false, + site_id: site_id, + // Kept to just the two enabled toggles (relying on + // decodeLicenceFeatures' fallback for the rest) to keep the + // encrypted payload well under this suite's RSA-OAEP plaintext + // ceiling - see generateKeys' modulusLength below. + features: { vb: true, fu: true } + } + + generateKeys(keyData, (keysGen: any) => { + cy.wait(2000) + + isLicensingPage((result: boolean) => { + if (result) { + generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then( + (combinedKey) => { + cy.get('button').contains('Paste licence').click() + + cy.get('.combined-key-form textarea', { + timeout: longerCommandTimeout + }) + .invoke('val', combinedKey) + .trigger('input') + .trigger('mouseleave') + + cy.get('.key-details') + .should('contain', 'Valid until:') + .and('contain', 'Allowed users:') + .and('contain', '4') + .and('contain', 'Site ID(s) in this key:') + .and('contain', 'Enabled features:') + .and('contain', 'Viewbox') + .and('contain', 'File Upload') + .invoke('text') + .then((text) => { + expect(text).to.not.contain('Edit Record') + expect(text).to.not.contain('Add Record') + }) + + cy.get('.combined-key-form textarea') + .invoke('val', '') + .trigger('input') + .trigger('mouseleave') + + cy.get('.key-details') + .should('not.exist') + .then(() => { + done() + }) + } + ) + } + }) + }) + }) + if (testLicenceUserLimits) { it('4 | User try to register when limit is reached', (done) => { let keyData = { @@ -446,7 +673,7 @@ const verifyLicensingPage = (text: string, callback: any) => { cy.wait(1000) isLicensingPage((result: boolean) => { if (result) { - cy.get('p.key-error') + cy.get('.key-error') .should('contain', text) .then((treeNodes: any) => { callback(true) @@ -470,6 +697,10 @@ const verifyLicensingWarning = (text: string, callback: any) => { const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { cy.get('button').contains('Paste licence').click() + // Defaults to the combined single-field form - this helper exercises the + // two-part path specifically, so switch to it first. + cy.get('button').contains('Paste as two separate keys instead').click() + cy.get('.license-key-form textarea', { timeout: longerCommandTimeout }) .invoke('val', licenseKey) .trigger('input') @@ -481,6 +712,30 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { cy.get('button.apply-keys').click() } +const inputCombinedKeyPage = (combinedKey: string) => { + cy.get('button').contains('Paste licence').click() + + // Combined is the default paste format - no toggle click needed here, + // unlike inputLicenseKeyPage's legacy two-field path. + cy.get('.combined-key-form textarea', { timeout: longerCommandTimeout }) + .invoke('val', combinedKey) + .trigger('input') + .trigger('mouseleave') + .should('not.be.undefined') + + // mouseleave's handler (onCombinedKeyInput) does async work - gzip + // decompress the split, then decrypt for the key-details preview - + // before licenceKeyValue/activationKeyValue are ready to submit. + // Wait for that preview to land rather than racing "Apply licence + // keys" against the still-in-flight promise. + cy.get('.key-details', { timeout: longerCommandTimeout }).should( + 'contain', + 'Valid until:' + ) + + cy.get('button.apply-keys').click() +} + const updateUsersTable = (options: any, callback?: any) => { visitPage('home') openTableFromTree(libraryToOpenIncludes, 'mpe_users') @@ -625,6 +880,60 @@ const generateKeys = async (licenseData: any, resultCallback?: any) => { }) } +// Mirrors dckey's own encodeCombinedKey()/gzipCompress() (main.js) and +// DC's own splitCombinedLicenceKey() decode side, so this stays a genuine +// end-to-end check of the real format rather than a fixture the app +// happens to accept. +const COMBINED_KEY_PREFIX = 'DCKEY1:' + +const generateCombinedKey = async ( + licenseKey: string, + activationKey: string +): Promise => { + cy.log('DEBUG2 generateCombinedKey entered') + + const payloadBytes = new TextEncoder().encode( + `${licenseKey} ${activationKey}` + ) + + cy.log(`DEBUG2 CompressionStream available=${typeof CompressionStream}`) + + const compressionStream = new CompressionStream('gzip') + + cy.log('DEBUG2 CompressionStream constructed') + + const writer = compressionStream.writable.getWriter() + + cy.log('DEBUG2 writer acquired, calling write') + + const writePromise = writer + .write(payloadBytes) + .then(() => { + cy.log('DEBUG2 write() resolved, calling close') + return writer.close() + }) + .then(() => cy.log('DEBUG2 close() resolved')) + + const readPromise = new Response(compressionStream.readable) + .arrayBuffer() + .then((buf) => { + cy.log(`DEBUG2 response.arrayBuffer() resolved bytes=${buf.byteLength}`) + return buf + }) + + cy.log('DEBUG2 awaiting Promise.all') + + const [, compressedBuffer] = await Promise.all([writePromise, readPromise]) + + cy.log(`DEBUG2 compression done bytes=${compressedBuffer.byteLength}`) + + const compressedBase64 = await arrayBufferToBase64(compressedBuffer) + + cy.log(`DEBUG2 base64 done length=${compressedBase64.length}`) + + return COMBINED_KEY_PREFIX + compressedBase64 +} + const editTableField = (edits: EditConfigTableCells[], callback?: any) => { cy.get('td').then((tdNodes: any) => { for (let edit of edits) { diff --git a/client/cypress/support/commands.ts b/client/cypress/support/commands.ts index 66f5354..db2d459 100644 --- a/client/cypress/support/commands.ts +++ b/client/cypress/support/commands.ts @@ -183,6 +183,12 @@ const isLicensingPage = (callback: any) => { const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => { cy.get('button').contains('Paste licence').click() + + // Combined single-string paste is the default format now - switch to + // the legacy two-field layout, since this helper fills licenseKey and + // activationKey as two separate values. + cy.get('button').contains('Paste as two separate keys instead').click() + cy.get('.license-key-form textarea', { timeout: longerCommandTimeout }) .invoke('val', licenseKey) .trigger('input') diff --git a/client/cypress/util/helper-functions.ts b/client/cypress/util/helper-functions.ts index b68a9f9..800d4f5 100644 --- a/client/cypress/util/helper-functions.ts +++ b/client/cypress/util/helper-functions.ts @@ -1,32 +1,30 @@ +import * as base64Converter from 'base64-arraybuffer' + export const base64ToArrayBuffer = (base64: string) => { return new Promise(async (resolve, reject) => { - const dataUrl = "data:application/octet-binary;base64," + base64; - + const dataUrl = 'data:application/octet-binary;base64,' + base64 + fetch(dataUrl) - .then(res => res.arrayBuffer()) - .then(buffer => { + .then((res) => res.arrayBuffer()) + .then((buffer) => { resolve(new Uint8Array(buffer)) - }).catch((err) => { + }) + .catch((err) => { reject(err) }) }) } -export const arrayBufferToBase64 = (arrayBuffer: any) => { - return new Promise((resolve, reject) => { - const blob = new Blob([arrayBuffer]) - - const reader = new FileReader(); - - reader.onload = async function(event){ - if (event.target) { - var base64: any = event.target.result - base64 = base64.substring(37, base64.length) - - resolve(base64) - } - }; - - reader.readAsDataURL(blob); - }) -} \ No newline at end of file +// Blob + FileReader.readAsDataURL (a natural alternative here) is +// unreliable in the Cypress runner's iframe - onload/onerror can simply +// never fire, leaving the promise permanently unsettled (a silent hang, +// surfacing only as Mocha's "done() was never invoked" 30s later with no +// clue why). base64-arraybuffer's encode() is synchronous and +// dependency-free - no browser API involved, so this class of hang isn't +// possible - and it's the same library the app itself uses for this exact +// conversion. +export const arrayBufferToBase64 = ( + arrayBuffer: ArrayBuffer +): Promise => { + return Promise.resolve(base64Converter.encode(arrayBuffer)) +} diff --git a/client/src/app/licensing/licensing.component.html b/client/src/app/licensing/licensing.component.html index 1f0964b..c62117c 100644 --- a/client/src/app/licensing/licensing.component.html +++ b/client/src/app/licensing/licensing.component.html @@ -5,6 +5,11 @@

+ Licence key is invalid. We can't provide you more details at the moment

@@ -20,6 +25,11 @@

+ The registered number of users reached the limit specified for your licence. Please contact @@ -34,9 +44,9 @@

-

Protocol: {{ protocol }}

+

Protocol: {{ protocol }}

-

+

SYSSITE:

-

- Allowed users: - {{ licenseKeyData.users_allowed }} -

- @@ -102,56 +107,102 @@ -
-

Licence key:

-
- -
-
+ +
+

Licence key:

+
+ +
+
-
-

Activation key:

-
- -
-
+ +
+ + +
+

Licence key:

+
+ +
+
+ +
+

Activation key:

+
+ +
+
+ + +
-

- This key was generated for a secure (HTTPS) connection, but - DataController is currently running over HTTP - it will not activate - here. Access DataController via HTTPS, or contact - - for an HTTP-compatible key. -

+ +
+
Key Details
-

- This key was generated for an insecure (HTTP) connection, but this page - is running in a secure browsing context (HTTPS, or localhost) - it will - not activate here. Contact - - for an HTTPS-compatible key. -

+

+ Valid until: + {{ licenseKeyData.valid_until }} + (demo/free tier key) +

+ +

+ Allowed users: + {{ licenseKeyData.users_allowed }} +

+ +

+ Site ID(s) in this key: + {{ + (licenseKeyData.site_id_multiple?.length + ? licenseKeyData.site_id_multiple + : [licenseKeyData.site_id] + ).join(', ') + }} +

+ +

+ Enabled features: + {{ enabledFeatures.join(', ') }} +

+
+ +

+ + This key was generated for a secure (HTTPS) connection, but DataController + is currently running over HTTP - it will not activate here. Access + DataController via HTTPS, or contact + + for an HTTP-compatible key. +

+ +

+ + This key was generated for an insecure (HTTP) connection, but this page is + running in a secure browsing context (HTTPS, or localhost) - it will not + activate here. Contact + + for an HTTPS-compatible key. +

diff --git a/client/src/app/licensing/licensing.component.scss b/client/src/app/licensing/licensing.component.scss index e69de29..acdcce4 100644 --- a/client/src/app/licensing/licensing.component.scss +++ b/client/src/app/licensing/licensing.component.scss @@ -0,0 +1,7 @@ +.key-error clr-icon { + // clr-icon defaults to vertical-align: middle, which sits it noticeably + // above the text baseline next to it - align its bottom edge with the + // text's instead. + vertical-align: bottom; + margin-right: 10px; +} diff --git a/client/src/app/licensing/licensing.component.ts b/client/src/app/licensing/licensing.component.ts index 27b58cb..63b88e5 100644 --- a/client/src/app/licensing/licensing.component.ts +++ b/client/src/app/licensing/licensing.component.ts @@ -1,4 +1,5 @@ import { Component, OnInit, ViewEncapsulation } from '@angular/core' +import { DomSanitizer, SafeHtml } from '@angular/platform-browser' import { ActivatedRoute, Router } from '@angular/router' import { AppService, LicenceService, SasService } from '../services' import { LicenseKeyData } from '../models/LicenseKeyData' @@ -7,6 +8,11 @@ import { detectLicenceKeyProtocolMismatch, LicenceKeyProtocolMismatch } from './utils/detectLicenceKeyProtocolMismatch' +import { + isCombinedLicenceKey, + splitCombinedLicenceKey +} from './utils/combinedLicenceKey' +import { getEnabledFeatureLabels } from './utils/getEnabledFeatureLabels' enum LicenseActions { key = 'key', @@ -25,12 +31,19 @@ enum LicenseActions { export class LicensingComponent implements OnInit { public action: LicenseActions | null = null - public licenseErrors: { [key: string]: string } = { - missing: `Licence key is missing - please contact support@datacontroller.io and enter valid keys below.`, - expired: `Licence key is expired - please contact support@datacontroller.io and enter valid keys below.`, - invalid: `Licence key is invalid - please contact support@datacontroller.io and enter valid keys below.`, - missmatch: `Your SYSSITE (below) is not found in the licence key - please contact support@datacontroller.io and enter valid keys below.` - } + // Each message carries its own icon markup, rather than relying on a + // sibling element in the template - these render via [innerHTML], which + // replaces all of the host element's children, so a real + // placed in the template around the binding would never survive. + private readonly errorIcon = `` + + // SafeHtml, built via bypassSecurityTrustHtml in the constructor (needs + // DomSanitizer, not yet available in a field initializer) - Angular's + // default [innerHTML] sanitizer strips unrecognised tags like + // (a custom element, not on its standard-tags allowlist), even though it + // leaves the plain in these same strings alone. Safe to bypass since + // this content is entirely hardcoded here, never derived from user input. + public licenseErrors: { [key: string]: SafeHtml } public keyError: string | undefined public errorDetails: string | undefined @@ -38,6 +51,12 @@ export class LicensingComponent implements OnInit { public licenceKeyValue: string = '' public activationKeyValue: string = '' + // Purely which INPUT LAYOUT is shown (one field vs two) - not a strict + // parsing gate. Recognising a combined key doesn't depend on this: see + // maybeSplitCombinedKey(), called from both layouts' own input handlers. + public keyFormat: 'combined' | 'legacy' = 'combined' + public combinedKeyValue: string = '' + public applyingKeys: boolean = false public protocol: string = location.protocol === 'https:' @@ -48,7 +67,6 @@ export class LicensingComponent implements OnInit { public currentLicenceKey = this.licenceService.licenceKey public currentActivationKey = this.licenceService.activationKey public isAppFreeTier = this.licenceService.isAppFreeTier - public userCountLimitation = this.licenceService.userCountLimitation public licenseKeyData: LicenseKeyData | null = null @@ -64,8 +82,24 @@ export class LicensingComponent implements OnInit { private router: Router, private licenceService: LicenceService, private sasService: SasService, - private appService: AppService - ) {} + private appService: AppService, + private sanitizer: DomSanitizer + ) { + this.licenseErrors = { + missing: this.sanitizer.bypassSecurityTrustHtml( + `${this.errorIcon} Licence key is missing - please contact support@datacontroller.io and enter valid keys below.` + ), + expired: this.sanitizer.bypassSecurityTrustHtml( + `${this.errorIcon} Licence key is expired - please contact support@datacontroller.io and enter valid keys below.` + ), + invalid: this.sanitizer.bypassSecurityTrustHtml( + `${this.errorIcon} Licence key is invalid - please contact support@datacontroller.io and enter valid keys below.` + ), + missmatch: this.sanitizer.bypassSecurityTrustHtml( + `${this.errorIcon} Your SYSSITE (below) is not found in the licence key - please contact support@datacontroller.io and enter valid keys below.` + ) + } + } ngOnInit(): void { this.licenceKeyValue = this.currentLicenceKey || '' @@ -93,9 +127,73 @@ export class LicensingComponent implements OnInit { this.licenseKeyData = this.licenceService.getLicenseKeyData() } - public trimKeys() { + public async trimKeys() { this.licenceKeyValue = this.licenceKeyValue.trim() this.activationKeyValue = this.activationKeyValue.trim() + + // Auto-detect regardless of keyFormat - a combined key pasted into the + // legacy "Licence key" field on its own still gets recognised and + // splits into both fields, rather than requiring the toggle to match. + await this.maybeSplitCombinedKey(this.licenceKeyValue) + await this.refreshKeyPreview() + } + + public async onCombinedKeyInput() { + this.combinedKeyValue = this.combinedKeyValue.trim() + + // The combined field is the sole source of truth for + // licenceKeyValue/activationKeyValue in this layout - if it's been + // cleared (or isn't a recognisable combined key), there's no key data + // to preview, so clear rather than leave a stale split from an earlier + // paste (which would otherwise keep decrypting to that old key). + const wasSplit = await this.maybeSplitCombinedKey(this.combinedKeyValue) + if (!wasSplit) { + this.licenceKeyValue = '' + this.activationKeyValue = '' + } + + await this.refreshKeyPreview() + } + + // Decrypts whatever's currently in licenceKeyValue/activationKeyValue + // purely to preview its details (valid_until, users_allowed, ...) before + // the user ever clicks Apply - decryptLicenseKey() has no side effects, + // so this is safe to call speculatively on every input change. Clears + // rather than leaves stale data on failure/incomplete input, since + // showing a previous key's details next to a since-changed paste would + // be actively misleading, not just uninformative. + private async refreshKeyPreview(): Promise { + if (!this.licenceKeyValue || !this.activationKeyValue) { + this.licenseKeyData = null + return + } + + try { + this.licenseKeyData = await this.licenceService.decryptLicenseKey( + this.licenceKeyValue, + this.activationKeyValue + ) + } catch { + this.licenseKeyData = null + } + } + + public toggleKeyFormat() { + this.keyFormat = this.keyFormat === 'combined' ? 'legacy' : 'combined' + } + + // Shared by both input layouts' own handlers above - populates + // licenceKeyValue/activationKeyValue when value is recognisably a + // combined key, regardless of which field it was typed/pasted into. + private async maybeSplitCombinedKey(value: string): Promise { + if (!isCombinedLicenceKey(value)) return false + + const split = await splitCombinedLicenceKey(value) + if (!split) return false + + this.licenceKeyValue = split.licenceKey + this.activationKeyValue = split.activationKey + return true } public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) { @@ -154,7 +252,7 @@ export class LicensingComponent implements OnInit { const reader = new FileReader() - reader.onload = (evt) => { + reader.onload = async (evt) => { this.licenceFileError = 'Error reading file.' if (!evt || !evt.target) return @@ -164,9 +262,20 @@ export class LicensingComponent implements OnInit { this.licenceFileLoading = false this.licenceFileError = undefined - const fileArr = evt.target.result.toString().split('\n') - this.activationKeyValue = fileArr[1] - this.licenceKeyValue = fileArr[0] + + const fileArr = evt.target.result.toString().trim().split('\n') + + // A combined-key file is a single line - splits into both fields. + // Anything else keeps the existing 2-line legacy file behaviour. + if ( + fileArr.length !== 1 || + !(await this.maybeSplitCombinedKey(fileArr[0])) + ) { + this.activationKeyValue = fileArr[1] + this.licenceKeyValue = fileArr[0] + } + + await this.refreshKeyPreview() } reader.readAsText(file) @@ -205,4 +314,8 @@ export class LicensingComponent implements OnInit { !!(window.crypto && window.crypto.subtle) ) } + + get enabledFeatures(): string[] { + return getEnabledFeatureLabels(this.licenseKeyData?.features) + } } diff --git a/client/src/app/licensing/utils/combinedLicenceKey.spec.ts b/client/src/app/licensing/utils/combinedLicenceKey.spec.ts new file mode 100644 index 0000000..5e343ea --- /dev/null +++ b/client/src/app/licensing/utils/combinedLicenceKey.spec.ts @@ -0,0 +1,90 @@ +import * as base64Converter from 'base64-arraybuffer' +import { + COMBINED_KEY_PREFIX, + isCombinedLicenceKey, + splitCombinedLicenceKey +} from './combinedLicenceKey' + +// Mirrors dckey's own encodeCombinedKey() (main.js) exactly, so these tests +// build fixtures the same way a real generated key would be produced, +// rather than relying on a hardcoded string that could go stale if either +// side's algorithm ever changes. +const gzipCompress = async (bytes: Uint8Array): Promise => { + const compressionStream = new CompressionStream('gzip') + const writer = compressionStream.writable.getWriter() + writer.write(new Uint8Array(bytes)) + writer.close() + + return new Response(compressionStream.readable).arrayBuffer() +} + +const encodeCombinedLicenceKeyFixture = async ( + licenceKey: string, + activationKey: string +): Promise => { + const payloadBytes = new TextEncoder().encode( + `${licenceKey} ${activationKey}` + ) + const compressedBytes = await gzipCompress(payloadBytes) + + return COMBINED_KEY_PREFIX + base64Converter.encode(compressedBytes) +} + +describe('isCombinedLicenceKey', () => { + it('is true for prefixed text', () => { + expect(isCombinedLicenceKey('DCKEY1:abc123')).toBeTrue() + }) + + it('is false for a plain legacy key', () => { + expect(isCombinedLicenceKey('some-legacy-licence-key')).toBeFalse() + }) + + it('is false for an empty string', () => { + expect(isCombinedLicenceKey('')).toBeFalse() + }) + + it('is true even with leading/trailing whitespace around the prefix', () => { + expect(isCombinedLicenceKey(' DCKEY1:abc123 ')).toBeTrue() + }) +}) + +describe('splitCombinedLicenceKey', () => { + it('round-trips a fixture built the same way dckey encodes one', async () => { + const combined = await encodeCombinedLicenceKeyFixture( + 'licence-key-value', + 'activation-key-value' + ) + + expect(await splitCombinedLicenceKey(combined)).toEqual({ + licenceKey: 'licence-key-value', + activationKey: 'activation-key-value' + }) + }) + + it('returns null for text without the prefix', async () => { + expect( + await splitCombinedLicenceKey('a-plain-legacy-licence-key') + ).toBeNull() + }) + + it('rejects for prefixed text that is not valid compressed data', async () => { + await expectAsync( + splitCombinedLicenceKey('DCKEY1:not-valid-base64-gzip-data') + ).toBeRejected() + }) + + it('splits on the first space only, not every space', async () => { + // Not a real-world value today (licence/activation keys are base64, + // which never contains a space) - guards the split logic itself rather + // than that assumption. + const combined = await encodeCombinedLicenceKeyFixture( + 'licence key with spaces', + 'activation-key-value' + ) + + expect(await splitCombinedLicenceKey(combined)).toEqual({ + licenceKey: 'licence', + activationKey: 'key with spaces activation-key-value' + }) + }) +}) diff --git a/client/src/app/licensing/utils/combinedLicenceKey.ts b/client/src/app/licensing/utils/combinedLicenceKey.ts new file mode 100644 index 0000000..606ea07 --- /dev/null +++ b/client/src/app/licensing/utils/combinedLicenceKey.ts @@ -0,0 +1,59 @@ +import * as base64Converter from 'base64-arraybuffer' + +/** + * Format produced by dckey's own encodeCombinedKey() (main.js): + * "DCKEY1:" + base64(gzip(licenceKey + " " + activationKey)). The prefix is + * plain ASCII, not itself compressed/encoded - base64's alphabet never + * contains ":", so it can never collide with the start of a legacy licence + * key or activation key, making format detection unambiguous. + */ +export const COMBINED_KEY_PREFIX = 'DCKEY1:' + +export const isCombinedLicenceKey = (text: string): boolean => + text.trim().startsWith(COMBINED_KEY_PREFIX) + +const gzipDecompress = async (bytes: ArrayBuffer): Promise => { + const decompressionStream = new DecompressionStream('gzip') + const writer = decompressionStream.writable.getWriter() + + // Awaited (unlike a fire-and-forget write) so invalid gzip data rejects + // through this function's own returned promise, not as a separate + // unhandled rejection racing the readable side below. + const result = await Promise.all([ + writer.write(new Uint8Array(bytes)).then(() => writer.close()), + new Response(decompressionStream.readable).arrayBuffer() + ]) + + return result[1] +} + +/** + * Splits a combined licence key back into its two parts. Returns null (not + * a rejected promise) when text isn't a combined key at all, so callers can + * use it as a "try this, then fall back to the legacy two-field input" check + * without a try/catch for that common case - a prefixed-but-corrupted + * string still rejects, since that's a real error, not a format mismatch. + */ +export const splitCombinedLicenceKey = async ( + text: string +): Promise<{ licenceKey: string; activationKey: string } | null> => { + const trimmed = text.trim() + + if (!isCombinedLicenceKey(trimmed)) return null + + const compressedBytes = base64Converter.decode( + trimmed.slice(COMBINED_KEY_PREFIX.length) + ) + const decompressedBytes = await gzipDecompress(compressedBytes) + const payload = new TextDecoder().decode(decompressedBytes) + const separatorIndex = payload.indexOf(' ') + + if (separatorIndex === -1) { + throw new Error('Invalid combined licence key: missing separator') + } + + return { + licenceKey: payload.slice(0, separatorIndex), + activationKey: payload.slice(separatorIndex + 1) + } +} diff --git a/client/src/app/licensing/utils/getEnabledFeatureLabels.spec.ts b/client/src/app/licensing/utils/getEnabledFeatureLabels.spec.ts new file mode 100644 index 0000000..fce59a6 --- /dev/null +++ b/client/src/app/licensing/utils/getEnabledFeatureLabels.spec.ts @@ -0,0 +1,73 @@ +import { getEnabledFeatureLabels } from './getEnabledFeatureLabels' + +describe('getEnabledFeatureLabels', () => { + it('returns an empty list when features is undefined', () => { + expect(getEnabledFeatureLabels(undefined)).toEqual([]) + }) + + it('lists only the enabled toggle features from a new-format object', () => { + expect( + getEnabledFeatureLabels({ + vra: null, + era: null, + sra: null, + hra: null, + srl: null, + till: null, + vb: true, + vbl: null, + ldl: null, + fu: false, + er: true, + ar: false + }) + ).toEqual(['Viewbox', 'Edit Record']) + }) + + it('lists all four when every toggle is enabled', () => { + expect( + getEnabledFeatureLabels({ + vra: null, + era: null, + sra: null, + hra: null, + srl: null, + till: null, + vb: true, + vbl: null, + ldl: null, + fu: true, + er: true, + ar: true + }) + ).toEqual(['Viewbox', 'File Upload', 'Edit Record', 'Add Record']) + }) + + it('returns an empty list when every toggle is disabled', () => { + expect( + getEnabledFeatureLabels({ + vra: null, + era: null, + sra: null, + hra: null, + srl: null, + till: null, + vb: false, + vbl: null, + ldl: null, + fu: false, + er: false, + ar: false + }) + ).toEqual([]) + }) + + it('decodes a legacy positional string the same way', () => { + // viewbox=1 (position 6), fileUpload=0 (position 9), editRecord=1 + // (position 10), addRecord=0 (position 11) - see LicenceFeaturesMap. + expect(getEnabledFeatureLabels('-,-,-,-,-,-,1,-,-,0,1,0')).toEqual([ + 'Viewbox', + 'Edit Record' + ]) + }) +}) diff --git a/client/src/app/licensing/utils/getEnabledFeatureLabels.ts b/client/src/app/licensing/utils/getEnabledFeatureLabels.ts new file mode 100644 index 0000000..6e7e6b3 --- /dev/null +++ b/client/src/app/licensing/utils/getEnabledFeatureLabels.ts @@ -0,0 +1,24 @@ +import { + decodeLicenceFeatures, + LicenceFeaturesObject +} from '../../services/utils/decodeLicenceFeatures' +import { LicenceState } from '../../models/LicenceState' + +const TOGGLE_FEATURE_LABELS: { key: keyof LicenceState; label: string }[] = [ + { key: 'viewbox', label: 'Viewbox' }, + { key: 'fileUpload', label: 'File Upload' }, + { key: 'editRecord', label: 'Edit Record' }, + { key: 'addRecord', label: 'Add Record' } +] + +export const getEnabledFeatureLabels = ( + features: string | LicenceFeaturesObject | undefined +): string[] => { + if (!features) return [] + + const decoded = decodeLicenceFeatures(features) + + return TOGGLE_FEATURE_LABELS.filter(({ key }) => decoded[key]).map( + ({ label }) => label + ) +} diff --git a/client/src/app/models/LicenceState.ts b/client/src/app/models/LicenceState.ts index 1bdb348..aefecd7 100644 --- a/client/src/app/models/LicenceState.ts +++ b/client/src/app/models/LicenceState.ts @@ -15,6 +15,11 @@ export interface LicenceState { } /** + * Legacy-format decode table only - newly-issued keys use a named + * LicenceFeaturesObject instead (see decodeLicenceFeatures.ts). Kept + * unchanged and permanently, since every already-issued key still relies + * on these ordinal positions to decode. + * * '-' means unset * '0' disabled * '1' enabled diff --git a/client/src/app/models/LicenseKeyData.ts b/client/src/app/models/LicenseKeyData.ts index 13759eb..0bfbf3e 100644 --- a/client/src/app/models/LicenseKeyData.ts +++ b/client/src/app/models/LicenseKeyData.ts @@ -1,3 +1,5 @@ +import { LicenceFeaturesObject } from '../services/utils/decodeLicenceFeatures' + export interface LicenseKeyData { valid_until: string users_allowed: number @@ -5,5 +7,5 @@ export interface LicenseKeyData { site_id_multiple: string[] demo: boolean hot_license_key: string | undefined - features?: string + features?: string | LicenceFeaturesObject } diff --git a/client/src/app/services/licence.service.ts b/client/src/app/services/licence.service.ts index 5bfcd33..2c431af 100644 --- a/client/src/app/services/licence.service.ts +++ b/client/src/app/services/licence.service.ts @@ -10,7 +10,8 @@ import { Globvar } from '../models/sas/public-startupservice.model' import { freeTierConfig } from '../free-tier.config' import { AppStoreService } from './app-store.service' import { HelperService } from './helper.service' -import { LicenceFeaturesMap, LicenceState } from '../models/LicenceState' +import { LicenceState } from '../models/LicenceState' +import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures' import { LoggerService } from './logger.service' import { EventService } from './event.service' @@ -255,9 +256,9 @@ export class LicenceService { } /** - * Decode and set features that are encoded in the key - * featureValue - used as number/Infinity for limit set - * featureToggle - used as boolean, based on 1 or 0 encoded in the key + * Decode and set features that are encoded in the key - accepts either + * an already-issued key's legacy positional string or a newly-issued + * key's named object, see decodeLicenceFeatures for the format contract. * @param licenseData from the licence key */ private decodeLicenceFeatures(licenseData: LicenseKeyData) { @@ -270,72 +271,21 @@ export class LicenceService { } } - const featuresMap = licenseData.features.split(',') this._licenceState = { ...this._licenceState, - viewer_rows_allowed: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.viewer_rows_allowed] - ), - editor_rows_allowed: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.editor_rows_allowed] - ), - stage_rows_allowed: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.stage_rows_allowed] - ), - history_rows_allowed: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.history_rows_allowed] - ), - submit_rows_limit: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.submit_rows_limit] - ), - tables_in_library_limit: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.tables_in_library_limit] - ), - viewbox_limit: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.viewbox_limit] - ), - lineage_daily_limit: this.parseFeatureValue( - featuresMap[LicenceFeaturesMap.lineage_daily_limit] - ), - viewbox: this.parseFeatureToggle(featuresMap[LicenceFeaturesMap.viewbox]), - fileUpload: this.parseFeatureToggle( - featuresMap[LicenceFeaturesMap.fileUpload] - ), - editRecord: this.parseFeatureToggle( - featuresMap[LicenceFeaturesMap.editRecord] - ), - addRecord: this.parseFeatureToggle( - featuresMap[LicenceFeaturesMap.addRecord] - ) + ...decodeFeatures(licenseData.features) } this.loggerService.log('Licence state:', this._licenceState) } - /** - * Converts licence key feature code value to the number or Infinity - * Used for limiting rows, submits etc. - * @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 5,10,15 - * @returns number - */ - private parseFeatureValue(codeBit: string): number { - if (codeBit === '-') return Infinity - return parseInt(codeBit) - } - - /** - * Converts licence key feature code value to the boolean. Depending on if it's 0 or 1 - * @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 1,1,0 - * @returns boolean to turn on or off the value - */ - private parseFeatureToggle(codeBit: string): boolean { - return !!parseInt(codeBit) - } - /** * If decryption fails, key will be marked as invalid and app not activated. + * Public (not just used internally by licensing()) since it's a pure + * decrypt with no side effects - also used to preview a pasted key's + * details before it's actually applied. */ - private decryptLicenseKey( + public decryptLicenseKey( licenseKey: string, activationKey: string ): Promise { diff --git a/client/src/app/services/utils/decodeLicenceFeatures.spec.ts b/client/src/app/services/utils/decodeLicenceFeatures.spec.ts new file mode 100644 index 0000000..d3a023c --- /dev/null +++ b/client/src/app/services/utils/decodeLicenceFeatures.spec.ts @@ -0,0 +1,79 @@ +import { decodeLicenceFeatures } from './decodeLicenceFeatures' + +describe('decodeLicenceFeatures', () => { + // Positional order per LicenceFeaturesMap: viewer_rows_allowed, + // editor_rows_allowed, stage_rows_allowed, history_rows_allowed, + // submit_rows_limit, tables_in_library_limit, viewbox, viewbox_limit, + // lineage_daily_limit, fileUpload, editRecord, addRecord. + const legacyFeaturesString = '10,15,20,-,5,35,1,3,7,1,0,1' + + const newFeaturesObject = { + vra: 10, + era: 15, + sra: 20, + hra: null, + srl: 5, + till: 35, + vb: true, + vbl: 3, + ldl: 7, + fu: true, + er: false, + ar: true + } + + const expectedDecodedState = { + viewer_rows_allowed: 10, + editor_rows_allowed: 15, + stage_rows_allowed: 20, + history_rows_allowed: Infinity, + submit_rows_limit: 5, + tables_in_library_limit: 35, + viewbox: true, + viewbox_limit: 3, + lineage_daily_limit: 7, + fileUpload: true, + editRecord: false, + addRecord: true + } + + it('decodes a legacy positional string exactly as before', () => { + expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual( + expectedDecodedState + ) + }) + + it('decodes a new named object', () => { + expect(decodeLicenceFeatures(newFeaturesObject)).toEqual( + expectedDecodedState + ) + }) + + it('produces an identical result for the same licence expressed in either format', () => { + expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual( + decodeLicenceFeatures(newFeaturesObject) + ) + }) + + it('maps "-" in a legacy limit position to Infinity', () => { + const result = decodeLicenceFeatures(legacyFeaturesString) + expect(result.history_rows_allowed).toBe(Infinity) + }) + + it('maps null in a new-format limit field to Infinity', () => { + const result = decodeLicenceFeatures(newFeaturesObject) + expect(result.history_rows_allowed).toBe(Infinity) + }) + + it('maps "1"/"0" in legacy toggle positions to true/false', () => { + const result = decodeLicenceFeatures(legacyFeaturesString) + expect(result.viewbox).toBe(true) + expect(result.editRecord).toBe(false) + }) + + it('passes new-format boolean toggle fields straight through', () => { + const result = decodeLicenceFeatures(newFeaturesObject) + expect(result.viewbox).toBe(true) + expect(result.editRecord).toBe(false) + }) +}) diff --git a/client/src/app/services/utils/decodeLicenceFeatures.ts b/client/src/app/services/utils/decodeLicenceFeatures.ts new file mode 100644 index 0000000..3709eba --- /dev/null +++ b/client/src/app/services/utils/decodeLicenceFeatures.ts @@ -0,0 +1,91 @@ +import { LicenceFeaturesMap, LicenceState } from '../../models/LicenceState' + +// Field names are abbreviated on the wire to keep the RSA-OAEP-encrypted +// payload small - see "Why abbreviated field names" in +// licence-features-object-plan.md for the full name each one stands for +// and why this is a fixed, append-only contract (same invariant as +// LicenceFeaturesMap's ordinals). +export type LicenceFeaturesObject = { + vra: number | null // viewer_rows_allowed + era: number | null // editor_rows_allowed + sra: number | null // stage_rows_allowed + hra: number | null // history_rows_allowed + srl: number | null // submit_rows_limit + till: number | null // tables_in_library_limit + vb: boolean // viewbox + vbl: number | null // viewbox_limit + ldl: number | null // lineage_daily_limit + fu: boolean // fileUpload + er: boolean // editRecord + ar: boolean // addRecord +} + +// Accepts either an already-issued key's positional string +// ("-,-,-,-,-,-,1,-,-,1,1,1", decoded via LicenceFeaturesMap) or a +// newly-issued key's named object - see licence-features-object-plan.md +// for why these two shapes are how format versioning is done here (no +// separate version field: the shapes are already distinguishable). +export const decodeLicenceFeatures = ( + features: string | LicenceFeaturesObject +): Partial => { + if (typeof features === 'object') { + return decodeFeaturesObject(features) + } + + return decodeFeaturesString(features) +} + +const decodeFeaturesObject = ( + features: LicenceFeaturesObject +): Partial => ({ + viewer_rows_allowed: features.vra ?? Infinity, + editor_rows_allowed: features.era ?? Infinity, + stage_rows_allowed: features.sra ?? Infinity, + history_rows_allowed: features.hra ?? Infinity, + submit_rows_limit: features.srl ?? Infinity, + tables_in_library_limit: features.till ?? Infinity, + viewbox: features.vb, + viewbox_limit: features.vbl ?? Infinity, + lineage_daily_limit: features.ldl ?? Infinity, + fileUpload: features.fu, + editRecord: features.er, + addRecord: features.ar +}) + +// Existing positional-string decode, moved here unchanged from +// LicenceService.decodeLicenceFeatures/parseFeatureValue/parseFeatureToggle. +const decodeFeaturesString = (features: string): Partial => { + const featuresMap = features.split(',') + const parseValue = (codeBit: string) => + codeBit === '-' ? Infinity : parseInt(codeBit) + const parseToggle = (codeBit: string) => !!parseInt(codeBit) + + return { + viewer_rows_allowed: parseValue( + featuresMap[LicenceFeaturesMap.viewer_rows_allowed] + ), + editor_rows_allowed: parseValue( + featuresMap[LicenceFeaturesMap.editor_rows_allowed] + ), + stage_rows_allowed: parseValue( + featuresMap[LicenceFeaturesMap.stage_rows_allowed] + ), + history_rows_allowed: parseValue( + featuresMap[LicenceFeaturesMap.history_rows_allowed] + ), + submit_rows_limit: parseValue( + featuresMap[LicenceFeaturesMap.submit_rows_limit] + ), + tables_in_library_limit: parseValue( + featuresMap[LicenceFeaturesMap.tables_in_library_limit] + ), + viewbox_limit: parseValue(featuresMap[LicenceFeaturesMap.viewbox_limit]), + lineage_daily_limit: parseValue( + featuresMap[LicenceFeaturesMap.lineage_daily_limit] + ), + viewbox: parseToggle(featuresMap[LicenceFeaturesMap.viewbox]), + fileUpload: parseToggle(featuresMap[LicenceFeaturesMap.fileUpload]), + editRecord: parseToggle(featuresMap[LicenceFeaturesMap.editRecord]), + addRecord: parseToggle(featuresMap[LicenceFeaturesMap.addRecord]) + } +} diff --git a/client/src/styles.scss b/client/src/styles.scss index b2654f5..cbe6048 100644 --- a/client/src/styles.scss +++ b/client/src/styles.scss @@ -2544,7 +2544,8 @@ app-licensing { } .license-key-form, - .activation-key-form { + .activation-key-form, + .combined-key-form { padding: 0; .clr-control-container {