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.
612 lines
18 KiB
TypeScript
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')
|
|
}
|
|
}
|
|
}
|
|
}
|