mirror of
https://github.com/beekeeper-studio/beekeeper-studio.git
synced 2026-03-13 10:12:54 +08:00
Merge pull request #2671 from beekeeper-studio/file-based-license
Offline License Checking
This commit is contained in:
9
apps/studio/extra_resources/production_pub.pem
Normal file
9
apps/studio/extra_resources/production_pub.pem
Normal 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-----
|
||||
@@ -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)
|
||||
|
||||
131
apps/studio/src/backend/lib/OfflineLicense.ts
Normal file
131
apps/studio/src/backend/lib/OfflineLicense.ts
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
1
apps/studio/tests/resources/bad.license
Normal file
1
apps/studio/tests/resources/bad.license
Normal file
@@ -0,0 +1 @@
|
||||
{"data":"eyJsaWNlbnNlX2tleSI6eyJ2YWxpZF91bnRpbCI6IjIwMTAtMDEtMDFUMDY6MDA6MDAuMDAwWiIsInN1cHBvcnRfdW50aWwiOiIyMDEwLTAxLTAxVDA2OjAwOjAwLjAwMFoiLCJjdXN0b21lcl9zdXBwb3J0X3VudGlsIjoiMjA5OS0wMS0wMVQwNjowMDowMC4wMDBaIiwiY3JlYXRlZF9hdCI6IjIwMjQtMTItMTRUMDM6Mzg6NTIuOTg5WiIsImxpY2Vuc2VfdHlwZSI6IlBlcnNvbmFsTGljZW5zZSIsImVtYWlsIjoiMTIzIiwiaWQiOjQsImtleSI6ImU2MDVjYTU3LTIxMTUtNGIyMS1iMWVjLTY5MGY5OTdkZjM4ZSIsInN1YnNjcmlwdGlvbiI6dHJ1ZSwibWF4X2FsbG93ZWRfYXBwX3JlbGVhc2UiOm51bGx9LCJlcnJvcnMiOm51bGwsInN0YXR1cyI6MjAwfQ==","signature":"ksjhB1BacaoYfoEDntEumqmj5YiiZvs9bhNeLxQRnQPmD7egOaVBsmV9pXwH5FSQXShXiiKqJcOWQr/ifulDuPf4j1EX57doFTH0aDI0BXdlhVa4339mqH6Jayu5yC/VfKnLobS527sex1DXTxeD64CYD7FPzqROH54Ewdt+QYdGum4MObVIg13yl+FIpqbTDRImLnO84NEnjags4aBWkem5PEh9FwfTVRsc0UNwJa1x6wTqnVzb4Y49iyUbTjHIUxrIMnrG0A5VUVjw3irGnbuuW/QB3J5nJWjVn7Dgn6JGh6+A87rW7ip51M8DSFf2lfk+qL6UKpe486xs1KiXXA=="}
|
||||
1
apps/studio/tests/resources/good.license
Normal file
1
apps/studio/tests/resources/good.license
Normal file
@@ -0,0 +1 @@
|
||||
{"data":"eyJsaWNlbnNlX2tleSI6eyJ2YWxpZF91bnRpbCI6IjIwOTktMDEtMDFUMDY6MDA6MDAuMDAwWiIsInN1cHBvcnRfdW50aWwiOiIyMDk5LTAxLTAxVDA2OjAwOjAwLjAwMFoiLCJjdXN0b21lcl9zdXBwb3J0X3VudGlsIjoiMjA5OS0wMS0wMVQwNjowMDowMC4wMDBaIiwiY3JlYXRlZF9hdCI6IjIwMjQtMTItMTRUMDM6Mzg6NTIuOTg5WiIsImxpY2Vuc2VfdHlwZSI6IlBlcnNvbmFsTGljZW5zZSIsImVtYWlsIjoiMTIzIiwiaWQiOjQsImtleSI6ImU2MDVjYTU3LTIxMTUtNGIyMS1iMWVjLTY5MGY5OTdkZjM4ZSIsInN1YnNjcmlwdGlvbiI6dHJ1ZSwibWF4X2FsbG93ZWRfYXBwX3JlbGVhc2UiOm51bGx9LCJlcnJvcnMiOm51bGwsInN0YXR1cyI6MjAwfQ==","signature":"ksjhB1BacaoYfoEDntEumqmj5YiiZvs9bhNeLxQRnQPmD7egOaVBsmV9pXwH5FSQXShXiiKqJcOWQr/ifulDuPf4j1EX57doFTH0aDI0BXdlhVa4339mqH6Jayu5yC/VfKnLobS527sex1DXTxeD64CYD7FPzqROH54Ewdt+QYdGum4MObVIg13yl+FIpqbTDRImLnO84NEnjags4aBWkem5PEh9FwfTVRsc0UNwJa1x6wTqnVzb4Y49iyUbTjHIUxrIMnrG0A5VUVjw3irGnbuuW/QB3J5nJWjVn7Dgn6JGh6+A87rW7ip51M8DSFf2lfk+qL6UKpe486xs1KiXXA=="}
|
||||
1
apps/studio/tests/resources/invalid_json.license
Normal file
1
apps/studio/tests/resources/invalid_json.license
Normal file
@@ -0,0 +1 @@
|
||||
{this is not valid json}
|
||||
1
apps/studio/tests/resources/missing_data.license
Normal file
1
apps/studio/tests/resources/missing_data.license
Normal file
@@ -0,0 +1 @@
|
||||
{"signature":"ksjhB1BacaoYfoEDntEumqmj5YiiZvs9bhNeLxQRnQPmD7egOaVBsmV9pXwH5FSQXShXiiKqJcOWQr/ifulDuPf4j1EX57doFTH0aDI0BXdlhVa4339mqH6Jayu5yC/VfKnLobS527sex1DXTxeD64CYD7FPzqROH54Ewdt+QYdGum4MObVIg13yl+FIpqbTDRImLnO84NEnjags4aBWkem5PEh9FwfTVRsc0UNwJa1x6wTqnVzb4Y49iyUbTjHIUxrIMnrG0A5VUVjw3irGnbuuW/QB3J5nJWjVn7Dgn6JGh6+A87rW7ip51M8DSFf2lfk+qL6UKpe486xs1KiXXA=="}
|
||||
1
apps/studio/tests/resources/missing_signature.license
Normal file
1
apps/studio/tests/resources/missing_signature.license
Normal file
@@ -0,0 +1 @@
|
||||
{"data":"eyJsaWNlbnNlX2tleSI6eyJ2YWxpZF91bnRpbCI6IjIwOTktMDEtMDFUMDY6MDA6MDAuMDAwWiIsInN1cHBvcnRfdW50aWwiOiIyMDk5LTAxLTAxVDA2OjAwOjAwLjAwMFoiLCJjdXN0b21lcl9zdXBwb3J0X3VudGlsIjoiMjA5OS0wMS0wMVQwNjowMDowMC4wMDBaIiwiY3JlYXRlZF9hdCI6IjIwMjQtMTItMTRUMDM6Mzg6NTIuOTg5WiIsImxpY2Vuc2VfdHlwZSI6IlBlcnNvbmFsTGljZW5zZSIsImVtYWlsIjoiMTIzIiwiaWQiOjQsImtleSI6ImU2MDVjYTU3LTIxMTUtNGIyMS1iMWVjLTY5MGY5OTdkZjM4ZSIsInN1YnNjcmlwdGlvbiI6dHJ1ZSwibWF4X2FsbG93ZWRfYXBwX3JlbGVhc2UiOm51bGx9LCJlcnJvcnMiOm51bGwsInN0YXR1cyI6MjAwfQ=="}
|
||||
9
apps/studio/tests/resources/public.pem
Normal file
9
apps/studio/tests/resources/public.pem
Normal 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-----
|
||||
188
apps/studio/tests/unit/backend/lib/OfflineLicense.spec.js
Normal file
188
apps/studio/tests/unit/backend/lib/OfflineLicense.spec.js
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,3 +38,4 @@ function hexStringToNumbers(hex: string) {
|
||||
if (hex === '') return []
|
||||
return hex.match(/.{1,2}/g).map((byte: string) => parseInt(byte, 16));
|
||||
}
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
BIN
docs/assets/images/download-key.png
Normal file
BIN
docs/assets/images/download-key.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/assets/images/offline-license.png
Normal file
BIN
docs/assets/images/offline-license.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/assets/images/trial-ended.png
Normal file
BIN
docs/assets/images/trial-ended.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -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)
|
||||
|
||||
42
docs/purchasing/license-types.md
Normal file
42
docs/purchasing/license-types.md
Normal 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)
|
||||
|
||||

|
||||
|
||||
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
|
||||

|
||||
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
|
||||

|
||||
|
||||
## 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
|
||||
@@ -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.
|
||||
|
||||
19
mkdocs.yml
19
mkdocs.yml
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user