diff --git a/packages/backend-core/src/db/utils.js b/packages/backend-core/src/db/utils.js index c4dcb8248b..310768ab0f 100644 --- a/packages/backend-core/src/db/utils.js +++ b/packages/backend-core/src/db/utils.js @@ -30,6 +30,7 @@ const UNICODE_MAX = "\ufff0" exports.ViewNames = { USER_BY_EMAIL: "by_email", + USER_BY_BUILDERS: "by_builders", } exports.StaticDatabases = StaticDatabases diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index fd004ca0c2..9ea2bfaab0 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -31,3 +31,25 @@ exports.createUserEmailView = async db => { } await db.put(designDoc) } + +exports.createUserBuildersView = async db => { + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + map: `function(doc) { + if (doc.builder && doc.builder.global === true) { + emit(doc._id, doc._id) + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewNames.USER_BY_BUILDERS]: view, + } + await db.put(designDoc) +} diff --git a/packages/backend-core/src/errors/base.js b/packages/backend-core/src/errors/base.js new file mode 100644 index 0000000000..d31f9838f4 --- /dev/null +++ b/packages/backend-core/src/errors/base.js @@ -0,0 +1,11 @@ +class BudibaseError extends Error { + constructor(message, type, code) { + super(message) + this.type = type + this.code = code + } +} + +module.exports = { + BudibaseError, +} diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.js new file mode 100644 index 0000000000..139dd5a5a1 --- /dev/null +++ b/packages/backend-core/src/errors/index.js @@ -0,0 +1,15 @@ +const licensing = require("./licensing") + +const codes = { + ...licensing.codes, +} + +const types = { + ...licensing.types, +} + +module.exports = { + codes, + types, + UsageLimitError: licensing.UsageLimitError, +} diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js new file mode 100644 index 0000000000..111f21ad46 --- /dev/null +++ b/packages/backend-core/src/errors/licensing.js @@ -0,0 +1,22 @@ +const { BudibaseError } = require("./base") + +const types = { + LICENSE_ERROR: "license_error", +} + +const codes = { + USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", +} + +class UsageLimitError extends BudibaseError { + constructor(message, limitName) { + super(message, types.LICENSE_ERROR, codes.USAGE_LIMIT_EXCEEDED) + this.limitName = limitName + } +} + +module.exports = { + types, + codes, + UsageLimitError, +} diff --git a/packages/backend-core/src/index.js b/packages/backend-core/src/index.js index 5b7a0fcd16..8c453caf07 100644 --- a/packages/backend-core/src/index.js +++ b/packages/backend-core/src/index.js @@ -16,4 +16,5 @@ module.exports = { constants: require("../constants"), migrations: require("../migrations"), licensing: require("./licensing"), + errors: require("./errors"), } diff --git a/packages/backend-core/src/licensing/cache/index.js b/packages/backend-core/src/licensing/cache/index.js index 6b11c0cda5..b1a0215852 100644 --- a/packages/backend-core/src/licensing/cache/index.js +++ b/packages/backend-core/src/licensing/cache/index.js @@ -29,7 +29,7 @@ exports.getLicense = async (tenantId, opts = { populateLicense: null }) => { return license } -exports.invalidateLicense = async tenantId => { +exports.invalidate = async tenantId => { const client = await redis.getClient() await client.delete(tenantId) } diff --git a/packages/backend-core/src/licensing/index.js b/packages/backend-core/src/licensing/index.js index cf29c8e51a..90a88741e7 100644 --- a/packages/backend-core/src/licensing/index.js +++ b/packages/backend-core/src/licensing/index.js @@ -1,7 +1,9 @@ const middleware = require("./middleware") const cache = require("./cache") +const usage = require("./usage") module.exports = { middleware, cache, + usage, } diff --git a/packages/backend-core/src/licensing/usage/index.js b/packages/backend-core/src/licensing/usage/index.js new file mode 100644 index 0000000000..58e5aadd7f --- /dev/null +++ b/packages/backend-core/src/licensing/usage/index.js @@ -0,0 +1,41 @@ +const { getTenantId } = require("../../context") +const cache = require("../cache") +const env = require("../../environment") +const utils = require("../../utils") +const { UsageLimitError } = require("../../errors") + +const UsageType = { + MONTHLY: "monthly", + STATIC: "static", +} + +const StaticUsageLimits = { + MAX_DEVELOPERS: "maxDevelopers", + QUERY_TIMEOUT_SECONDS: "queryTimeoutSeconds", +} + +// eslint-disable-next-line no-unused-vars +const MonthlyUsageLimits = { + MAX_QUERY_COUNT: "maxQueryCount", +} + +exports.checkMaxDevelopers = async () => { + const developerCount = await utils.getBuildersCount() + await checkUsageLimit( + developerCount, + UsageType.STATIC, + StaticUsageLimits.MAX_DEVELOPERS + ) +} + +const checkUsageLimit = async (currentUsage, usageType, usageLimit) => { + const tenantId = getTenantId() + const license = await cache.getLicense(tenantId) + const limit = license.usageLimits[usageType][usageLimit] + if (currentUsage >= limit.value) { + throw new UsageLimitError( + `Licensed ${limit.name} has been exceeded. To upgrade, visit ${env.ACCOUNT_PORTAL_URL}/portal/plans`, + limit.name + ) + } +} diff --git a/packages/backend-core/src/utils.js b/packages/backend-core/src/utils.js index c653ce7b52..3adfe4ade0 100644 --- a/packages/backend-core/src/utils.js +++ b/packages/backend-core/src/utils.js @@ -6,7 +6,7 @@ const { } = require("./db/utils") const jwt = require("jsonwebtoken") const { options } = require("./middleware/passport/jwt") -const { createUserEmailView } = require("./db/views") +const { createUserEmailView, createUserBuildersView } = require("./db/views") const { Headers, UserStatus, Cookies, MAX_VALID_DATE } = require("./constants") const { getGlobalDB, @@ -20,6 +20,7 @@ const { hash } = require("./hashing") const userCache = require("./cache/user") const env = require("./environment") const { getUserSessions, invalidateSessions } = require("./security/sessions") +const { usage } = require("./licensing") const APP_PREFIX = DocumentTypes.APP + SEPARATOR @@ -160,12 +161,33 @@ exports.getGlobalUserByEmail = async email => { } } +exports.getBuildersCount = async () => { + const db = getGlobalDB() + try { + let users = await db.query(`database/${ViewNames.USER_BY_BUILDERS}`) + return users.total_rows + } catch (err) { + if (err != null && err.name === "not_found") { + await createUserBuildersView(db) + return exports.getBuildersCount() + } else { + throw err + } + } +} + exports.saveUser = async ( user, tenantId, hashPassword = true, requirePassword = true ) => { + // new user + // check license restrictions + if (!user._id && user.builder) { + await usage.checkMaxDevelopers() + } + if (!tenantId) { throw "No tenancy specified." } diff --git a/packages/builder/src/pages/builder/invite/index.svelte b/packages/builder/src/pages/builder/invite/index.svelte index c4745d8737..8ac35de07f 100644 --- a/packages/builder/src/pages/builder/invite/index.svelte +++ b/packages/builder/src/pages/builder/invite/index.svelte @@ -14,7 +14,7 @@ notifications.success("Invitation accepted successfully") $goto("../auth/login") } catch (error) { - notifications.error("Error accepting invitation") + notifications.error(error.message) } } diff --git a/packages/builder/src/pages/builder/portal/manage/users/_components/BasicOnboardingModal.svelte b/packages/builder/src/pages/builder/portal/manage/users/_components/BasicOnboardingModal.svelte index 29e2d56ed0..a8345394d6 100644 --- a/packages/builder/src/pages/builder/portal/manage/users/_components/BasicOnboardingModal.svelte +++ b/packages/builder/src/pages/builder/portal/manage/users/_components/BasicOnboardingModal.svelte @@ -26,7 +26,7 @@ }) notifications.success("Successfully created user") } catch (error) { - notifications.error("Error creating user") + notifications.error(error.message) } } diff --git a/packages/server/src/migrations/functions/userBuildersView.ts b/packages/server/src/migrations/functions/userBuildersView.ts new file mode 100644 index 0000000000..b46ddb1285 --- /dev/null +++ b/packages/server/src/migrations/functions/userBuildersView.ts @@ -0,0 +1,13 @@ +const { createUserBuildersView } = require("@budibase/backend-core/db") + +/** + * Date: + * March 2022 + * + * Description: + * Create the builder users view. + */ + +export const run = async (db: any) => { + await createUserBuildersView(db) +} diff --git a/packages/server/src/migrations/index.ts b/packages/server/src/migrations/index.ts index 966041e0c9..4dc92786b7 100644 --- a/packages/server/src/migrations/index.ts +++ b/packages/server/src/migrations/index.ts @@ -8,6 +8,7 @@ const { import * as userEmailViewCasing from "./functions/userEmailViewCasing" import * as quota1 from "./functions/quotas1" import * as appUrls from "./functions/appUrls" +import * as userBuildersView from "./functions/userBuildersView" export interface Migration { type: string @@ -49,6 +50,11 @@ export const MIGRATIONS: Migration[] = [ opts: { all: true }, fn: appUrls.run, }, + { + type: MIGRATION_TYPES.GLOBAL, + name: "user_builders_view", + fn: userBuildersView.run, + }, ] export const migrate = async (options?: MigrationOptions) => { diff --git a/packages/server/yarn.lock b/packages/server/yarn.lock index a45a05d9d3..31c88d99bf 100644 --- a/packages/server/yarn.lock +++ b/packages/server/yarn.lock @@ -2494,6 +2494,13 @@ "@types/node" "*" safe-buffer "*" +"@types/redis@^2.8.0": + version "2.8.32" + resolved "https://registry.yarnpkg.com/@types/redis/-/redis-2.8.32.tgz#1d3430219afbee10f8cfa389dad2571a05ecfb11" + integrity sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w== + dependencies: + "@types/node" "*" + "@types/serve-static@*": version "1.13.10" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" diff --git a/packages/worker/src/api/controllers/global/users.js b/packages/worker/src/api/controllers/global/users.js index 8acce2a481..77b4bdcd70 100644 --- a/packages/worker/src/api/controllers/global/users.js +++ b/packages/worker/src/api/controllers/global/users.js @@ -24,6 +24,7 @@ const { const { removeUserFromInfoDB } = require("@budibase/backend-core/deprovision") const env = require("../../../environment") const { syncUserInApps } = require("../../../utilities/appService") +const { errors } = require("@budibase/backend-core") async function allUsers() { const db = getGlobalDB() @@ -295,6 +296,10 @@ exports.inviteAccept = async ctx => { info.tenantId ) } catch (err) { + if (err.code === errors.codes.USAGE_LIMIT_EXCEEDED) { + // explicitly re-throw limit exceeded errors + ctx.throw(400, err) + } ctx.throw(400, "Unable to create new user, invitation invalid.") } } diff --git a/packages/worker/src/api/index.js b/packages/worker/src/api/index.js index 2817da685f..1a0d7e7971 100644 --- a/packages/worker/src/api/index.js +++ b/packages/worker/src/api/index.js @@ -9,6 +9,7 @@ const { buildCsrfMiddleware, } = require("@budibase/backend-core/auth") const { licensing } = require("@budibase/backend-core") +const { errors } = require("@budibase/backend-core") const PUBLIC_ENDPOINTS = [ // old deprecated endpoints kept for backwards compat @@ -105,16 +106,32 @@ router }) .use(auditLog) -// error handling middleware +// error handling middleware - TODO: This could be moved to backend-core router.use(async (ctx, next) => { try { await next() } catch (err) { ctx.log.error(err) ctx.status = err.status || err.statusCode || 500 + + let error + if (err.code || err.type) { + // add generic error information + error = { + code: err.code, + type: err.type, + } + + // add specific error information + if (error.code === errors.codes.USAGE_LIMIT_EXCEEDED) { + error.limitName = err.limitName + } + } + ctx.body = { message: err.message, status: ctx.status, + error, } } })