Files
dc/client/src/app/services/licence.service.ts
T
dc ad2da45389
Build / Build-and-ng-test (pull_request) Successful in 5m53s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m10s
Build / Build-and-test-development (pull_request) Successful in 31m25s
fix: offer the launcher on the globvars failure modal too
The globvars check in licence.service reports a failed startupservice run as
well, so its modal needs the service name for the new-window button - that was
the fourth startup failure path and the only one still a dead end.

Also guard the decodeURIComponent call in the Cypress drive/folder intercept,
so a lone '%' in a URL cannot throw inside the handler and abort the request.
2026-10-23 02:17:49 +00:00

612 lines
18 KiB
TypeScript

import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { LicenseKeyData } from '../models/LicenseKeyData'
import { SasService } from './sas.service'
import moment from 'moment'
import * as base64Converter from 'base64-arraybuffer'
import * as encoding from 'text-encoding'
import { Router } from '@angular/router'
import { Globvar } from '../models/sas/public-startupservice.model'
import { freeTierConfig } from '../free-tier.config'
import { HelperService } from './helper.service'
import { LicenceState } from '../models/LicenceState'
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
import { LoggerService } from './logger.service'
import { EventService } from './event.service'
/**
* Handsontable licence key used until a licence is loaded from the backend.
* The decrypted licence carries the customer's own key (`hot_license_key`), so
* this is only the pre-licence / free-tier default - it used to be supplied by
* the `<sasjs>` tag.
*/
export const DEFAULT_HOT_LICENCE_KEY = 'non-commercial-and-evaluation'
@Injectable({
providedIn: 'root'
})
export class LicenceService {
public userCountLimitation: boolean = false //toggler for user count limit registration
public licenceKey: string | undefined
public activationKey: string | undefined
private licenseKeyData: LicenseKeyData | null = null
private _licenceState: LicenceState = freeTierConfig
private allFeaturesOn: LicenceState = {
viewer_rows_allowed: Infinity,
editor_rows_allowed: Infinity,
stage_rows_allowed: Infinity,
history_rows_allowed: Infinity,
submit_rows_limit: Infinity,
tables_in_library_limit: Infinity,
viewbox_limit: Infinity,
lineage_daily_limit: Infinity,
viewbox: true,
fileUpload: true,
editRecord: true,
addRecord: true
}
private freeTierLicenceData: LicenseKeyData = {
demo: true,
hot_license_key: DEFAULT_HOT_LICENCE_KEY,
users_allowed: this._licenceState.users_allowed || 1,
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
site_id: '',
site_id_multiple: []
}
public isAppActivated = new BehaviorSubject<boolean | null>(null)
public isAppOverCapacity = new BehaviorSubject<boolean>(false)
public currentUserCanRegister = new BehaviorSubject<boolean>(false)
public appLocked = new BehaviorSubject<boolean>(false)
public licenseExpiresInDays = new BehaviorSubject<number | null>(null)
public isAppFreeTier = new BehaviorSubject<boolean>(false)
public licenceProblem = new BehaviorSubject<string | null>(null) //contains the link for particular licence error if any
public hot_license_key = new BehaviorSubject<string | undefined>(undefined)
public licenceState: BehaviorSubject<LicenceState> =
new BehaviorSubject<LicenceState>(this._licenceState)
constructor(
private loggerService: LoggerService,
private eventService: EventService,
private sasService: SasService,
private helperService: HelperService,
private router: Router
) {
this.sasService.incorrectSiteIdEmitter.subscribe((missmatchId: string) => {
if (this.isAppActivated.value !== null && !this.isAppFreeTier.value) {
const url = `/licensing/key?error=missmatch&missmatchId=${missmatchId}&force=true`
this.licenceProblem.next(url)
this.deactivateApp(url)
}
})
}
public async activation(startupservice: any) {
this.freeTierLicenceData.hot_license_key = DEFAULT_HOT_LICENCE_KEY
await this.setStartupserviceRules(startupservice)
await this.licensing(startupservice.globvars, startupservice.SYSSITE)
}
/**
* Sets feature restrictions included in startupservice
* @param startupservice SAS service response
*/
public setStartupserviceRules(startupservice: any) {
// Whether or not to disable EDIT RECORD modal
this._licenceState.editRecord = !(
startupservice.globvars[0].DC_RESTRICT_EDITRECORD === 'YES'
)
}
/**
* Parsing the licence key, checking if it's valid, expired etc.
* It also sets the features encoded in the key.
* @param globvars from response, includes licence key, is user registered and user registered count
* @param startup_site_id system site id
*/
public async licensing(globvars: Globvar[], startup_site_id: string) {
if (!globvars || !globvars[0]) {
const err = 'Error getting "Globvars" from startupservice response.'
console.error(err)
this.isAppActivated.next(false)
this.router.navigateByUrl(
`/licensing/key?error=invalid&details=${btoa(err)}`
)
return
}
let variables = globvars[0]
// Trim whitespace — the SAS makedata service initialises licence keys
// as a single space, which is truthy in JS but not a valid key.
if (variables.LICENCE_KEY)
variables.LICENCE_KEY = variables.LICENCE_KEY.trim()
if (variables.ACTIVATION_KEY)
variables.ACTIVATION_KEY = variables.ACTIVATION_KEY.trim()
if (
variables.LICENCE_KEY === undefined ||
variables.ACTIVATION_KEY === undefined ||
variables.REGISTERCOUNT === null ||
variables.REGISTERCOUNT === undefined ||
variables.ISREGISTERED === null ||
variables.ISREGISTERED === undefined
) {
console.error('Some of globvars are not present')
this.isAppActivated.next(false)
this.eventService.showInfoModal(
'Error',
'Some of the globvars are not present in the startupservice',
'public/startupservice'
)
// this.router.navigateByUrl('/licensing/key?error=missing')
return
}
if (!variables.LICENCE_KEY || !variables.ACTIVATION_KEY) {
//fall back to free tier if key not present
// setTimeout(() => {
return await this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id
)
// })
// return
}
this.licenceKey = variables.LICENCE_KEY
this.activationKey = variables.ACTIVATION_KEY
await this.decryptLicenseKey(
variables.LICENCE_KEY,
variables.ACTIVATION_KEY
).then(
async (decryptedLicenseData: LicenseKeyData) => {
await this.applicationActivation(
decryptedLicenseData,
variables,
startup_site_id
)
},
async (err: any) => {
const errString = `Error decrypting license key. ${err}`
console.error(errString)
// this.isAppActivated.next(false)
const url = `/licensing/key?error=invalid&details=${btoa(
errString
)}&force=true`
this.licenceProblem.next(url)
setTimeout(() => {
this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id,
true
)
this.router.navigateByUrl(url)
})
}
)
}
/**
* Checks if:
* - site id match
* - key is/isn't expired
* - user can be registered (user limit)
* @param licenseData
* @param variables from SAS response
* @param startup_site_id of current sas environment
*/
private applicationActivation(
licenseData: LicenseKeyData,
variables?: Globvar,
startup_site_id?: string,
skipRouting?: boolean
) {
if (!skipRouting) skipRouting = false
this.setSiteId(licenseData)
if (!licenseData.demo && variables && startup_site_id) {
this.handleSiteIdMissmatch(
licenseData,
variables,
startup_site_id,
skipRouting
)
}
let hotLicenceKey =
licenseData.hot_license_key !== undefined
? licenseData.hot_license_key
: this.hot_license_key.value
if (!hotLicenceKey) hotLicenceKey = DEFAULT_HOT_LICENCE_KEY
this.hot_license_key.next(hotLicenceKey)
if (this.userCountLimitation) {
this.handleUsersAllowed(licenseData)
}
this.licenseKeyData = this.helperService.deepClone(licenseData)
;(window as any).appinfo() //Show the table of appinfo in the console
const { expiry_date, daysToExpiry } = this.calculateExpiry(licenseData)
;(window as any).licenseExpiresIn = daysToExpiry
this.licenseExpiresInDays.next(daysToExpiry)
if (daysToExpiry <= 0) {
console.error('License key has expired')
if (variables && startup_site_id) {
this.handleExpiry(expiry_date, variables, startup_site_id, skipRouting)
}
return
}
this.isAppFreeTier.next(!!licenseData.demo)
if (variables) {
this.handleUserRegistration(licenseData, variables, skipRouting)
}
this.decodeLicenceFeatures(licenseData)
this.licenceState.next(this._licenceState) //set the global licence state
}
/**
* 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) {
if (!licenseData.features) {
if (licenseData.demo) {
return
} else {
this._licenceState = this.allFeaturesOn
return
}
}
this._licenceState = {
...this._licenceState,
...decodeFeatures(licenseData.features)
}
this.loggerService.log('Licence state:', this._licenceState)
}
/**
* 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.
*/
public decryptLicenseKey(
licenseKey: string,
activationKey: string
): Promise<LicenseKeyData> {
return new Promise<LicenseKeyData>(async (resolve, reject) => {
if (!(window.crypto && window.crypto.subtle)) {
try {
let licenseKeyDecoded = atob(licenseKey.split('').reverse().join(''))
resolve(JSON.parse(licenseKeyDecoded))
} catch (ex) {
reject('Error parsing http license key data. ' + ex)
}
}
let licenseKeyBytes = await this.base64ToArrayBuffer(licenseKey).catch(
(err: any) => {
reject(err)
}
)
let activationKeyBytes: ArrayBuffer
const activationKeyBytesTemp = await this.base64ToArrayBuffer(
activationKey
).catch((err: any) => {
reject(err)
})
if (!activationKeyBytesTemp) {
reject('Missing activation key')
return
}
activationKeyBytes = activationKeyBytesTemp
let decryptionKey
try {
decryptionKey = await window.crypto.subtle.importKey(
'pkcs8',
activationKeyBytes,
{
name: 'RSA-OAEP',
hash: 'SHA-256'
},
true,
['decrypt']
)
} catch (ex) {
reject('Unable to import decryption key: ' + ex)
}
if (!decryptionKey) {
reject('Unable to import decryption key')
return
}
if (!licenseKeyBytes) {
reject('License key bytes missing')
} else {
try {
window.crypto.subtle
.decrypt(
{
name: 'RSA-OAEP',
hash: { name: 'SHA-256' }
} as any,
decryptionKey,
licenseKeyBytes
)
.then(
(value: any) => {
let keyDataString = new encoding.TextDecoder().decode(value)
try {
resolve(JSON.parse(keyDataString))
} catch (ex) {
reject('Error parsing license key data. ' + ex)
}
},
(err: any) => {
reject(err)
}
)
} catch (ex) {
reject(ex)
}
}
})
}
public getHotLicenseKey() {
return this.hot_license_key.value
}
public getLicenseKeyData() {
return this.licenseKeyData
}
public deactivateApp(redirectTo?: string) {
// this.isAppActivated.next(false)
this._licenceState = freeTierConfig
this.applicationActivation(this.freeTierLicenceData)
if (redirectTo) this.router.navigateByUrl(redirectTo)
}
/**
* Checks fo lineage daily renderings limit
*
* @param checkOnly if true it will only check if limit reached
* othervise it will increment number of renderings.
* @returns true if limited and false if not limited
*/
public checkLineageLimit(checkOnly?: boolean): boolean {
if (this.licenceState.value.lineage_daily_limit === Infinity) return false
const renders = localStorage.getItem('lineage_renders')
const todayTimestamp = moment().valueOf()
if (!renders) {
if (!checkOnly) this.setLineageRender(todayTimestamp, 1)
return false
}
const timestamp = parseInt(renders.split(',')[0])
const noOfRenders = parseInt(renders.split(',')[1])
if (moment(timestamp).isSame(moment(), 'day')) {
if (noOfRenders >= this.licenceState.value.lineage_daily_limit)
return true
if (!checkOnly) this.setLineageRender(todayTimestamp, noOfRenders + 1)
return false
}
if (!checkOnly) this.setLineageRender(todayTimestamp, 1)
return false
}
private setLineageRender(timestamp: number, renders: number) {
localStorage.setItem('lineage_renders', `${timestamp},${renders}`)
}
public base64ToArrayBuffer(base64: string): Promise<ArrayBuffer> {
return new Promise(async (resolve, reject) => {
resolve(base64Converter.decode(base64))
})
}
public arrayBufferToBase64(arrayBuffer: ArrayBuffer): Promise<string> {
return new Promise((resolve, reject) => {
resolve(base64Converter.encode(arrayBuffer))
})
}
private setSiteId(licenseData: LicenseKeyData) {
if (licenseData.site_id_multiple) {
this.sasService.setLicenseSiteId(licenseData.site_id_multiple)
} else if (licenseData.site_id) {
this.sasService.setLicenseSiteId(licenseData.site_id)
}
}
/**
* One of our customers is having multiple site ids in same organization.
* So we added support for list od site ids to be placed in licence key.
* But we also want to have dc backwards compatible so we have to check
* for both properties legacy and new.
* New key must work with old DC
* Old key must work with new DC
*/
private handleSiteIdMissmatch(
licenseData: LicenseKeyData,
variables: Globvar,
startup_site_id: string,
skipRouting: boolean
) {
let noLegacySiteId: boolean = false
let noMultipleSiteId: boolean = false
if (!licenseData.site_id || licenseData.site_id !== startup_site_id) {
noLegacySiteId = true
}
if (
!licenseData.site_id_multiple ||
!licenseData.site_id_multiple.includes(startup_site_id)
) {
noMultipleSiteId = true
}
if (noLegacySiteId && noMultipleSiteId) {
console.error('The key provided is for different organization.')
// this.isAppActivated.next(false)
setTimeout(() => {
const url = '/licensing/key?error=missmatch&force=true'
this.licenceProblem.next(url)
this._licenceState = freeTierConfig
this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id,
true
)
if (!skipRouting) this.router.navigateByUrl(url)
})
return
}
}
private calculateExpiry(licenseData: LicenseKeyData): {
expiry_date: moment.Moment
daysToExpiry: number
} {
let expiry_date = moment(licenseData.valid_until, 'YYYY-MM-DD').startOf(
'day'
)
let current_date = moment().startOf('day')
let daysToExpiry = expiry_date.diff(current_date, 'days')
return {
expiry_date,
daysToExpiry
}
}
private handleExpiry(
expiry_date: moment.Moment,
variables: Globvar,
startup_site_id: string,
skipRouting: boolean
) {
setTimeout(() => {
const url = `/licensing/key?force=true&error=expired&details=${btoa(
'Expiry date: ' + expiry_date.format('DD/MM/YYYY')
)}`
this.licenceProblem.next(url)
// Instead of blocking the app we revert to free tier version of the app
this.applicationActivation(
this.freeTierLicenceData,
variables,
startup_site_id,
true
)
if (!skipRouting) {
this.router.navigateByUrl(url)
}
})
}
private handleUsersAllowed(licenseData: LicenseKeyData) {
if (licenseData.demo) {
if (this._licenceState.users_allowed !== undefined)
licenseData.users_allowed =
licenseData.users_allowed > 0
? licenseData.users_allowed
: this._licenceState.users_allowed
}
this._licenceState.users_allowed = licenseData.users_allowed
}
private handleUserRegistration(
licenseData: LicenseKeyData,
variables: Globvar,
skipRouting: boolean
) {
if (this.userCountLimitation) {
//if limit disabled we don't track the count
if (variables.REGISTERCOUNT > licenseData.users_allowed) {
//App is over capacity of users
console.warn('App has more users registered then licensed.')
this.isAppOverCapacity.next(true)
} else if (variables.REGISTERCOUNT === licenseData.users_allowed) {
//App reached capacity of users
console.warn('App has hit limit of users registered.')
}
}
if (variables.ISREGISTERED === 1) {
this.isAppActivated.next(true)
return
} else {
// if limit is disabled we always go into register directly
if (
variables.REGISTERCOUNT < licenseData.users_allowed ||
!this.userCountLimitation
) {
console.log('User can register')
this.currentUserCanRegister.next(true)
// this.appLocked.next(true)
this.isAppActivated.next(false)
if (!skipRouting) this.router.navigateByUrl('/licensing/register')
//After register, activate app
return
} else {
this.currentUserCanRegister.next(false)
this.isAppActivated.next(false)
// this.appLocked.next(true)
if (!skipRouting) this.router.navigateByUrl('/licensing/limit')
}
}
}
}