diff --git a/packages/backend-core/src/environment.ts b/packages/backend-core/src/environment.ts index eab8cd4c45..c0785ef419 100644 --- a/packages/backend-core/src/environment.ts +++ b/packages/backend-core/src/environment.ts @@ -156,6 +156,7 @@ const environment = { : false, VERSION: findVersion(), DISABLE_PINO_LOGGER: process.env.DISABLE_PINO_LOGGER, + OFFLINE_MODE: process.env.OFFLINE_MODE, _set(key: any, value: any) { process.env[key] = value // @ts-ignore diff --git a/packages/backend-core/src/errors/errors.ts b/packages/backend-core/src/errors/errors.ts index 4e1f1abbb5..7d55d25e89 100644 --- a/packages/backend-core/src/errors/errors.ts +++ b/packages/backend-core/src/errors/errors.ts @@ -55,6 +55,18 @@ export class HTTPError extends BudibaseError { } } +export class NotFoundError extends HTTPError { + constructor(message: string) { + super(message, 404) + } +} + +export class BadRequestError extends HTTPError { + constructor(message: string) { + super(message, 400) + } +} + // LICENSING export class UsageLimitError extends HTTPError { diff --git a/packages/backend-core/src/events/identification.ts b/packages/backend-core/src/events/identification.ts index 5eb11d1354..948d3b692b 100644 --- a/packages/backend-core/src/events/identification.ts +++ b/packages/backend-core/src/events/identification.ts @@ -264,7 +264,7 @@ const getEventTenantId = async (tenantId: string): Promise => { } } -const getUniqueTenantId = async (tenantId: string): Promise => { +export const getUniqueTenantId = async (tenantId: string): Promise => { // make sure this tenantId always matches the tenantId in context return context.doInTenant(tenantId, () => { return withCache(CacheKey.UNIQUE_TENANT_ID, TTL.ONE_DAY, async () => { diff --git a/packages/backend-core/tests/core/utilities/structures/accounts.ts b/packages/backend-core/tests/core/utilities/structures/accounts.ts index 807153cd09..8476399aa3 100644 --- a/packages/backend-core/tests/core/utilities/structures/accounts.ts +++ b/packages/backend-core/tests/core/utilities/structures/accounts.ts @@ -13,7 +13,7 @@ import { } from "@budibase/types" import _ from "lodash" -export const account = (): Account => { +export const account = (partial: Partial = {}): Account => { return { accountId: uuid(), tenantId: generator.word(), @@ -29,6 +29,7 @@ export const account = (): Account => { size: "10+", profession: "Software Engineer", quotaUsage: quotas.usage(), + ...partial, } } diff --git a/packages/backend-core/tests/core/utilities/structures/db.ts b/packages/backend-core/tests/core/utilities/structures/db.ts index 31a52dce8b..87325573eb 100644 --- a/packages/backend-core/tests/core/utilities/structures/db.ts +++ b/packages/backend-core/tests/core/utilities/structures/db.ts @@ -1,4 +1,4 @@ -import { structures } from ".." +import { generator } from "./generator" import { newid } from "../../../../src/docIds/newid" export function id() { @@ -6,7 +6,7 @@ export function id() { } export function rev() { - return `${structures.generator.character({ + return `${generator.character({ numeric: true, - })}-${structures.uuid().replace(/-/, "")}` + })}-${generator.guid().replace(/-/, "")}` } diff --git a/packages/backend-core/tests/core/utilities/structures/documents/index.ts b/packages/backend-core/tests/core/utilities/structures/documents/index.ts new file mode 100644 index 0000000000..c3bfba3597 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/structures/documents/index.ts @@ -0,0 +1 @@ +export * from "./platform" diff --git a/packages/backend-core/tests/core/utilities/structures/documents/platform/index.ts b/packages/backend-core/tests/core/utilities/structures/documents/platform/index.ts new file mode 100644 index 0000000000..98a6314999 --- /dev/null +++ b/packages/backend-core/tests/core/utilities/structures/documents/platform/index.ts @@ -0,0 +1 @@ +export * as installation from "./installation" diff --git a/packages/backend-core/tests/core/utilities/structures/documents/platform/installation.ts b/packages/backend-core/tests/core/utilities/structures/documents/platform/installation.ts new file mode 100644 index 0000000000..711c6cf14f --- /dev/null +++ b/packages/backend-core/tests/core/utilities/structures/documents/platform/installation.ts @@ -0,0 +1,12 @@ +import { generator } from "../../generator" +import { Installation } from "@budibase/types" +import * as db from "../../db" + +export function install(): Installation { + return { + _id: "install", + _rev: db.rev(), + installId: generator.guid(), + version: generator.string(), + } +} diff --git a/packages/backend-core/tests/core/utilities/structures/index.ts b/packages/backend-core/tests/core/utilities/structures/index.ts index 2c094f43a7..1a49e912fc 100644 --- a/packages/backend-core/tests/core/utilities/structures/index.ts +++ b/packages/backend-core/tests/core/utilities/structures/index.ts @@ -2,6 +2,7 @@ export * from "./common" export * as accounts from "./accounts" export * as apps from "./apps" export * as db from "./db" +export * as docs from "./documents" export * as koa from "./koa" export * as licenses from "./licenses" export * as plugins from "./plugins" diff --git a/packages/backend-core/tests/core/utilities/structures/licenses.ts b/packages/backend-core/tests/core/utilities/structures/licenses.ts index 22e73f2871..5cce84edfd 100644 --- a/packages/backend-core/tests/core/utilities/structures/licenses.ts +++ b/packages/backend-core/tests/core/utilities/structures/licenses.ts @@ -3,6 +3,8 @@ import { Customer, Feature, License, + OfflineIdentifier, + OfflineLicense, PlanModel, PlanType, PriceDuration, @@ -11,6 +13,7 @@ import { Quotas, Subscription, } from "@budibase/types" +import { generator } from "./generator" export function price(): PurchasedPrice { return { @@ -127,15 +130,15 @@ export function subscription(): Subscription { } } -export const license = ( - opts: { - quotas?: Quotas - plan?: PurchasedPlan - planType?: PlanType - features?: Feature[] - billing?: Billing - } = {} -): License => { +interface GenerateLicenseOpts { + quotas?: Quotas + plan?: PurchasedPlan + planType?: PlanType + features?: Feature[] + billing?: Billing +} + +export const license = (opts: GenerateLicenseOpts = {}): License => { return { features: opts.features || [], quotas: opts.quotas || quotas(), @@ -143,3 +146,22 @@ export const license = ( billing: opts.billing || billing(), } } + +export function offlineLicense(opts: GenerateLicenseOpts = {}): OfflineLicense { + const base = license(opts) + return { + ...base, + expireAt: new Date().toISOString(), + identifier: offlineIdentifier(), + } +} + +export function offlineIdentifier( + installId: string = generator.guid(), + tenantId: string = generator.guid() +): OfflineIdentifier { + return { + installId, + tenantId, + } +} diff --git a/packages/builder/src/pages/builder/portal/account/upgrade.svelte b/packages/builder/src/pages/builder/portal/account/upgrade.svelte index f0ee87bde5..b9ce143728 100644 --- a/packages/builder/src/pages/builder/portal/account/upgrade.svelte +++ b/packages/builder/src/pages/builder/portal/account/upgrade.svelte @@ -10,6 +10,8 @@ Label, ButtonGroup, notifications, + CopyInput, + File, } from "@budibase/bbui" import { auth, admin } from "stores/portal" import { redirect } from "@roxi/routify" @@ -21,15 +23,20 @@ $: license = $auth.user.license $: upgradeUrl = `${$admin.accountPortalUrl}/portal/upgrade` + // LICENSE KEY + $: activateDisabled = !licenseKey || licenseKeyDisabled - - let licenseInfo - let licenseKeyDisabled = false let licenseKeyType = "text" let licenseKey = "" let deleteLicenseKeyModal + // OFFLINE + + let offlineLicenseIdentifier = "" + let offlineLicense = undefined + const offlineLicenseExtensions = [".txt"] + // Make sure page can't be visited directly in cloud $: { if ($admin.cloud) { @@ -37,28 +44,115 @@ } } - const activate = async () => { + // LICENSE KEY + + const getLicenseKey = async () => { try { - await API.activateLicenseKey({ licenseKey }) - await auth.getSelf() - await setLicenseInfo() - notifications.success("Successfully activated") + licenseKey = await API.getLicenseKey() + if (licenseKey) { + licenseKey = "**********************************************" + licenseKeyType = "password" + licenseKeyDisabled = true + activateDisabled = true + } } catch (e) { - notifications.error(e.message) + console.error(e) + notifications.error("Error retrieving license key") } } - const destroy = async () => { + const activateLicenseKey = async () => { + try { + await API.activateLicenseKey({ licenseKey }) + await auth.getSelf() + await getLicenseKey() + notifications.success("Successfully activated") + } catch (e) { + console.error(e) + notifications.error("Error activating license key") + } + } + + const deleteLicenseKey = async () => { try { await API.deleteLicenseKey({ licenseKey }) await auth.getSelf() - await setLicenseInfo() + await getLicenseKey() // reset the form licenseKey = "" licenseKeyDisabled = false - notifications.success("Successfully deleted") + notifications.success("Offline license removed") } catch (e) { - notifications.error(e.message) + console.error(e) + notifications.error("Error deleting license key") + } + } + + // OFFLINE LICENSE + + const getOfflineLicense = async () => { + try { + const license = await API.getOfflineLicense() + if (license) { + offlineLicense = { + name: "license", + } + } else { + offlineLicense = undefined + } + } catch (e) { + console.error(e) + notifications.error("Error loading offline license") + } + } + + const getOfflineLicenseIdentifier = async () => { + try { + const res = await API.getOfflineLicenseIdentifier() + offlineLicenseIdentifier = res.identifierBase64 + } catch (e) { + console.error(e) + notifications.error("Error loading installation identifier") + } + } + + async function activateOfflineLicense(offlineLicenseToken) { + try { + await API.activateOfflineLicense({ offlineLicenseToken }) + await auth.getSelf() + await getOfflineLicense() + notifications.success("Successfully activated") + } catch (e) { + console.error(e) + notifications.error("Error activating offline license") + } + } + + async function deleteOfflineLicense() { + try { + await API.deleteOfflineLicense() + await auth.getSelf() + await getOfflineLicense() + notifications.success("Successfully removed ofline license") + } catch (e) { + console.error(e) + notifications.error("Error upload offline license") + } + } + + async function onOfflineLicenseChange(event) { + if (event.detail) { + // prevent file preview jitter by assigning constant + // as soon as possible + offlineLicense = { + name: "license", + } + const reader = new FileReader() + reader.readAsText(event.detail) + reader.onload = () => activateOfflineLicense(reader.result) + } else { + offlineLicense = undefined + await deleteOfflineLicense() } } @@ -73,29 +167,19 @@ } } - // deactivate the license key field if there is a license key set - $: { - if (licenseInfo?.licenseKey) { - licenseKey = "**********************************************" - licenseKeyType = "password" - licenseKeyDisabled = true - activateDisabled = true - } - } - - const setLicenseInfo = async () => { - licenseInfo = await API.getLicenseInfo() - } - onMount(async () => { - await setLicenseInfo() + if ($admin.offlineMode) { + await Promise.all([getOfflineLicense(), getOfflineLicenseIdentifier()]) + } else { + await getLicenseKey() + } }) {#if $auth.isAdmin} @@ -108,42 +192,82 @@ {:else} To manage your plan visit your account +
 
{/if}
- - Activate - Enter your license key below to activate your plan - - -
-
- - + Installation identifier + Share this with support@budibase.com to obtain your offline license + + +
+ +
+
+ + + License + Upload your license to activate your plan + + +
+
-
- - - {#if licenseInfo?.licenseKey} - - {/if} - - + {#if licenseKey} + + {/if} + + + {/if} - Plan - + Plan + You are currently on the {license.plan.type} plan +
+ If you purchase or update your plan on the account + portal, click the refresh button to sync those changes +
{processStringSync("Updated {{ duration time 'millisecond' }} ago", { time: @@ -169,4 +293,7 @@ grid-gap: var(--spacing-l); align-items: center; } + .identifier-input { + width: 300px; + } diff --git a/packages/builder/src/stores/portal/admin.js b/packages/builder/src/stores/portal/admin.js index b9467fd037..2106acac27 100644 --- a/packages/builder/src/stores/portal/admin.js +++ b/packages/builder/src/stores/portal/admin.js @@ -17,6 +17,7 @@ export const DEFAULT_CONFIG = { adminUser: { checked: false }, sso: { checked: false }, }, + offlineMode: false, } export function createAdminStore() { diff --git a/packages/frontend-core/src/api/licensing.js b/packages/frontend-core/src/api/licensing.js index c27d79d740..987fc34cf5 100644 --- a/packages/frontend-core/src/api/licensing.js +++ b/packages/frontend-core/src/api/licensing.js @@ -1,30 +1,58 @@ export const buildLicensingEndpoints = API => ({ - /** - * Activates a self hosted license key - */ + // LICENSE KEY + activateLicenseKey: async data => { return API.post({ - url: `/api/global/license/activate`, + url: `/api/global/license/key`, body: data, }) }, - - /** - * Delete a self hosted license key - */ deleteLicenseKey: async () => { return API.delete({ - url: `/api/global/license/info`, + url: `/api/global/license/key`, }) }, + getLicenseKey: async () => { + try { + return await API.get({ + url: "/api/global/license/key", + }) + } catch (e) { + if (e.status !== 404) { + throw e + } + } + }, - /** - * Get the license info - metadata about the license including the - * obfuscated license key. - */ - getLicenseInfo: async () => { - return API.get({ - url: "/api/global/license/info", + // OFFLINE LICENSE + + activateOfflineLicense: async ({ offlineLicenseToken }) => { + return API.post({ + url: "/api/global/license/offline", + body: { + offlineLicenseToken, + }, + }) + }, + deleteOfflineLicense: async () => { + return API.delete({ + url: "/api/global/license/offline", + }) + }, + getOfflineLicense: async () => { + try { + return await API.get({ + url: "/api/global/license/offline", + }) + } catch (e) { + if (e.status !== 404) { + throw e + } + } + }, + getOfflineLicenseIdentifier: async () => { + return await API.get({ + url: "/api/global/license/offline/identifier", }) }, @@ -36,7 +64,6 @@ export const buildLicensingEndpoints = API => ({ url: "/api/global/license/refresh", }) }, - /** * Retrieve the usage information for the tenant */ diff --git a/packages/pro b/packages/pro index 1a5207d91f..44fa63cc0f 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit 1a5207d91fb9e0835562c708dd9c421973026543 +Subproject commit 44fa63cc0f536648ac57ba61b62c60dd2812dbc6 diff --git a/packages/server/specs/openapi.json b/packages/server/specs/openapi.json index bcff78e861..d97b09568c 100644 --- a/packages/server/specs/openapi.json +++ b/packages/server/specs/openapi.json @@ -841,7 +841,8 @@ "auto", "json", "internal", - "barcodeqr" + "barcodeqr", + "bigint" ], "description": "Defines the type of the column, most explain themselves, a link column is a relationship." }, @@ -1045,7 +1046,8 @@ "auto", "json", "internal", - "barcodeqr" + "barcodeqr", + "bigint" ], "description": "Defines the type of the column, most explain themselves, a link column is a relationship." }, @@ -1260,7 +1262,8 @@ "auto", "json", "internal", - "barcodeqr" + "barcodeqr", + "bigint" ], "description": "Defines the type of the column, most explain themselves, a link column is a relationship." }, diff --git a/packages/server/specs/openapi.yaml b/packages/server/specs/openapi.yaml index a9daed6c76..86807c9981 100644 --- a/packages/server/specs/openapi.yaml +++ b/packages/server/specs/openapi.yaml @@ -768,6 +768,7 @@ components: - json - internal - barcodeqr + - bigint description: Defines the type of the column, most explain themselves, a link column is a relationship. constraints: @@ -931,6 +932,7 @@ components: - json - internal - barcodeqr + - bigint description: Defines the type of the column, most explain themselves, a link column is a relationship. constraints: @@ -1101,6 +1103,7 @@ components: - json - internal - barcodeqr + - bigint description: Defines the type of the column, most explain themselves, a link column is a relationship. constraints: diff --git a/packages/server/src/environment.ts b/packages/server/src/environment.ts index 79ee5d977c..64b342d577 100644 --- a/packages/server/src/environment.ts +++ b/packages/server/src/environment.ts @@ -81,7 +81,6 @@ const environment = { SELF_HOSTED: process.env.SELF_HOSTED, HTTP_MB_LIMIT: process.env.HTTP_MB_LIMIT, FORKED_PROCESS_NAME: process.env.FORKED_PROCESS_NAME || "main", - OFFLINE_MODE: process.env.OFFLINE_MODE, // old CLIENT_ID: process.env.CLIENT_ID, _set(key: string, value: any) { diff --git a/packages/types/src/api/account/license.ts b/packages/types/src/api/account/license.ts index a867358559..edb1267ecf 100644 --- a/packages/types/src/api/account/license.ts +++ b/packages/types/src/api/account/license.ts @@ -1,5 +1,6 @@ import { LicenseOverrides, QuotaUsage } from "../../documents" -import { PlanType } from "../../sdk" +import { OfflineLicense, PlanType } from "../../sdk" +import { ISO8601 } from "../../shared" export interface GetLicenseRequest { // All fields should be optional to cater for @@ -26,3 +27,13 @@ export interface UpdateLicenseRequest { planType?: PlanType overrides?: LicenseOverrides } + +export interface CreateOfflineLicenseRequest { + installationIdentifierBase64: string + expireAt: ISO8601 +} + +export interface GetOfflineLicenseResponse { + offlineLicenseToken: string + license: OfflineLicense +} diff --git a/packages/types/src/api/web/global/index.ts b/packages/types/src/api/web/global/index.ts index bf4b43f0ba..efcb6dc39c 100644 --- a/packages/types/src/api/web/global/index.ts +++ b/packages/types/src/api/web/global/index.ts @@ -3,3 +3,4 @@ export * from "./auditLogs" export * from "./events" export * from "./configs" export * from "./scim" +export * from "./license" diff --git a/packages/types/src/api/web/global/license.ts b/packages/types/src/api/web/global/license.ts new file mode 100644 index 0000000000..21d8876412 --- /dev/null +++ b/packages/types/src/api/web/global/license.ts @@ -0,0 +1,25 @@ +// LICENSE KEY + +export interface ActivateLicenseKeyRequest { + licenseKey: string +} + +export interface GetLicenseKeyResponse { + licenseKey: string +} + +// OFFLINE LICENSE + +export interface ActivateOfflineLicenseTokenRequest { + offlineLicenseToken: string +} + +export interface GetOfflineLicenseTokenResponse { + offlineLicenseToken: string +} + +// IDENTIFIER + +export interface GetOfflineIdentifierResponse { + identifierBase64: string +} diff --git a/packages/types/src/documents/account/account.ts b/packages/types/src/documents/account/account.ts index dad8abed30..5321aa7e08 100644 --- a/packages/types/src/documents/account/account.ts +++ b/packages/types/src/documents/account/account.ts @@ -51,6 +51,7 @@ export interface Account extends CreateAccount { licenseRequestedAt?: number licenseOverrides?: LicenseOverrides quotaUsage?: QuotaUsage + offlineLicenseToken?: string } export interface PasswordAccount extends Account { diff --git a/packages/types/src/sdk/licensing/feature.ts b/packages/types/src/sdk/licensing/feature.ts index 1cbdd55bcf..f286a1cc44 100644 --- a/packages/types/src/sdk/licensing/feature.ts +++ b/packages/types/src/sdk/licensing/feature.ts @@ -9,6 +9,7 @@ export enum Feature { BRANDING = "branding", SCIM = "scim", SYNC_AUTOMATIONS = "syncAutomations", + OFFLINE = "offline", } export type PlanFeatures = { [key in PlanType]: Feature[] | undefined } diff --git a/packages/types/src/sdk/licensing/license.ts b/packages/types/src/sdk/licensing/license.ts index b287e67adf..105c3680a3 100644 --- a/packages/types/src/sdk/licensing/license.ts +++ b/packages/types/src/sdk/licensing/license.ts @@ -1,4 +1,15 @@ import { PurchasedPlan, Quotas, Feature, Billing } from "." +import { ISO8601 } from "../../shared" + +export interface OfflineIdentifier { + installId: string + tenantId: string +} + +export interface OfflineLicense extends License { + identifier: OfflineIdentifier + expireAt: ISO8601 +} export interface License { features: Feature[] diff --git a/packages/types/src/shared/typeUtils.ts b/packages/types/src/shared/typeUtils.ts index 71fadfc7aa..fbe215fdb9 100644 --- a/packages/types/src/shared/typeUtils.ts +++ b/packages/types/src/shared/typeUtils.ts @@ -1,3 +1,5 @@ export type DeepPartial = { [P in keyof T]?: T[P] extends object ? DeepPartial : T[P] } + +export type ISO8601 = string diff --git a/packages/worker/__mocks__/@budibase/pro.ts b/packages/worker/__mocks__/@budibase/pro.ts new file mode 100644 index 0000000000..59c7939111 --- /dev/null +++ b/packages/worker/__mocks__/@budibase/pro.ts @@ -0,0 +1,27 @@ +const actual = jest.requireActual("@budibase/pro") +const pro = { + ...actual, + licensing: { + keys: { + activateLicenseKey: jest.fn(), + getLicenseKey: jest.fn(), + deleteLicenseKey: jest.fn(), + }, + offline: { + activateOfflineLicenseToken: jest.fn(), + getOfflineLicenseToken: jest.fn(), + deleteOfflineLicenseToken: jest.fn(), + getIdentifierBase64: jest.fn(), + }, + cache: { + ...actual.licensing.cache, + refresh: jest.fn(), + }, + }, + quotas: { + ...actual.quotas, + getQuotaUsage: jest.fn(), + }, +} + +export = pro diff --git a/packages/worker/openapi-3.0.yaml b/packages/worker/openapi-3.0.yaml new file mode 100644 index 0000000000..c68654fffb --- /dev/null +++ b/packages/worker/openapi-3.0.yaml @@ -0,0 +1,133 @@ +openapi: 3.0.0 +info: + title: Worker API Specification + version: 1.0.0 +servers: + - url: "http://localhost:10000" + description: localhost + - url: "https://budibaseqa.app" + description: QA + - url: "https://preprod.qa.budibase.net" + description: Preprod + - url: "https://budibase.app" + description: Production + +tags: + - name: license + description: License operations + +paths: + /api/global/license/key: + post: + tags: + - license + summary: Activate license key + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateLicenseKeyRequest' + responses: + '200': + description: Success + get: + tags: + - license + summary: Get license key + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetLicenseKeyResponse' + delete: + tags: + - license + summary: Delete license key + responses: + '204': + description: No content + /api/global/license/offline: + post: + tags: + - license + summary: Activate offline license + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ActivateOfflineLicenseTokenRequest' + responses: + '200': + description: Success + get: + tags: + - license + summary: Get offline license + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetOfflineLicenseTokenResponse' + delete: + tags: + - license + summary: Delete offline license + responses: + '204': + description: No content + /api/global/license/offline/identifier: + get: + tags: + - license + summary: Get offline identifier + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/GetOfflineIdentifierResponse' + +components: + schemas: + ActivateOfflineLicenseTokenRequest: + type: object + properties: + offlineLicenseToken: + type: string + required: + - offlineLicenseToken + GetOfflineLicenseTokenResponse: + type: object + properties: + offlineLicenseToken: + type: string + required: + - offlineLicenseToken + ActivateLicenseKeyRequest: + type: object + properties: + licenseKey: + type: string + required: + - licenseKey + GetLicenseKeyResponse: + type: object + properties: + licenseKey: + type: string + required: + - licenseKey + GetOfflineIdentifierResponse: + type: object + properties: + identifierBase64: + type: string + required: + - identifierBase64 \ No newline at end of file diff --git a/packages/worker/src/api/controllers/global/license.ts b/packages/worker/src/api/controllers/global/license.ts index 2bd173010f..111cb5cea3 100644 --- a/packages/worker/src/api/controllers/global/license.ts +++ b/packages/worker/src/api/controllers/global/license.ts @@ -1,34 +1,83 @@ import { licensing, quotas } from "@budibase/pro" +import { + ActivateLicenseKeyRequest, + ActivateOfflineLicenseTokenRequest, + GetLicenseKeyResponse, + GetOfflineIdentifierResponse, + GetOfflineLicenseTokenResponse, + UserCtx, +} from "@budibase/types" -export const activate = async (ctx: any) => { +// LICENSE KEY + +export async function activateLicenseKey( + ctx: UserCtx +) { const { licenseKey } = ctx.request.body - if (!licenseKey) { - ctx.throw(400, "licenseKey is required") - } - - await licensing.activateLicenseKey(licenseKey) + await licensing.keys.activateLicenseKey(licenseKey) ctx.status = 200 } +export async function getLicenseKey(ctx: UserCtx) { + const licenseKey = await licensing.keys.getLicenseKey() + if (licenseKey) { + ctx.body = { licenseKey: "*" } + ctx.status = 200 + } else { + ctx.status = 404 + } +} + +export async function deleteLicenseKey(ctx: UserCtx) { + await licensing.keys.deleteLicenseKey() + ctx.status = 204 +} + +// OFFLINE LICENSE + +export async function activateOfflineLicenseToken( + ctx: UserCtx +) { + const { offlineLicenseToken } = ctx.request.body + await licensing.offline.activateOfflineLicenseToken(offlineLicenseToken) + ctx.status = 200 +} + +export async function getOfflineLicenseToken( + ctx: UserCtx +) { + const offlineLicenseToken = await licensing.offline.getOfflineLicenseToken() + if (offlineLicenseToken) { + ctx.body = { offlineLicenseToken: "*" } + ctx.status = 200 + } else { + ctx.status = 404 + } +} + +export async function deleteOfflineLicenseToken(ctx: UserCtx) { + await licensing.offline.deleteOfflineLicenseToken() + ctx.status = 204 +} + +export async function getOfflineLicenseIdentifier( + ctx: UserCtx +) { + const identifierBase64 = await licensing.offline.getIdentifierBase64() + ctx.body = { identifierBase64 } + ctx.status = 200 +} + +// LICENSES + export const refresh = async (ctx: any) => { await licensing.cache.refresh() ctx.status = 200 } -export const getInfo = async (ctx: any) => { - const licenseInfo = await licensing.getLicenseInfo() - if (licenseInfo) { - licenseInfo.licenseKey = "*" - ctx.body = licenseInfo - } - ctx.status = 200 -} - -export const deleteInfo = async (ctx: any) => { - await licensing.deleteLicenseInfo() - ctx.status = 200 -} +// USAGE export const getQuotaUsage = async (ctx: any) => { ctx.body = await quotas.getQuotaUsage() + ctx.status = 200 } diff --git a/packages/worker/src/api/controllers/system/environment.ts b/packages/worker/src/api/controllers/system/environment.ts index f3f917c2dd..729a00fa88 100644 --- a/packages/worker/src/api/controllers/system/environment.ts +++ b/packages/worker/src/api/controllers/system/environment.ts @@ -1,10 +1,11 @@ import { Ctx } from "@budibase/types" import env from "../../../environment" +import { env as coreEnv } from "@budibase/backend-core" export const fetch = async (ctx: Ctx) => { ctx.body = { multiTenancy: !!env.MULTI_TENANCY, - offlineMode: !!env.OFFLINE_MODE, + offlineMode: !!coreEnv.OFFLINE_MODE, cloud: !env.SELF_HOSTED, accountPortalUrl: env.ACCOUNT_PORTAL_URL, disableAccountPortal: env.DISABLE_ACCOUNT_PORTAL, diff --git a/packages/worker/src/api/routes/global/license.ts b/packages/worker/src/api/routes/global/license.ts index 0fb2a6e8bd..b0e474e4d5 100644 --- a/packages/worker/src/api/routes/global/license.ts +++ b/packages/worker/src/api/routes/global/license.ts @@ -1,13 +1,44 @@ import Router from "@koa/router" import * as controller from "../../controllers/global/license" +import { middleware } from "@budibase/backend-core" +import Joi from "joi" + +const activateLicenseKeyValidator = middleware.joiValidator.body( + Joi.object({ + licenseKey: Joi.string().required(), + }).required() +) + +const activateOfflineLicenseValidator = middleware.joiValidator.body( + Joi.object({ + offlineLicenseToken: Joi.string().required(), + }).required() +) const router: Router = new Router() router - .post("/api/global/license/activate", controller.activate) .post("/api/global/license/refresh", controller.refresh) - .get("/api/global/license/info", controller.getInfo) - .delete("/api/global/license/info", controller.deleteInfo) .get("/api/global/license/usage", controller.getQuotaUsage) + // LICENSE KEY + .post( + "/api/global/license/key", + activateLicenseKeyValidator, + controller.activateLicenseKey + ) + .get("/api/global/license/key", controller.getLicenseKey) + .delete("/api/global/license/key", controller.deleteLicenseKey) + // OFFLINE LICENSE + .post( + "/api/global/license/offline", + activateOfflineLicenseValidator, + controller.activateOfflineLicenseToken + ) + .get("/api/global/license/offline", controller.getOfflineLicenseToken) + .delete("/api/global/license/offline", controller.deleteOfflineLicenseToken) + .get( + "/api/global/license/offline/identifier", + controller.getOfflineLicenseIdentifier + ) export default router diff --git a/packages/worker/src/api/routes/global/tests/license.spec.ts b/packages/worker/src/api/routes/global/tests/license.spec.ts index be0673729e..26e3b3dfb5 100644 --- a/packages/worker/src/api/routes/global/tests/license.spec.ts +++ b/packages/worker/src/api/routes/global/tests/license.spec.ts @@ -1,4 +1,6 @@ -import { TestConfiguration } from "../../../../tests" +import { TestConfiguration, mocks, structures } from "../../../../tests" +const licensing = mocks.pro.licensing +const quotas = mocks.pro.quotas describe("/api/global/license", () => { const config = new TestConfiguration() @@ -12,18 +14,105 @@ describe("/api/global/license", () => { }) afterEach(() => { - jest.clearAllMocks() + jest.resetAllMocks() }) - describe("POST /api/global/license/activate", () => { - it("activates license", () => {}) + describe("POST /api/global/license/refresh", () => { + it("returns 200", async () => { + const res = await config.api.license.refresh() + expect(res.status).toBe(200) + expect(licensing.cache.refresh).toBeCalledTimes(1) + }) }) - describe("POST /api/global/license/refresh", () => {}) + describe("GET /api/global/license/usage", () => { + it("returns 200 + usage", async () => { + const usage = structures.quotas.usage() + quotas.getQuotaUsage.mockResolvedValue(usage) + const res = await config.api.license.getUsage() + expect(res.status).toBe(200) + expect(res.body).toEqual(usage) + }) + }) - describe("GET /api/global/license/info", () => {}) + describe("POST /api/global/license/key", () => { + it("returns 200", async () => { + const res = await config.api.license.activateLicenseKey({ + licenseKey: "licenseKey", + }) + expect(res.status).toBe(200) + expect(licensing.keys.activateLicenseKey).toBeCalledWith("licenseKey") + }) + }) - describe("DELETE /api/global/license/info", () => {}) + describe("GET /api/global/license/key", () => { + it("returns 404 when not found", async () => { + const res = await config.api.license.getLicenseKey() + expect(res.status).toBe(404) + }) + it("returns 200 + license key", async () => { + licensing.keys.getLicenseKey.mockResolvedValue("licenseKey") + const res = await config.api.license.getLicenseKey() + expect(res.status).toBe(200) + expect(res.body).toEqual({ + licenseKey: "*", + }) + }) + }) - describe("GET /api/global/license/usage", () => {}) + describe("DELETE /api/global/license/key", () => { + it("returns 204", async () => { + const res = await config.api.license.deleteLicenseKey() + expect(licensing.keys.deleteLicenseKey).toBeCalledTimes(1) + expect(res.status).toBe(204) + }) + }) + + describe("POST /api/global/license/offline", () => { + it("activates offline license", async () => { + const res = await config.api.license.activateOfflineLicense({ + offlineLicenseToken: "offlineLicenseToken", + }) + expect(licensing.offline.activateOfflineLicenseToken).toBeCalledWith( + "offlineLicenseToken" + ) + expect(res.status).toBe(200) + }) + }) + + describe("GET /api/global/license/offline", () => { + it("returns 404 when not found", async () => { + const res = await config.api.license.getOfflineLicense() + expect(res.status).toBe(404) + }) + it("returns 200 + offline license token", async () => { + licensing.offline.getOfflineLicenseToken.mockResolvedValue( + "offlineLicenseToken" + ) + const res = await config.api.license.getOfflineLicense() + expect(res.status).toBe(200) + expect(res.body).toEqual({ + offlineLicenseToken: "*", + }) + }) + }) + + describe("DELETE /api/global/license/offline", () => { + it("returns 204", async () => { + const res = await config.api.license.deleteOfflineLicense() + expect(res.status).toBe(204) + expect(licensing.offline.deleteOfflineLicenseToken).toBeCalledTimes(1) + }) + }) + + describe("GET /api/global/license/offline/identifier", () => { + it("returns 200 + identifier base64", async () => { + licensing.offline.getIdentifierBase64.mockResolvedValue("base64") + const res = await config.api.license.getOfflineLicenseIdentifier() + expect(res.status).toBe(200) + expect(res.body).toEqual({ + identifierBase64: "base64", + }) + }) + }) }) diff --git a/packages/worker/src/environment.ts b/packages/worker/src/environment.ts index 74d58831d9..678ffe7f14 100644 --- a/packages/worker/src/environment.ts +++ b/packages/worker/src/environment.ts @@ -61,7 +61,6 @@ const environment = { CHECKLIST_CACHE_TTL: parseIntSafe(process.env.CHECKLIST_CACHE_TTL) || 3600, SESSION_UPDATE_PERIOD: process.env.SESSION_UPDATE_PERIOD, ENCRYPTED_TEST_PUBLIC_API_KEY: process.env.ENCRYPTED_TEST_PUBLIC_API_KEY, - OFFLINE_MODE: process.env.OFFLINE_MODE, /** * Mock the email service in use - links to ethereal hosted emails are logged instead. */ diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts index a79ac0e189..b41b76efda 100644 --- a/packages/worker/src/tests/TestConfiguration.ts +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -1,8 +1,7 @@ import mocks from "./mocks" // init the licensing mock -import * as pro from "@budibase/pro" -mocks.licenses.init(pro) +mocks.licenses.init(mocks.pro) // use unlimited license by default mocks.licenses.useUnlimited() @@ -238,21 +237,21 @@ class TestConfiguration { const db = context.getGlobalDB() - const id = dbCore.generateDevInfoID(this.user._id) + const id = dbCore.generateDevInfoID(this.user!._id) // TODO: dry this.apiKey = encryption.encrypt( `${this.tenantId}${dbCore.SEPARATOR}${utils.newid()}` ) const devInfo = { _id: id, - userId: this.user._id, + userId: this.user!._id, apiKey: this.apiKey, } await db.put(devInfo) }) } - async getUser(email: string): Promise { + async getUser(email: string): Promise { return context.doInTenant(this.getTenantId(), () => { return users.getGlobalUserByEmail(email) }) @@ -264,7 +263,7 @@ class TestConfiguration { } const response = await this._req(user, null, controllers.users.save) const body = response as SaveUserResponse - return this.getUser(body.email) + return this.getUser(body.email) as Promise } // CONFIGS diff --git a/packages/worker/src/tests/api/license.ts b/packages/worker/src/tests/api/license.ts index 9d7745a80e..a6645226af 100644 --- a/packages/worker/src/tests/api/license.ts +++ b/packages/worker/src/tests/api/license.ts @@ -1,17 +1,62 @@ import TestConfiguration from "../TestConfiguration" import { TestAPI } from "./base" +import { + ActivateLicenseKeyRequest, + ActivateOfflineLicenseTokenRequest, +} from "@budibase/types" export class LicenseAPI extends TestAPI { constructor(config: TestConfiguration) { super(config) } - activate = async (licenseKey: string) => { + refresh = async () => { return this.request - .post("/api/global/license/activate") - .send({ licenseKey: licenseKey }) + .post("/api/global/license/refresh") + .set(this.config.defaultHeaders()) + } + getUsage = async () => { + return this.request + .get("/api/global/license/usage") .set(this.config.defaultHeaders()) .expect("Content-Type", /json/) .expect(200) } + activateLicenseKey = async (body: ActivateLicenseKeyRequest) => { + return this.request + .post("/api/global/license/key") + .send(body) + .set(this.config.defaultHeaders()) + } + getLicenseKey = async () => { + return this.request + .get("/api/global/license/key") + .set(this.config.defaultHeaders()) + } + deleteLicenseKey = async () => { + return this.request + .delete("/api/global/license/key") + .set(this.config.defaultHeaders()) + } + activateOfflineLicense = async (body: ActivateOfflineLicenseTokenRequest) => { + return this.request + .post("/api/global/license/offline") + .send(body) + .set(this.config.defaultHeaders()) + } + getOfflineLicense = async () => { + return this.request + .get("/api/global/license/offline") + .set(this.config.defaultHeaders()) + } + deleteOfflineLicense = async () => { + return this.request + .delete("/api/global/license/offline") + .set(this.config.defaultHeaders()) + } + getOfflineLicenseIdentifier = async () => { + return this.request + .get("/api/global/license/offline/identifier") + .set(this.config.defaultHeaders()) + } } diff --git a/packages/worker/src/tests/mocks/index.ts b/packages/worker/src/tests/mocks/index.ts index 30bb4e1d09..cab019bb46 100644 --- a/packages/worker/src/tests/mocks/index.ts +++ b/packages/worker/src/tests/mocks/index.ts @@ -1,7 +1,11 @@ import * as email from "./email" import { mocks } from "@budibase/backend-core/tests" +import * as _pro from "@budibase/pro" +const pro = jest.mocked(_pro, true) + export default { email, + pro, ...mocks, } diff --git a/qa-core/scripts/testResultsWebhook.js b/qa-core/scripts/testResultsWebhook.js index 40cc42082d..5fbdd3a32e 100644 --- a/qa-core/scripts/testResultsWebhook.js +++ b/qa-core/scripts/testResultsWebhook.js @@ -42,7 +42,7 @@ async function discordResultsNotification(report) { Accept: "application/json", }, body: JSON.stringify({ - content: `**Nightly Tests Status**: ${OUTCOME}`, + content: `**Tests Status**: ${OUTCOME}`, embeds: [ { title: `Budi QA Bot - ${env}`,