diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b4f7739293..457d2c1451 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -31,6 +31,9 @@ A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. + +**App Export** +If possible - please attach an export of your budibase application for debugging/reproduction purposes. **Desktop (please complete the following information):** - OS: [e.g. iOS] diff --git a/hosting/single/Dockerfile b/hosting/single/Dockerfile index 4e3239d960..b5bf17adde 100644 --- a/hosting/single/Dockerfile +++ b/hosting/single/Dockerfile @@ -108,7 +108,7 @@ RUN chmod +x install.sh && ./install.sh WORKDIR / ADD hosting/single/runner.sh . RUN chmod +x ./runner.sh -ADD hosting/scripts/healthcheck.sh . +ADD hosting/single/healthcheck.sh . RUN chmod +x ./healthcheck.sh ADD hosting/scripts/build-target-paths.sh . diff --git a/hosting/scripts/healthcheck.sh b/hosting/single/healthcheck.sh similarity index 93% rename from hosting/scripts/healthcheck.sh rename to hosting/single/healthcheck.sh index 80f2ece0b6..b92cd153a3 100644 --- a/hosting/scripts/healthcheck.sh +++ b/hosting/single/healthcheck.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash healthy=true +if [ -f "/data/.env" ]; then + export $(cat /data/.env | xargs) +fi + if [[ $(curl -Lfk -s -w "%{http_code}\n" http://localhost/ -o /dev/null) -ne 200 ]]; then echo 'ERROR: Budibase is not running'; healthy=false diff --git a/lerna.json b/lerna.json index e2850810c5..0acb68386d 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.1.18-alpha.0", + "version": "1.1.29-alpha.0", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index fc29e8a060..2e1df9e1c3 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/backend-core", - "version": "1.1.18-alpha.0", + "version": "1.1.29-alpha.0", "description": "Budibase backend core libraries used in server and worker", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -20,7 +20,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/types": "^1.1.18-alpha.0", + "@budibase/types": "^1.1.29-alpha.0", "@techpass/passport-openidconnect": "0.3.2", "aws-sdk": "2.1030.0", "bcrypt": "5.0.1", diff --git a/packages/backend-core/src/auth.js b/packages/backend-core/src/auth.js index b60144a0de..9ae29a3cbd 100644 --- a/packages/backend-core/src/auth.js +++ b/packages/backend-core/src/auth.js @@ -18,6 +18,8 @@ const { ssoCallbackUrl, csrf, internalApi, + adminOnly, + joiValidator, } = require("./middleware") const { invalidateUser } = require("./cache/user") @@ -173,4 +175,6 @@ module.exports = { refreshOAuthToken, updateUserOAuth, ssoCallbackUrl, + adminOnly, + joiValidator, } diff --git a/packages/backend-core/src/cache/writethrough.ts b/packages/backend-core/src/cache/writethrough.ts index e11ca0acaa..ec6b1604c8 100644 --- a/packages/backend-core/src/cache/writethrough.ts +++ b/packages/backend-core/src/cache/writethrough.ts @@ -1,5 +1,6 @@ import BaseCache from "./base" import { getWritethroughClient } from "../redis/init" +import { logWarn } from "../logging" const DEFAULT_WRITE_RATE_MS = 10000 let CACHE: BaseCache | null = null @@ -51,10 +52,8 @@ export async function put( if (err.status !== 409) { throw err } else { - // get the rev, update over it - this is risky, may change in future - const readDoc = await db.get(doc._id) - doc._rev = readDoc._rev - await writeDb(doc) + // Swallow 409s but log them + logWarn(`Ignoring conflict in write-through cache`) } } } diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index 716762dd45..9c6be25424 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -11,6 +11,7 @@ export enum AutomationViewModes { } export enum ViewNames { + USER_BY_APP = "by_app", USER_BY_EMAIL = "by_email2", BY_API_KEY = "by_api_key", USER_BY_BUILDERS = "by_builders", @@ -28,6 +29,7 @@ export const DeprecatedViews = { export enum DocumentTypes { USER = "us", + GROUP = "gr", WORKSPACE = "workspace", CONFIG = "config", TEMPLATE = "template", diff --git a/packages/backend-core/src/db/conversions.js b/packages/backend-core/src/db/conversions.js index 455cc712d8..90c04e9251 100644 --- a/packages/backend-core/src/db/conversions.js +++ b/packages/backend-core/src/db/conversions.js @@ -50,3 +50,8 @@ exports.getProdAppID = appId => { const rest = split.join(APP_DEV_PREFIX) return `${APP_PREFIX}${rest}` } + +exports.extractAppUUID = id => { + const split = id?.split("_") || [] + return split.length ? split[split.length - 1] : null +} diff --git a/packages/backend-core/src/db/utils.ts b/packages/backend-core/src/db/utils.ts index ba3f1dd3e9..8ab6fa6e98 100644 --- a/packages/backend-core/src/db/utils.ts +++ b/packages/backend-core/src/db/utils.ts @@ -8,7 +8,7 @@ import { doWithDB, allDbs } from "./index" import { getCouchInfo } from "./pouch" import { getAppMetadata } from "../cache/appMetadata" import { checkSlashesInUrl } from "../helpers" -import { isDevApp, isDevAppID } from "./conversions" +import { isDevApp, isDevAppID, getProdAppID } from "./conversions" import { APP_PREFIX } from "./constants" import * as events from "../events" @@ -107,6 +107,15 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) { } } +export function getUsersByAppParams(appId: any, otherProps: any = {}) { + const prodAppId = getProdAppID(appId) + return { + ...otherProps, + startkey: prodAppId, + endkey: `${prodAppId}${UNICODE_MAX}`, + } +} + /** * Generates a template ID. * @param ownerId The owner/user of the template, this could be global or a workspace level. @@ -115,6 +124,10 @@ export function generateTemplateID(ownerId: any) { return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}` } +export function generateAppUserID(prodAppId: string, userId: string) { + return `${prodAppId}${SEPARATOR}${userId}` +} + /** * Gets parameters for retrieving templates. Owner ID must be specified, either global or a workspace level. */ @@ -442,15 +455,29 @@ export const getPlatformUrl = async (opts = { tenantAware: true }) => { export function pagination( data: any[], pageSize: number, - { paginate, property } = { paginate: true, property: "_id" } + { + paginate, + property, + getKey, + }: { + paginate: boolean + property: string + getKey?: (doc: any) => string | undefined + } = { + paginate: true, + property: "_id", + } ) { if (!paginate) { return { data, hasNextPage: false } } const hasNextPage = data.length > pageSize let nextPage = undefined + if (!getKey) { + getKey = (doc: any) => (property ? doc?.[property] : doc?._id) + } if (hasNextPage) { - nextPage = property ? data[pageSize]?.[property] : data[pageSize]?._id + nextPage = getKey(data[pageSize]) } return { data: data.slice(0, pageSize), diff --git a/packages/backend-core/src/db/views.js b/packages/backend-core/src/db/views.js index 1e8dd7ee77..baf1807ca5 100644 --- a/packages/backend-core/src/db/views.js +++ b/packages/backend-core/src/db/views.js @@ -56,6 +56,33 @@ exports.createNewUserEmailView = async () => { await db.put(designDoc) } +exports.createUserAppView = async () => { + const db = getGlobalDB() + let designDoc + try { + designDoc = await db.get("_design/database") + } catch (err) { + // no design doc, make one + designDoc = DesignDoc() + } + const view = { + // if using variables in a map function need to inject them before use + map: `function(doc) { + if (doc._id.startsWith("${DocumentTypes.USER}${SEPARATOR}") && doc.roles) { + for (let prodAppId of Object.keys(doc.roles)) { + let emitted = prodAppId + "${SEPARATOR}" + doc._id + emit(emitted, null) + } + } + }`, + } + designDoc.views = { + ...designDoc.views, + [ViewNames.USER_BY_APP]: view, + } + await db.put(designDoc) +} + exports.createApiKeyView = async () => { const db = getGlobalDB() let designDoc @@ -106,6 +133,7 @@ exports.queryGlobalView = async (viewName, params, db = null) => { [ViewNames.USER_BY_EMAIL]: exports.createNewUserEmailView, [ViewNames.BY_API_KEY]: exports.createApiKeyView, [ViewNames.USER_BY_BUILDERS]: exports.createUserBuildersView, + [ViewNames.USER_BY_APP]: exports.createUserAppView, } // can pass DB in if working with something specific if (!db) { diff --git a/packages/backend-core/src/errors/index.js b/packages/backend-core/src/errors/index.js index 58b4eea8c5..31ffd739a0 100644 --- a/packages/backend-core/src/errors/index.js +++ b/packages/backend-core/src/errors/index.js @@ -37,6 +37,7 @@ module.exports = { types, errors: { UsageLimitError: licensing.UsageLimitError, + FeatureDisabledError: licensing.FeatureDisabledError, HTTPError: http.HTTPError, }, getPublicError, diff --git a/packages/backend-core/src/errors/licensing.js b/packages/backend-core/src/errors/licensing.js index 0d8ce08146..85d207ac35 100644 --- a/packages/backend-core/src/errors/licensing.js +++ b/packages/backend-core/src/errors/licensing.js @@ -4,6 +4,7 @@ const type = "license_error" const codes = { USAGE_LIMIT_EXCEEDED: "usage_limit_exceeded", + FEATURE_DISABLED: "feature_disabled", } const context = { @@ -12,6 +13,11 @@ const context = { limitName: err.limitName, } }, + [codes.FEATURE_DISABLED]: err => { + return { + featureName: err.featureName, + } + }, } class UsageLimitError extends HTTPError { @@ -21,9 +27,17 @@ class UsageLimitError extends HTTPError { } } +class FeatureDisabledError extends HTTPError { + constructor(message, featureName) { + super(message, 400, codes.FEATURE_DISABLED, type) + this.featureName = featureName + } +} + module.exports = { type, codes, context, UsageLimitError, + FeatureDisabledError, } diff --git a/packages/backend-core/src/events/publishers/group.ts b/packages/backend-core/src/events/publishers/group.ts new file mode 100644 index 0000000000..d300873725 --- /dev/null +++ b/packages/backend-core/src/events/publishers/group.ts @@ -0,0 +1,64 @@ +import { publishEvent } from "../events" +import { + Event, + UserGroup, + GroupCreatedEvent, + GroupDeletedEvent, + GroupUpdatedEvent, + GroupUsersAddedEvent, + GroupUsersDeletedEvent, + GroupAddedOnboardingEvent, + UserGroupRoles, +} from "@budibase/types" + +export async function created(group: UserGroup, timestamp?: number) { + const properties: GroupCreatedEvent = { + groupId: group._id as string, + } + await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp) +} + +export async function updated(group: UserGroup) { + const properties: GroupUpdatedEvent = { + groupId: group._id as string, + } + await publishEvent(Event.USER_GROUP_UPDATED, properties) +} + +export async function deleted(group: UserGroup) { + const properties: GroupDeletedEvent = { + groupId: group._id as string, + } + await publishEvent(Event.USER_GROUP_DELETED, properties) +} + +export async function usersAdded(count: number, group: UserGroup) { + const properties: GroupUsersAddedEvent = { + count, + groupId: group._id as string, + } + await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) +} + +export async function usersDeleted(emails: string[], group: UserGroup) { + const properties: GroupUsersDeletedEvent = { + count: emails.length, + groupId: group._id as string, + } + await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) +} + +export async function createdOnboarding(groupId: string) { + const properties: GroupAddedOnboardingEvent = { + groupId: groupId, + onboarding: true, + } + await publishEvent(Event.USER_GROUP_ONBOARDING, properties) +} + +export async function permissionsEdited(roles: UserGroupRoles) { + const properties: UserGroupRoles = { + ...roles, + } + await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties) +} diff --git a/packages/backend-core/src/events/publishers/index.ts b/packages/backend-core/src/events/publishers/index.ts index 65785d4d8b..57fd0bf8e2 100644 --- a/packages/backend-core/src/events/publishers/index.ts +++ b/packages/backend-core/src/events/publishers/index.ts @@ -17,3 +17,4 @@ export * as user from "./user" export * as view from "./view" export * as installation from "./installation" export * as backfill from "./backfill" +export * as group from "./group" diff --git a/packages/backend-core/src/index.ts b/packages/backend-core/src/index.ts index ab89eed3b2..35777ae817 100644 --- a/packages/backend-core/src/index.ts +++ b/packages/backend-core/src/index.ts @@ -3,6 +3,7 @@ const errorClasses = errors.errors import * as events from "./events" import * as migrations from "./migrations" import * as users from "./users" +import * as roles from "./security/roles" import * as accounts from "./cloud/accounts" import * as installation from "./installation" import env from "./environment" @@ -51,6 +52,7 @@ const core = { installation, errors, logging, + roles, ...errorClasses, } diff --git a/packages/backend-core/src/logging.ts b/packages/backend-core/src/logging.ts index 68c3307b2f..8eda15ac79 100644 --- a/packages/backend-core/src/logging.ts +++ b/packages/backend-core/src/logging.ts @@ -15,6 +15,11 @@ export function logAlert(message: string, e?: any) { console.error(`bb-alert: ${message} ${errorJson}`) } +export function logWarn(message: string) { + console.warn(`bb-warn: ${message}`) +} + export default { logAlert, + logWarn, } diff --git a/packages/backend-core/src/middleware/adminOnly.js b/packages/backend-core/src/middleware/adminOnly.js new file mode 100644 index 0000000000..4bfdf83848 --- /dev/null +++ b/packages/backend-core/src/middleware/adminOnly.js @@ -0,0 +1,9 @@ +module.exports = async (ctx, next) => { + if ( + !ctx.internal && + (!ctx.user || !ctx.user.admin || !ctx.user.admin.global) + ) { + ctx.throw(403, "Admin user only endpoint.") + } + return next() +} diff --git a/packages/backend-core/src/middleware/authenticated.js b/packages/backend-core/src/middleware/authenticated.js index 4e6e0b7ba2..d86af773c3 100644 --- a/packages/backend-core/src/middleware/authenticated.js +++ b/packages/backend-core/src/middleware/authenticated.js @@ -127,7 +127,7 @@ module.exports = ( } if (!user && tenantId) { user = { tenantId } - } else { + } else if (user) { delete user.password } // be explicit diff --git a/packages/backend-core/src/middleware/index.js b/packages/backend-core/src/middleware/index.js index 1721d56a3c..9d94bf5763 100644 --- a/packages/backend-core/src/middleware/index.js +++ b/packages/backend-core/src/middleware/index.js @@ -9,7 +9,8 @@ const tenancy = require("./tenancy") const internalApi = require("./internalApi") const datasourceGoogle = require("./passport/datasource/google") const csrf = require("./csrf") - +const adminOnly = require("./adminOnly") +const joiValidator = require("./joi-validator") module.exports = { google, oidc, @@ -25,4 +26,6 @@ module.exports = { google: datasourceGoogle, }, csrf, + adminOnly, + joiValidator, } diff --git a/packages/backend-core/src/middleware/joi-validator.js b/packages/backend-core/src/middleware/joi-validator.js new file mode 100644 index 0000000000..1686b0e727 --- /dev/null +++ b/packages/backend-core/src/middleware/joi-validator.js @@ -0,0 +1,28 @@ +function validate(schema, property) { + // Return a Koa middleware function + return (ctx, next) => { + if (!schema) { + return next() + } + let params = null + if (ctx[property] != null) { + params = ctx[property] + } else if (ctx.request[property] != null) { + params = ctx.request[property] + } + const { error } = schema.validate(params) + if (error) { + ctx.throw(400, `Invalid ${property} - ${error.message}`) + return + } + return next() + } +} + +module.exports.body = schema => { + return validate(schema, "body") +} + +module.exports.params = schema => { + return validate(schema, "params") +} diff --git a/packages/backend-core/src/security/roles.js b/packages/backend-core/src/security/roles.js index 7c57cadcbf..44dc4f2d3e 100644 --- a/packages/backend-core/src/security/roles.js +++ b/packages/backend-core/src/security/roles.js @@ -76,7 +76,7 @@ function isBuiltin(role) { /** * Works through the inheritance ranks to see how far up the builtin stack this ID is. */ -function builtinRoleToNumber(id) { +exports.builtinRoleToNumber = id => { const builtins = exports.getBuiltinRoles() const MAX = Object.values(BUILTIN_IDS).length + 1 if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { @@ -104,7 +104,8 @@ exports.lowerBuiltinRoleID = (roleId1, roleId2) => { if (!roleId2) { return roleId1 } - return builtinRoleToNumber(roleId1) > builtinRoleToNumber(roleId2) + return exports.builtinRoleToNumber(roleId1) > + exports.builtinRoleToNumber(roleId2) ? roleId2 : roleId1 } diff --git a/packages/backend-core/src/users.js b/packages/backend-core/src/users.js index 0c1350a674..34d546a8bb 100644 --- a/packages/backend-core/src/users.js +++ b/packages/backend-core/src/users.js @@ -1,4 +1,9 @@ -const { ViewNames } = require("./db/utils") +const { + ViewNames, + getUsersByAppParams, + getProdAppID, + generateAppUserID, +} = require("./db/utils") const { queryGlobalView } = require("./db/views") const { UNICODE_MAX } = require("./db/constants") @@ -13,12 +18,32 @@ exports.getGlobalUserByEmail = async email => { throw "Must supply an email address to view" } - const response = await queryGlobalView(ViewNames.USER_BY_EMAIL, { + return await queryGlobalView(ViewNames.USER_BY_EMAIL, { key: email.toLowerCase(), include_docs: true, }) +} - return response +exports.searchGlobalUsersByApp = async (appId, opts) => { + if (typeof appId !== "string") { + throw new Error("Must provide a string based app ID") + } + const params = getUsersByAppParams(appId, { + include_docs: true, + }) + params.startkey = opts && opts.startkey ? opts.startkey : params.startkey + let response = await queryGlobalView(ViewNames.USER_BY_APP, params) + if (!response) { + response = [] + } + return Array.isArray(response) ? response : [response] +} + +exports.getGlobalUserByAppPage = (appId, user) => { + if (!user) { + return + } + return generateAppUserID(getProdAppID(appId), user._id) } /** diff --git a/packages/backend-core/tests/utilities/mocks/events.js b/packages/backend-core/tests/utilities/mocks/events.js index a4055cc5ea..415d59019d 100644 --- a/packages/backend-core/tests/utilities/mocks/events.js +++ b/packages/backend-core/tests/utilities/mocks/events.js @@ -89,6 +89,14 @@ jest.spyOn(events.user, "passwordUpdated") jest.spyOn(events.user, "passwordResetRequested") jest.spyOn(events.user, "passwordReset") +jest.spyOn(events.group, "created") +jest.spyOn(events.group, "updated") +jest.spyOn(events.group, "deleted") +jest.spyOn(events.group, "usersAdded") +jest.spyOn(events.group, "usersDeleted") +jest.spyOn(events.group, "createdOnboarding") +jest.spyOn(events.group, "permissionsEdited") + jest.spyOn(events.serve, "servedBuilder") jest.spyOn(events.serve, "servedApp") jest.spyOn(events.serve, "servedAppPreview") diff --git a/packages/bbui/package.json b/packages/bbui/package.json index bc49185e84..096a415e52 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.1.18-alpha.0", + "version": "1.1.29-alpha.0", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", @@ -38,7 +38,7 @@ ], "dependencies": { "@adobe/spectrum-css-workflow-icons": "^1.2.1", - "@budibase/string-templates": "^1.1.18-alpha.0", + "@budibase/string-templates": "^1.1.29-alpha.0", "@spectrum-css/actionbutton": "^1.0.1", "@spectrum-css/actiongroup": "^1.0.1", "@spectrum-css/avatar": "^3.0.2", diff --git a/packages/bbui/src/Avatar/Avatar.svelte b/packages/bbui/src/Avatar/Avatar.svelte index f8acd9024c..136a4fe24b 100644 --- a/packages/bbui/src/Avatar/Avatar.svelte +++ b/packages/bbui/src/Avatar/Avatar.svelte @@ -4,7 +4,7 @@ ["XXS", "--spectrum-alias-avatar-size-50"], ["XS", "--spectrum-alias-avatar-size-75"], ["S", "--spectrum-alias-avatar-size-200"], - ["M", "--spectrum-alias-avatar-size-300"], + ["M", "--spectrum-alias-avatar-size-400"], ["L", "--spectrum-alias-avatar-size-500"], ["XL", "--spectrum-alias-avatar-size-600"], ["XXL", "--spectrum-alias-avatar-size-700"], @@ -13,6 +13,19 @@ export let url = "" export let disabled = false export let initials = "JD" + + const DefaultColor = "#3aab87" + + $: color = getColor(initials) + + const getColor = initials => { + if (!initials?.length) { + return DefaultColor + } + const code = initials[0].toLowerCase().charCodeAt(0) + const hue = ((code % 26) / 26) * 360 + return `hsl(${hue}, 50%, 50%)` + } {#if url} @@ -25,10 +38,11 @@ /> {:else}