Merge pull request #2671 from beekeeper-studio/file-based-license

Offline License Checking
This commit is contained in:
Matthew Rathbone
2025-04-05 15:32:10 -05:00
committed by GitHub
28 changed files with 514 additions and 72 deletions

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsXaGGue23iKEC0WCyOgB
vR+krTre7bFyeH27hPPGhdqVNeGuVH9X3Q455LgPhGJiyACh9HZjGYjoyrlX0UgS
wQUqyeF40yzYVsmHpey7J/d8Du3nq1uF/jW9+WmcVdkSokKjZK63biH8JPKVBzmE
X6TCpNWdv45N11yggH+SfqqOj15ZMVI1XxC0Zay8Z9B809ZHaQHt3RSZ25cPUDi1
DB2KKT9b60dzEVTM2BdwHTIrM+syQdshfSg/ksuAomHTJe1UYTcH8XiLP2YKBANw
6FGm6XTjjEgSYz0k/g9wAIT/Jlpfwc7Zd5aj6wgWDuozMW4CQZHaEUSeBiZFd2tV
vQIDAQAB
-----END PUBLIC KEY-----

View File

@@ -208,6 +208,9 @@ export default Vue.extend({
const isValidDateExpired = compare ? !prev.isValidDateExpired && curr.isValidDateExpired : this.status.isValidDateExpired
const isSupportDateExpired = compare ? !prev.isSupportDateExpired && curr.isSupportDateExpired : this.status.isSupportDateExpired
const status = compare ? curr : this.status
if (curr?.fromFile && curr?.noLicenseKey) {
this.$noty.error(`Something is wrong with your license file: ${curr?.condition.join(", ") }`)
}
if (isValidDateExpired) {
this.$root.$emit(AppEvent.licenseValidDateExpired, status)

View File

@@ -0,0 +1,131 @@
import platformInfo from '@/common/platform_info'
import path from 'path'
import crypto from 'crypto'
import { existsSync, readFileSync } from 'fs'
import { keysToStatus, LicenseKey } from '@/common/appdb/models/LicenseKey'
import { LicenseStatus } from '@/lib/license'
import rawLog from '@bksLogger'
import { snakeCaseObjectKeys } from '@/common/utils'
import { TransportLicenseKey } from '@/common/transport'
const log = rawLog.scope('OfflineLicense')
let _cachedLicense = null
/**
* Payload looks like this (for good license):
* {
"license_key": {
"valid_until": "2099-01-01T06:00:00.000Z",
"support_until": "2099-01-01T06:00:00.000Z",
"customer_support_until": "2099-01-01T06:00:00.000Z",
"created_at": "2024-12-14T03:38:52.989Z",
"license_type": "PersonalLicense",
"email": "123",
"id": 4,
"key": "e605ca57-2115-4b21-b1ec-690f997df38e",
"subscription": true,
"max_allowed_app_release": null
},
"errors": null,
"status": 200
}
*/
export interface LicenseOptions {
licensePath?: string
keyPath?: string
}
export class OfflineLicense {
static load(options?: LicenseOptions): OfflineLicense {
if (!_cachedLicense) _cachedLicense = new OfflineLicense(options)
return _cachedLicense
}
defaultPath = path.join(platformInfo.userDirectory, 'license.json')
defaultKeyPath = path.join(platformInfo.resourcesPath, 'production_pub.pem')
path: string
publicKeyPath: string
rawPayload? = null
payload = null
publicKey = null
isValid = false
error?: string
constructor(options: LicenseOptions = {}) {
const { licensePath, keyPath } = options
this.path = licensePath ?? this.defaultPath
this.publicKeyPath = keyPath ?? this.defaultKeyPath
try {
if (existsSync(this.path)) {
this.rawPayload = JSON.parse(readFileSync(this.path, 'utf-8'))
}
if (existsSync(this.publicKeyPath)) {
this.publicKey = readFileSync(this.publicKeyPath, 'utf-8')
}
this.validateSignature()
log.info("Validated license - ", this.payload)
} catch (ex) {
log.error("Unable to validate license -", ex)
this.isValid = false
this.error = ex.message
}
}
exists() {
return !!this.payload
}
toLicenseKey() {
if (!this.isValid) return null
const parsedKey = this.payload.license_key
const result = new LicenseKey()
result.email = parsedKey.email
result.validUntil = new Date(parsedKey.valid_until)
result.supportUntil = new Date(parsedKey.support_until)
result.maxAllowedAppRelease = parsedKey.max_allowed_app_release
result.licenseType = parsedKey.license_type
result.key = parsedKey.key
result.fromFile = true
return result
}
toLicenseStatus(): LicenseStatus {
const licenses = [this.toLicenseKey()]
const result = keysToStatus(licenses)
// we keep this, becuase if licenseKey is null, we still need LicenseStatus to know it was from a file
result.fromFile = true
result.filePath = this.path
return result
}
validateSignature() {
if (!this.rawPayload) return
if (!this.publicKey) return
if (!this.rawPayload?.data) return
if (!this.rawPayload?.signature) return
// Decode the Base64-encoded signature
const signature = Buffer.from(this.rawPayload.signature, 'base64');
const data = Buffer.from(this.rawPayload.data, 'base64').toString('utf-8');
// Create a SHA256 hash of the decoded data
const verifier = crypto.createVerify('SHA256');
verifier.update(data);
verifier.end();
// Verify the signature with the public key, convert sig to uint8array
const signatureUint8Array = new Uint8Array(signature);
const isValid = verifier.verify(this.publicKey, signatureUint8Array);
this.isValid = isValid
if (this.isValid) {
this.payload = JSON.parse(data)
}
}
}

View File

@@ -32,13 +32,20 @@ const models = [
InstallationId
]
interface IConnectionState {
connection: Connection | null
}
export const ConnectionState: IConnectionState = {
connection: null
}
export default class Connection {
private connection?: DataSource
public connection?: DataSource
constructor(private path: string, private logging: LoggerOptions = false) {}
async connect(options: any = {}): Promise<DataSource> {
async connect(options: any = {}): Promise<void> {
this.connection = new DataSource({
database: this.path,
type: 'better-sqlite3',
@@ -49,7 +56,7 @@ export default class Connection {
...options
})
await this.connection.initialize()
return this.connection
ConnectionState.connection = this
}
async disconnect() {

View File

@@ -1,58 +1,18 @@
import platformInfo from "@/common/platform_info";
import { LicenseStatus } from "@/lib/license";
import { isVersionLessThanOrEqual } from "@/common/version";
import { Column, Entity, Not } from "typeorm";
import { ApplicationEntity } from "./application_entity";
import _ from 'lodash';
import globals from "@/common/globals";
import platformInfo from '@/common/platform_info'
import { isVersionLessThanOrEqual } from "@/common/version";
function daysInFuture(days: number = 14) {
function daysInFuture(days = 14) {
return new Date(new Date().setDate(new Date().getDate() + days))
}
@Entity({ name: 'license_keys' })
export class LicenseKey extends ApplicationEntity {
withProps(props: any) {
if (props) LicenseKey.merge(this, props);
return this;
}
@Column({type: 'varchar', nullable: false})
email: string
@Column({type: 'varchar', nullable: false})
key: string
@Column({type: 'datetime', nullable: false})
validUntil: Date
@Column({type: 'datetime', nullable: false})
supportUntil: Date
@Column({ type: 'varchar', nullable: false })
licenseType: 'TrialLicense' | 'PersonalLicense' | 'BusinessLicense'
@Column({ type: 'json', nullable: true })
maxAllowedAppRelease: { tagName: string }
/** Get all licenses except trial */
static async all() {
return await LicenseKey.findBy({ licenseType: Not("TrialLicense" as const) });
}
/** Delete all licenses except trial */
static async wipe() {
await LicenseKey.delete({ licenseType: Not("TrialLicense" as const) });
}
static async getLicenseStatus(): Promise<LicenseStatus> {
export function keysToStatus(licenses: LicenseKey[]): LicenseStatus {
const status = new LicenseStatus();
status.condition = []
const licenses = await LicenseKey.find();
const currentDate = new Date();
const currentVersion = platformInfo.parsedAppVersion;
@@ -95,6 +55,52 @@ export class LicenseKey extends ApplicationEntity {
status.edition = "community";
status.condition.push("App version not allowed");
return status;
}
@Entity({ name: 'license_keys' })
export class LicenseKey extends ApplicationEntity {
withProps(props: any) {
console.log("merging props into me:", props)
if (props) LicenseKey.merge(this, props);
return this;
}
fromFile = false
@Column({type: 'varchar', nullable: false})
email: string
@Column({type: 'varchar', nullable: false})
key: string
@Column({type: 'datetime', nullable: false})
validUntil: Date
@Column({type: 'datetime', nullable: false})
supportUntil: Date
@Column({ type: 'varchar', nullable: false })
licenseType: 'TrialLicense' | 'PersonalLicense' | 'BusinessLicense'
@Column({ type: 'json', nullable: true })
maxAllowedAppRelease: { tagName: string }
/** Get all licenses except trial */
static async all() {
return await LicenseKey.findBy({ licenseType: Not("TrialLicense" as const) });
}
/** Delete all licenses except trial */
static async wipe() {
await LicenseKey.delete({ licenseType: Not("TrialLicense" as const) });
}
static async getLicenseStatus(): Promise<LicenseStatus> {
const licenses = await LicenseKey.find();
return keysToStatus(licenses)
}
public get active() : boolean {

View File

@@ -8,6 +8,22 @@ import { SettingsPlugin } from '@/plugins/SettingsPlugin';
import { IndexColumn } from '@shared/lib/dialects/models';
import type { Stream } from 'stream';
export function camelCaseObjectKeys(data) {
if (_.isPlainObject(data)) {
const result = _.deepMapKeys(data, (_value, key) => _.camelCase(key))
return result
}
return data
}
// I don't know why different, but don't want to edit.
export function snakeCaseObjectKeys(data) {
const result = _.mapKeys(data, (_value, key) => {
return _.snakeCase(key)
})
return result
}
export function parseIndexColumn(str: string): IndexColumn {
str = str.trim()

View File

@@ -2,9 +2,19 @@
<div class="license-information card card-flat padding">
<h3 class="card-title flex flex-middle">
<span class="expand">{{ license.email }}</span>
<a @click.prevent="destroy" class="btn btn-danger btn-icon"><i class="material-icons">delete_outline</i><span>Remove from app</span></a></h3>
<a v-if="!licenseStatus.fromFile" @click.prevent="destroy" class="btn btn-danger btn-icon"><i class="material-icons">delete_outline</i><span>Remove from app</span></a></h3>
<div class="card-body">
<table class="simple-table">
<tr>
<td>License Type <a href="https://docs.beekeeperstudio.io/">learn more</a></td>
<td>{{ licenseStatus.fromFile ? "Offline File License" : "Online License" }}</td>
</tr>
<template v-if="licenseStatus.fromFile">
<tr>
<td>File Path</td>
<td>{{ licenseStatus.filePath }}</td>
</tr>
</template>
<tr>
<td>Status</td>
<td>{{ status }}</td>

View File

@@ -1,3 +1,5 @@
import { OfflineLicense } from "@/backend/lib/OfflineLicense";
import { ConnectionState } from "@/common/appdb/Connection";
import { LicenseKey } from "@/common/appdb/models/LicenseKey";
import { TransportLicenseKey } from "@/common/transport";
import { LicenseStatus } from "@/lib/license";
@@ -23,7 +25,15 @@ export const LicenseHandlers: ILicenseHandlers = {
}
},
"license/getStatus": async function () {
const status = await LicenseKey.getLicenseStatus();
// If someone has a file-based license, that takes
// priority over ALL other licenses
const offline = OfflineLicense.load()
let status = null
if (offline && offline.isValid) {
status = offline.toLicenseStatus()
} else {
await LicenseKey.getLicenseStatus();
}
return {
...status,
isUltimate: status.isUltimate,
@@ -35,6 +45,8 @@ export const LicenseHandlers: ILicenseHandlers = {
};
},
"license/get": async function () {
const offline = OfflineLicense.load()
if (offline) return [offline.toLicenseKey()]
return await LicenseKey.find();
},
"license/wipe": async function() {

View File

@@ -12,6 +12,8 @@ import { ConnectionFoldersController } from '@/lib/cloud/controllers/ConnectionF
import { QueryFoldersController } from '@/lib/cloud/controllers/QueryFoldersController';
import { UsedQueriesController } from '@/lib/cloud/controllers/UsedQueriesController';
import { LicenseKeyController } from './controllers/LicenseKeyController';
import { camelCaseObjectKeys, snakeCaseObjectKeys } from '@/common/utils';
import { IPlatformInfo } from '@/common/IPlatformInfo';
const log = rawLog.scope('cloudClient')
@@ -22,19 +24,11 @@ const defaultTransformRequest = ad.transformRequest as AxiosRequestTransformer[]
const defaultTransformResponse = ad.transformResponse as AxiosResponseTransformer[]
const snakeCaseData: AxiosRequestTransformer = (data) => {
const result = _.mapKeys(data, (_value, key) => {
return _.snakeCase(key)
})
return result
return snakeCaseObjectKeys(data)
}
const camelCaseData: AxiosResponseTransformer = (data) => {
if (_.isPlainObject(data)) {
const result = _.deepMapKeys(data, (_value, key) => _.camelCase(key))
return result
}
return data
return camelCaseObjectKeys(data)
}

View File

@@ -1,6 +1,5 @@
import { TransportLicenseKey } from "@/common/transport";
import { parseVersion, Version } from "@/common/version";
export interface BksVersion extends Version {
channel: 'stable' | 'beta' | 'alpha'
channelRelease?: number
@@ -15,10 +14,13 @@ export enum DevLicenseState {
expiredLifetimeCoversEarlierVersion,
}
export class LicenseStatus {
edition: "community" | "ultimate"
condition: string[]
license?: TransportLicenseKey
fromFile = false
filePath?: string = undefined
get isUltimate() {
return this.edition === "ultimate";

View File

@@ -0,0 +1 @@
{"data":"eyJsaWNlbnNlX2tleSI6eyJ2YWxpZF91bnRpbCI6IjIwMTAtMDEtMDFUMDY6MDA6MDAuMDAwWiIsInN1cHBvcnRfdW50aWwiOiIyMDEwLTAxLTAxVDA2OjAwOjAwLjAwMFoiLCJjdXN0b21lcl9zdXBwb3J0X3VudGlsIjoiMjA5OS0wMS0wMVQwNjowMDowMC4wMDBaIiwiY3JlYXRlZF9hdCI6IjIwMjQtMTItMTRUMDM6Mzg6NTIuOTg5WiIsImxpY2Vuc2VfdHlwZSI6IlBlcnNvbmFsTGljZW5zZSIsImVtYWlsIjoiMTIzIiwiaWQiOjQsImtleSI6ImU2MDVjYTU3LTIxMTUtNGIyMS1iMWVjLTY5MGY5OTdkZjM4ZSIsInN1YnNjcmlwdGlvbiI6dHJ1ZSwibWF4X2FsbG93ZWRfYXBwX3JlbGVhc2UiOm51bGx9LCJlcnJvcnMiOm51bGwsInN0YXR1cyI6MjAwfQ==","signature":"ksjhB1BacaoYfoEDntEumqmj5YiiZvs9bhNeLxQRnQPmD7egOaVBsmV9pXwH5FSQXShXiiKqJcOWQr/ifulDuPf4j1EX57doFTH0aDI0BXdlhVa4339mqH6Jayu5yC/VfKnLobS527sex1DXTxeD64CYD7FPzqROH54Ewdt+QYdGum4MObVIg13yl+FIpqbTDRImLnO84NEnjags4aBWkem5PEh9FwfTVRsc0UNwJa1x6wTqnVzb4Y49iyUbTjHIUxrIMnrG0A5VUVjw3irGnbuuW/QB3J5nJWjVn7Dgn6JGh6+A87rW7ip51M8DSFf2lfk+qL6UKpe486xs1KiXXA=="}

View File

@@ -0,0 +1 @@
{"data":"eyJsaWNlbnNlX2tleSI6eyJ2YWxpZF91bnRpbCI6IjIwOTktMDEtMDFUMDY6MDA6MDAuMDAwWiIsInN1cHBvcnRfdW50aWwiOiIyMDk5LTAxLTAxVDA2OjAwOjAwLjAwMFoiLCJjdXN0b21lcl9zdXBwb3J0X3VudGlsIjoiMjA5OS0wMS0wMVQwNjowMDowMC4wMDBaIiwiY3JlYXRlZF9hdCI6IjIwMjQtMTItMTRUMDM6Mzg6NTIuOTg5WiIsImxpY2Vuc2VfdHlwZSI6IlBlcnNvbmFsTGljZW5zZSIsImVtYWlsIjoiMTIzIiwiaWQiOjQsImtleSI6ImU2MDVjYTU3LTIxMTUtNGIyMS1iMWVjLTY5MGY5OTdkZjM4ZSIsInN1YnNjcmlwdGlvbiI6dHJ1ZSwibWF4X2FsbG93ZWRfYXBwX3JlbGVhc2UiOm51bGx9LCJlcnJvcnMiOm51bGwsInN0YXR1cyI6MjAwfQ==","signature":"ksjhB1BacaoYfoEDntEumqmj5YiiZvs9bhNeLxQRnQPmD7egOaVBsmV9pXwH5FSQXShXiiKqJcOWQr/ifulDuPf4j1EX57doFTH0aDI0BXdlhVa4339mqH6Jayu5yC/VfKnLobS527sex1DXTxeD64CYD7FPzqROH54Ewdt+QYdGum4MObVIg13yl+FIpqbTDRImLnO84NEnjags4aBWkem5PEh9FwfTVRsc0UNwJa1x6wTqnVzb4Y49iyUbTjHIUxrIMnrG0A5VUVjw3irGnbuuW/QB3J5nJWjVn7Dgn6JGh6+A87rW7ip51M8DSFf2lfk+qL6UKpe486xs1KiXXA=="}

View File

@@ -0,0 +1 @@
{this is not valid json}

View File

@@ -0,0 +1 @@
{"signature":"ksjhB1BacaoYfoEDntEumqmj5YiiZvs9bhNeLxQRnQPmD7egOaVBsmV9pXwH5FSQXShXiiKqJcOWQr/ifulDuPf4j1EX57doFTH0aDI0BXdlhVa4339mqH6Jayu5yC/VfKnLobS527sex1DXTxeD64CYD7FPzqROH54Ewdt+QYdGum4MObVIg13yl+FIpqbTDRImLnO84NEnjags4aBWkem5PEh9FwfTVRsc0UNwJa1x6wTqnVzb4Y49iyUbTjHIUxrIMnrG0A5VUVjw3irGnbuuW/QB3J5nJWjVn7Dgn6JGh6+A87rW7ip51M8DSFf2lfk+qL6UKpe486xs1KiXXA=="}

View File

@@ -0,0 +1 @@
{"data":"eyJsaWNlbnNlX2tleSI6eyJ2YWxpZF91bnRpbCI6IjIwOTktMDEtMDFUMDY6MDA6MDAuMDAwWiIsInN1cHBvcnRfdW50aWwiOiIyMDk5LTAxLTAxVDA2OjAwOjAwLjAwMFoiLCJjdXN0b21lcl9zdXBwb3J0X3VudGlsIjoiMjA5OS0wMS0wMVQwNjowMDowMC4wMDBaIiwiY3JlYXRlZF9hdCI6IjIwMjQtMTItMTRUMDM6Mzg6NTIuOTg5WiIsImxpY2Vuc2VfdHlwZSI6IlBlcnNvbmFsTGljZW5zZSIsImVtYWlsIjoiMTIzIiwiaWQiOjQsImtleSI6ImU2MDVjYTU3LTIxMTUtNGIyMS1iMWVjLTY5MGY5OTdkZjM4ZSIsInN1YnNjcmlwdGlvbiI6dHJ1ZSwibWF4X2FsbG93ZWRfYXBwX3JlbGVhc2UiOm51bGx9LCJlcnJvcnMiOm51bGwsInN0YXR1cyI6MjAwfQ=="}

View File

@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmuASw+WbmTU1083fuJPs
aXZyfWKDVplk/uaizlb/e9k/hLk5E9Xn9g1LM7ju4rqs1mN8zpfphg0+4fUeaZi+
+24c2mseQ32gBEmssjXNiiqwRAcLVo+5xyHeVpD67o92HCBOaXcShPq4+UsuYnKA
b9VHcDMAvN0tzJH88S6+EJsHhAR9nNc2Utwlcyn41qFxqfEki22URH4U0HRJdRmb
/t6Q4lcG1oYZxFYjfzppcj5PlvB8Zmga+vbFnHCl8LEbXCYWHWbgDIcB2t+Uix+R
fr1cVHr2sCTcjxeatLA43SPlLC23Yybaxz0P2lQO88hEn+7jahxLePUq9A6ljImw
vwIDAQAB
-----END PUBLIC KEY-----

View File

@@ -0,0 +1,188 @@
import { OfflineLicense, LicenseOptions } from '@/backend/lib/OfflineLicense'
import { TestOrmConnection } from '@tests/lib/TestOrmConnection'
import { LicenseStatus } from '@/lib/license'
import path from 'path'
/**
* Payload looks like this (for good license):
* {
* "license_key": {
* "valid_until": "2099-01-01T06:00:00.000Z",
* "support_until": "2099-01-01T06:00:00.000Z",
* "customer_support_until": "2099-01-01T06:00:00.000Z",
* "created_at": "2024-12-14T03:38:52.989Z",
* "license_type": "PersonalLicense",
* "email": "123",
* "id": 4,
* "key": "e605ca57-2115-4b21-b1ec-690f997df38e",
* "subscription": true,
* "max_allowed_app_release": null
* },
* "errors": null,
* "status": 200
*}
*/
describe("Offline License", () => {
/**
* @type {OfflineLicense}
*/
let goodOfflineLicense = null
beforeAll(async () => {
await TestOrmConnection.connect()
const publicKeyPath = "tests/resources/public.pem"
const goodLicense = 'tests/resources/good.license'
const options = {
licensePath: goodLicense,
keyPath: publicKeyPath
}
goodOfflineLicense = new OfflineLicense(options)
})
afterAll(async () => {
await TestOrmConnection.disconnect()
})
it("Good license: should be valid", async () => {
expect(goodOfflineLicense.isValid).toBeTruthy()
})
it("Good license: Should extract data properly", async () => {
const key = goodOfflineLicense.toLicenseKey()
expect(key.validUntil).toEqual(new Date("2099-01-01T06:00:00.000Z"))
expect(key.key).toEqual("e605ca57-2115-4b21-b1ec-690f997df38e")
expect(key.supportUntil).toEqual(new Date("2099-01-01T06:00:00.000Z"))
})
it("Bad license: Should not be valid", async() => {
const publicKeyPath = "tests/resources/public.pem"
const badLicense = "tests/resources/bad.license"
const options = {
licensePath: badLicense,
keyPath: publicKeyPath
}
const license = new OfflineLicense(options)
expect(license.isValid).not.toBeTruthy()
})
// Additional tests begin here
it("Should check for exists() method with valid license", async () => {
expect(goodOfflineLicense.exists()).toBeTruthy()
})
it("Should handle non-existent license file", async () => {
const publicKeyPath = "tests/resources/public.pem"
const nonExistentLicense = "tests/resources/nonexistent.license"
const options = {
licensePath: nonExistentLicense,
keyPath: publicKeyPath
}
const license = new OfflineLicense(options)
expect(license.isValid).toBeFalsy()
expect(license.exists()).toBeFalsy()
})
it("Should handle non-existent public key file", async () => {
const nonExistentKey = "tests/resources/nonexistent.pem"
const goodLicense = 'tests/resources/good.license'
const options = {
licensePath: goodLicense,
keyPath: nonExistentKey
}
const license = new OfflineLicense(options)
expect(license.isValid).toBeFalsy()
})
it("Should handle invalid JSON in license file", async () => {
const publicKeyPath = "tests/resources/public.pem"
const invalidJsonLicense = "tests/resources/invalid_json.license"
const options = {
licensePath: invalidJsonLicense,
keyPath: publicKeyPath
}
const license = new OfflineLicense(options)
expect(license.isValid).toBeFalsy()
expect(license.error).toBeDefined()
})
it("Should correctly generate LicenseStatus for valid license", async () => {
const status = goodOfflineLicense.toLicenseStatus()
expect(status).toBeInstanceOf(Object)
expect(status.fromFile).toBeTruthy()
expect(status.filePath).toEqual(goodOfflineLicense.path)
expect(status.isUltimate).toBeDefined()
expect(status.isCommunity).toBeDefined()
})
it("Should return null from toLicenseKey() for invalid license", async () => {
const publicKeyPath = "tests/resources/public.pem"
const badLicense = "tests/resources/bad.license"
const options = {
licensePath: badLicense,
keyPath: publicKeyPath
}
const license = new OfflineLicense(options)
expect(license.toLicenseKey()).toBeNull()
})
it("Should cache license instance when using load() static method", async () => {
const publicKeyPath = "tests/resources/public.pem"
const goodLicense = 'tests/resources/good.license'
const options = {
licensePath: goodLicense,
keyPath: publicKeyPath
}
const license1 = OfflineLicense.load(options)
const license2 = OfflineLicense.load() // Should return cached instance
expect(license1).toBe(license2) // Should be the same instance
})
it("Should handle license file with missing signature", async () => {
const publicKeyPath = "tests/resources/public.pem"
const missingSignature = "tests/resources/missing_signature.license"
const options = {
licensePath: missingSignature,
keyPath: publicKeyPath
}
const license = new OfflineLicense(options)
expect(license.isValid).toBeFalsy()
})
it("Should handle license file with missing data", async () => {
const publicKeyPath = "tests/resources/public.pem"
const missingData = "tests/resources/missing_data.license"
const options = {
licensePath: missingData,
keyPath: publicKeyPath
}
const license = new OfflineLicense(options)
expect(license.isValid).toBeFalsy()
})
it("Should use default paths when no options provided", async () => {
const license = new OfflineLicense()
expect(license.path).toBeDefined()
expect(license.publicKeyPath).toBeDefined()
})
})

View File

@@ -19,7 +19,7 @@ function v(strings: TemplateStringsArray) {
function expectStatus() {
return expect(
LicenseKey.getLicenseStatus().then((s) => _.omit(s, ["license"]))
LicenseKey.getLicenseStatus().then((s) => _.omit(s, ["license", "fromFile", "filePath"]))
).resolves;
}

View File

@@ -38,3 +38,4 @@ function hexStringToNumbers(hex: string) {
if (hex === '') return []
return hex.match(/.{1,2}/g).map((byte: string) => parseInt(byte, 16));
}

View File

@@ -53,7 +53,7 @@ ASSETS=$(gh release view $RELEASE_TAG --repo $SOURCE_REPO --json assets --jq '.a
for ASSET_URL in $ASSETS; do
ASSET_NAME=$(basename $ASSET_URL)
echo "Downloading asset $ASSET_NAME from $SOURCE_REPO..."
curl -L -o $ASSET_NAME $ASSET_URL
curl -L -H "Accept: application/octet-stream" -o $ASSET_NAME $ASSET_URL
if [ $? -ne 0 ]; then
echo "Failed to download asset $ASSET_NAME."
@@ -61,7 +61,9 @@ for ASSET_URL in $ASSETS; do
fi
echo "Uploading asset $ASSET_NAME to $TARGET_REPO..."
gh release upload $RELEASE_TAG $ASSET_NAME --repo $TARGET_REPO
gh release upload $RELEASE_TAG $ASSET_NAME --repo $TARGET_REPO
if [ $? -ne 0 ]; then
echo "Failed to upload asset $ASSET_NAME."

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -40,7 +40,7 @@ A quick and easy way to explore Beekeeper Studio's functionality is with the `Sa
These topics will help you get started with the most commonly used features of Beekeeper Studio
- [Connecting to a database](./user_guide/connecting/first-page.md)
- [Connecting to a database](./user_guide/connecting/connecting.md)
- [Write some SQL](./user_guide/using-the-sql-editor.md)
- [Browse and edit table data](./user_guide/editing-data.md)
- [Create & modify tables](./user_guide/modify-tables.md)

View File

@@ -0,0 +1,42 @@
---
title: Offline Licenses
summary: If you're behind a firewall or are managing a fleet of machines, an offline license might be useful.
---
When you purchase a Beekeeper Studio license by default you'll be given a license name and license key to enter into the app.
This license key and name will be validated against the Beekeeper Studio servers.
This works great for 99% of users, but sometimes online validation of a license key might not work, and the app will tell you that you don't have a valid license (even when you do)
![Trial ended by mistake](../assets/images/trial-ended.png)
To solve this problem, if you purchased a yearly professional license or team license you can download the key for 100% offline validation.
## How to use an offline license
1. Go to [the license management dashboard](https://app.beekeeperstudio.io/purchases)
2. Click on your license
3. Click `Download offline key` - this will give you your offline license key
![License](../assets/images/download-key.png)
4. Save the file as `license.json` in the Beekeeper Studio configuration directory:
- Linux: `~/.config/beekeeper-studio/license.json`
- Linux (snap): `~/snap/beekeeper-studio/current/.config/beekeeper-studio/license.json`
- Windows: `~\AppData\Roaming\beekeeper-studio\license.json`
- MacOS: `~/Library/Application Support/beekeeper-studio/license.json`
5. Restart Beekeeper Studio
6. Check `Help -> Manage License Keys` and you should see it registered
![Offline license](../assets/images/offline-license.png)
## How offline licenses work
The offline license file contains a digitally signed license key that Beekeeper Studio can verify locally without contacting any servers. When present, the offline license takes priority over any online licenses. The app verifies the license signature using a public key that's bundled with the application.
## Troubleshooting
If your offline license isn't recognized:
- Make sure the file is named exactly `license.json`
- Verify the file is in the correct directory for your operating system
- Check that the file wasn't corrupted during download
- Try downloading the license file again from the dashboard

View File

@@ -29,4 +29,4 @@ To import query files, you can click the import button, and then click `Import .
## Where does Beekeeper Studio save my SQL Queries?
When you save SQL queries in Beekeeper Studio they are persisted to a SQLite database in your local configuration directory. Please see [Data Storage Location](/support/data-location) for more details.
When you save SQL queries in Beekeeper Studio they are persisted to a SQLite database in your local configuration directory. Please see [Data Storage Location](../support/data-location.md) for more details.

View File

@@ -78,13 +78,16 @@ markdown_extensions:
nav:
- Introduction: index.md
- getting-started-guide.md
- purchasing-a-license.md
- Installation:
- Installing:
- installation/index.md
- installation/windows.md
- installation/macos-m1-intel.md
- installation/linux.md
- Databases:
- Supported Platforms:
- installation/windows.md
- installation/macos-m1-intel.md
- installation/linux.md
- Purchasing:
- purchasing/purchasing-a-license.md
- purchasing/license-types.md
- Connecting To Your Database:
- user_guide/connecting/supported-databases.md
- Connecting To A Database: user_guide/connecting/connecting.md
- DB Specific Guides:
@@ -93,10 +96,12 @@ nav:
- user_guide/connecting/oracle-database.md
- user_guide/connecting/amazon-rdb.md
- user_guide/connecting/firebird.md
- user_guide/connecting/clickhouse.md
- user_guide/connecting/duckdb.md
- user_guide/connecting/mongodb.md
- User Guide:
- Usage Guide:
- user_guide/using-the-sql-editor.md
- user_guide/saving-queries.md
- user_guide/editing-data.md
- user_guide/data-export.md
- user_guide/modify-tables.md