Merge branch 'next' of github.com:Budibase/budibase into app-list

This commit is contained in:
Andrew Kingston 2021-05-06 14:01:03 +01:00
commit 1ed08a8a8b
57 changed files with 4500 additions and 365 deletions

View File

@ -59,6 +59,7 @@ services:
container_name: budi-redis-dev container_name: budi-redis-dev
restart: always restart: always
image: redis image: redis
command: redis-server --requirepass ${REDIS_PASSWORD}
ports: ports:
- "${REDIS_PORT}:6379" - "${REDIS_PORT}:6379"
volumes: volumes:

View File

@ -23,6 +23,8 @@ services:
LOG_LEVEL: info LOG_LEVEL: info
SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131 SENTRY_DSN: https://a34ae347621946bf8acded18e5b7d4b8@o420233.ingest.sentry.io/5338131
ENABLE_ANALYTICS: "true" ENABLE_ANALYTICS: "true"
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on: depends_on:
- worker-service - worker-service
@ -43,6 +45,8 @@ services:
COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD} COUCH_DB_PASSWORD: ${COUCH_DB_PASSWORD}
COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984 COUCH_DB_URL: http://${COUCH_DB_USER}:${COUCH_DB_PASSWORD}@couchdb-service:5984
SELF_HOST_KEY: ${HOSTING_KEY} SELF_HOST_KEY: ${HOSTING_KEY}
REDIS_URL: redis-service:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
depends_on: depends_on:
- minio-service - minio-service
- couch-init - couch-init
@ -100,8 +104,7 @@ services:
redis-service: redis-service:
restart: always restart: always
image: redis image: redis
ports: command: redis-server --requirepass ${REDIS_PASSWORD}
- "${REDIS_PORT}:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data

View File

@ -21,11 +21,6 @@ static_resources:
cluster: couchdb-service cluster: couchdb-service
prefix_rewrite: "/" prefix_rewrite: "/"
- match: { prefix: "/cache/" }
route:
cluster: redis-service
prefix_rewrite: "/"
- match: { prefix: "/api/admin/" } - match: { prefix: "/api/admin/" }
route: route:
cluster: worker-dev cluster: worker-dev
@ -85,20 +80,6 @@ static_resources:
address: couchdb-service address: couchdb-service
port_value: 5984 port_value: 5984
- name: redis-service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: redis-service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: redis-service
port_value: 6379
- name: server-dev - name: server-dev
connect_timeout: 0.25s connect_timeout: 0.25s
type: strict_dns type: strict_dns

View File

@ -41,11 +41,6 @@ static_resources:
cluster: worker-service cluster: worker-service
prefix_rewrite: "/" prefix_rewrite: "/"
- match: { prefix: "/cache/" }
route:
cluster: redis-service
prefix_rewrite: "/"
- match: { prefix: "/db/" } - match: { prefix: "/db/" }
route: route:
cluster: couchdb-service cluster: couchdb-service
@ -117,18 +112,3 @@ static_resources:
address: couchdb-service address: couchdb-service
port_value: 5984 port_value: 5984
- name: redis-service
connect_timeout: 0.25s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: redis-service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: redis-service
port_value: 6379

View File

@ -12,6 +12,7 @@ MINIO_ACCESS_KEY=budibase
MINIO_SECRET_KEY=budibase MINIO_SECRET_KEY=budibase
COUCH_DB_PASSWORD=budibase COUCH_DB_PASSWORD=budibase
COUCH_DB_USER=budibase COUCH_DB_USER=budibase
REDIS_PASSWORD=budibase
# This section contains variables that do not need to be altered under normal circumstances # This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002 APP_PORT=4002

View File

@ -1,12 +1,13 @@
{ {
"name": "@budibase/auth", "name": "@budibase/auth",
"version": "0.0.1", "version": "0.18.6",
"description": "Authentication middlewares for budibase builder and apps", "description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"ioredis": "^4.27.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"koa-passport": "^4.1.4", "koa-passport": "^4.1.4",
"passport-google-auth": "^1.0.2", "passport-google-auth": "^1.0.2",
@ -14,5 +15,8 @@
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"uuid": "^8.3.2" "uuid": "^8.3.2"
},
"devDependencies": {
"ioredis-mock": "^5.5.5"
} }
} }

View File

@ -123,7 +123,7 @@ const getConfigParams = ({ type, group, user }, otherProps = {}) => {
* @param {Object} scopes - the type, group and userID scopes of the configuration. * @param {Object} scopes - the type, group and userID scopes of the configuration.
* @returns The most granular configuration document based on the scope. * @returns The most granular configuration document based on the scope.
*/ */
const determineScopedConfig = async function (db, { type, user, group }) { const getScopedFullConfig = async function (db, { type, user, group }) {
const response = await db.allDocs( const response = await db.allDocs(
getConfigParams( getConfigParams(
{ type, user, group }, { type, user, group },
@ -157,9 +157,15 @@ const determineScopedConfig = async function (db, { type, user, group }) {
(a, b) => determineScore(a) - determineScore(b) (a, b) => determineScore(a) - determineScore(b)
)[0] )[0]
return scopedConfig.doc return scopedConfig && scopedConfig.doc
} }
async function getScopedConfig(db, params) {
const configDoc = await getScopedFullConfig(db, params)
return configDoc && configDoc.config ? configDoc.config : configDoc
}
exports.getScopedConfig = getScopedConfig
exports.generateConfigID = generateConfigID exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams exports.getConfigParams = getConfigParams
exports.determineScopedConfig = determineScopedConfig exports.getScopedFullConfig = getScopedFullConfig

View File

@ -1,5 +1,16 @@
function isTest() {
return (
process.env.NODE_ENV === "jest" ||
process.env.NODE_ENV === "cypress" ||
process.env.JEST_WORKER_ID != null
)
}
module.exports = { module.exports = {
JWT_SECRET: process.env.JWT_SECRET, JWT_SECRET: process.env.JWT_SECRET,
COUCH_DB_URL: process.env.COUCH_DB_URL, COUCH_DB_URL: process.env.COUCH_DB_URL,
SALT_ROUNDS: process.env.SALT_ROUNDS, SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
isTest,
} }

View File

@ -28,6 +28,10 @@ module.exports = {
setDB(pouch) setDB(pouch)
}, },
db: require("./db/utils"), db: require("./db/utils"),
redis: {
Client: require("./redis"),
utils: require("./redis/utils"),
},
utils: { utils: {
...require("./utils"), ...require("./utils"),
...require("./hashing"), ...require("./hashing"),

View File

@ -3,11 +3,35 @@ const database = require("../db")
const { getCookie, clearCookie } = require("../utils") const { getCookie, clearCookie } = require("../utils")
const { StaticDatabases } = require("../db/utils") const { StaticDatabases } = require("../db/utils")
module.exports = (noAuthPatterns = []) => { const PARAM_REGEX = /\/:(.*?)\//g
const regex = new RegExp(noAuthPatterns.join("|"))
function buildNoAuthRegex(patterns) {
return patterns.map(pattern => {
const isObj = typeof pattern === "object" && pattern.route
const method = isObj ? pattern.method : "GET"
let route = isObj ? pattern.route : pattern
const matches = route.match(PARAM_REGEX)
if (matches) {
for (let match of matches) {
route = route.replace(match, "/.*/")
}
}
return { regex: new RegExp(route), method }
})
}
module.exports = (noAuthPatterns = [], opts) => {
const noAuthOptions = noAuthPatterns ? buildNoAuthRegex(noAuthPatterns) : []
return async (ctx, next) => { return async (ctx, next) => {
// the path is not authenticated // the path is not authenticated
if (regex.test(ctx.request.url)) { const found = noAuthOptions.find(({ regex, method }) => {
return (
regex.test(ctx.request.url) &&
ctx.request.method.toLowerCase() === method.toLowerCase()
)
})
if (found != null) {
return next() return next()
} }
try { try {
@ -30,10 +54,14 @@ module.exports = (noAuthPatterns = []) => {
if (ctx.isAuthenticated !== true) { if (ctx.isAuthenticated !== true) {
ctx.isAuthenticated = false ctx.isAuthenticated = false
} }
return next() return next()
} catch (err) { } catch (err) {
// allow configuring for public access
if (opts && opts.publicAllowed) {
ctx.isAuthenticated = false
} else {
ctx.throw(err.status || 403, err) ctx.throw(err.status || 403, err)
} }
} }
} }
}

View File

@ -0,0 +1,152 @@
const env = require("../environment")
// ioredis mock is all in memory
const Redis = env.isTest() ? require("ioredis-mock") : require("ioredis")
const { addDbPrefix, removeDbPrefix, getRedisOptions } = require("./utils")
const CLUSTERED = false
// for testing just generate the client once
let CLIENT = env.isTest() ? new Redis(getRedisOptions()) : null
/**
* Inits the system, will error if unable to connect to redis cluster (may take up to 10 seconds) otherwise
* will return the ioredis client which will be ready to use.
* @return {Promise<object>} The ioredis client.
*/
function init() {
return new Promise((resolve, reject) => {
// testing uses a single in memory client
if (env.isTest()) {
return resolve(CLIENT)
}
// if a connection existed, close it and re-create it
if (CLIENT) {
CLIENT.disconnect()
CLIENT = null
}
const { opts, host, port } = getRedisOptions(CLUSTERED)
if (CLUSTERED) {
CLIENT = new Redis.Cluster([{ host, port }], opts)
} else {
CLIENT = new Redis(opts)
}
CLIENT.on("end", err => {
reject(err)
})
CLIENT.on("error", err => {
reject(err)
})
CLIENT.on("connect", () => {
resolve(CLIENT)
})
})
}
/**
* Utility function, takes a redis stream and converts it to a promisified response -
* this can only be done with redis streams because they will have an end.
* @param stream A redis stream, specifically as this type of stream will have an end.
* @return {Promise<object>} The final output of the stream
*/
function promisifyStream(stream) {
return new Promise((resolve, reject) => {
const outputKeys = new Set()
stream.on("data", keys => {
keys.forEach(key => {
outputKeys.add(key)
})
})
stream.on("error", err => {
reject(err)
})
stream.on("end", async () => {
const keysArray = Array.from(outputKeys)
try {
let getPromises = []
for (let key of keysArray) {
getPromises.push(CLIENT.get(key))
}
const jsonArray = await Promise.all(getPromises)
resolve(
keysArray.map(key => ({
key: removeDbPrefix(key),
value: JSON.parse(jsonArray.shift()),
}))
)
} catch (err) {
reject(err)
}
})
})
}
class RedisWrapper {
constructor(db) {
this._db = db
}
async init() {
this._client = await init()
return this
}
async finish() {
this._client.disconnect()
}
async scan() {
const db = this._db,
client = this._client
let stream
if (CLUSTERED) {
let node = client.nodes("master")
stream = node[0].scanStream({ match: db + "-*", count: 100 })
} else {
stream = client.scanStream({ match: db + "-*", count: 100 })
}
return promisifyStream(stream)
}
async get(key) {
const db = this._db,
client = this._client
let response = await client.get(addDbPrefix(db, key))
// overwrite the prefixed key
if (response != null && response.key) {
response.key = key
}
// if its not an object just return the response
try {
return JSON.parse(response)
} catch (err) {
return response
}
}
async store(key, value, expirySeconds = null) {
const db = this._db,
client = this._client
if (typeof value === "object") {
value = JSON.stringify(value)
}
const prefixedKey = addDbPrefix(db, key)
await client.set(prefixedKey, value)
if (expirySeconds) {
await client.expire(prefixedKey, expirySeconds)
}
}
async delete(key) {
const db = this._db,
client = this._client
await client.del(addDbPrefix(db, key))
}
async clear() {
const db = this._db
let items = await this.scan(db)
await Promise.all(items.map(obj => this.delete(db, obj.key)))
}
}
module.exports = RedisWrapper

View File

@ -0,0 +1,46 @@
const env = require("../environment")
const SLOT_REFRESH_MS = 2000
const CONNECT_TIMEOUT_MS = 10000
const SEPARATOR = "-"
const REDIS_URL = !env.REDIS_URL ? "localhost:6379" : env.REDIS_URL
const REDIS_PASSWORD = !env.REDIS_PASSWORD ? "budibase" : env.REDIS_PASSWORD
exports.Databases = {
PW_RESETS: "pwReset",
INVITATIONS: "invitation",
}
exports.getRedisOptions = (clustered = false) => {
const [host, port] = REDIS_URL.split(":")
const opts = {
connectTimeout: CONNECT_TIMEOUT_MS,
}
if (clustered) {
opts.redisOptions = {}
opts.redisOptions.tls = {}
opts.redisOptions.password = REDIS_PASSWORD
opts.slotsRefreshTimeout = SLOT_REFRESH_MS
opts.dnsLookup = (address, callback) => callback(null, address)
} else {
opts.host = host
opts.port = port
opts.password = REDIS_PASSWORD
}
return { opts, host, port }
}
exports.addDbPrefix = (db, key) => {
return `${db}${SEPARATOR}${key}`
}
exports.removeDbPrefix = key => {
let parts = key.split(SEPARATOR)
if (parts.length >= 2) {
parts.shift()
return parts.join(SEPARATOR)
} else {
// return the only part
return parts[0]
}
}

View File

@ -105,6 +105,12 @@ exports.isClient = ctx => {
return ctx.headers["x-budibase-type"] === "client" return ctx.headers["x-budibase-type"] === "client"
} }
/**
* Given an email address this will use a view to search through
* all the users to find one with this email address.
* @param {string} email the email to lookup the user by.
* @return {Promise<object|null>}
*/
exports.getGlobalUserByEmail = async email => { exports.getGlobalUserByEmail = async email => {
const db = getDB(StaticDatabases.GLOBAL.name) const db = getDB(StaticDatabases.GLOBAL.name)
try { try {

View File

@ -46,6 +46,11 @@ aws4@^1.8.0:
resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
balanced-match@^1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64url@3.x.x: base64url@3.x.x:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
@ -63,6 +68,14 @@ bcryptjs@^2.4.3:
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb" resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms= integrity sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms=
brace-expansion@^1.1.7:
version "1.1.11"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
dependencies:
balanced-match "^1.0.0"
concat-map "0.0.1"
buffer-equal-constant-time@1.0.1: buffer-equal-constant-time@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
@ -73,6 +86,11 @@ caseless@~0.12.0:
resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
cluster-key-slot@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d"
integrity sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==
combined-stream@^1.0.6, combined-stream@~1.0.6: combined-stream@^1.0.6, combined-stream@~1.0.6:
version "1.0.8" version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@ -80,6 +98,11 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
dependencies: dependencies:
delayed-stream "~1.0.0" delayed-stream "~1.0.0"
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
core-util-is@1.0.2: core-util-is@1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@ -92,11 +115,23 @@ dashdash@^1.12.0:
dependencies: dependencies:
assert-plus "^1.0.0" assert-plus "^1.0.0"
debug@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
dependencies:
ms "2.1.2"
delayed-stream@~1.0.0: delayed-stream@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
denque@^1.1.0:
version "1.5.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
ecc-jsbn@~0.1.1: ecc-jsbn@~0.1.1:
version "0.1.2" version "0.1.2"
resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@ -137,6 +172,20 @@ fast-json-stable-stringify@^2.0.0:
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fengari-interop@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/fengari-interop/-/fengari-interop-0.1.2.tgz#f7731dcdd2ff4449073fb7ac3c451a8841ce1e87"
integrity sha512-8iTvaByZVoi+lQJhHH9vC+c/Yaok9CwOqNQZN6JrVpjmWwW4dDkeblBXhnHC+BoI6eF4Cy5NKW3z6ICEjvgywQ==
fengari@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/fengari/-/fengari-0.1.4.tgz#72416693cd9e43bd7d809d7829ddc0578b78b0bb"
integrity sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==
dependencies:
readline-sync "^1.4.9"
sprintf-js "^1.1.1"
tmp "^0.0.33"
forever-agent@~0.6.1: forever-agent@~0.6.1:
version "0.6.1" version "0.6.1"
resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
@ -216,6 +265,33 @@ http-signature@~1.2.0:
jsprim "^1.2.2" jsprim "^1.2.2"
sshpk "^1.7.0" sshpk "^1.7.0"
ioredis-mock@^5.5.5:
version "5.5.5"
resolved "https://registry.yarnpkg.com/ioredis-mock/-/ioredis-mock-5.5.5.tgz#dec9fedd238c6ab9f56c026fc366533144f8a256"
integrity sha512-7SxCAwNtDLC8IFDptqIhOC7ajp3fciVtCrXOEOkpyjPboAGRQkJbnpNPy1NYORoWi+0/iOtUPUQckSKtSQj4DA==
dependencies:
fengari "^0.1.4"
fengari-interop "^0.1.2"
lodash "^4.17.21"
minimatch "^3.0.4"
standard-as-callback "^2.1.0"
ioredis@^4.27.1:
version "4.27.1"
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-4.27.1.tgz#4ef947b455a1b995baa4b0d7e2c4e4f75f746421"
integrity sha512-PaFNFeBbOcEYHXAdrJuy7uesJcyvzStTM1aYMchTuky+VgKqDbXhnTJHaDsjAwcTwPx8Asatx+l2DW8zZ2xlsQ==
dependencies:
cluster-key-slot "^1.1.0"
debug "^4.3.1"
denque "^1.1.0"
lodash.defaults "^4.2.0"
lodash.flatten "^4.4.0"
p-map "^2.1.0"
redis-commands "1.7.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.1.0"
is-typedarray@~1.0.0: is-typedarray@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
@ -296,6 +372,16 @@ koa-passport@^4.1.4:
dependencies: dependencies:
passport "^0.4.0" passport "^0.4.0"
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
lodash.flatten@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f"
integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
lodash.includes@^4.3.0: lodash.includes@^4.3.0:
version "4.3.0" version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
@ -336,7 +422,7 @@ lodash.once@^4.0.0:
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
lodash@^4.14.0: lodash@^4.14.0, lodash@^4.17.21:
version "4.17.21" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@ -358,6 +444,18 @@ mime@^1.4.1:
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
minimatch@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
dependencies:
brace-expansion "^1.1.7"
ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@^2.1.1: ms@^2.1.1:
version "2.1.3" version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@ -378,6 +476,16 @@ oauth@0.9.x:
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
os-tmpdir@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
p-map@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/p-map/-/p-map-2.1.0.tgz#310928feef9c9ecc65b68b17693018a665cea175"
integrity sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==
passport-google-auth@^1.0.2: passport-google-auth@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/passport-google-auth/-/passport-google-auth-1.0.2.tgz#8b300b5aa442ef433de1d832ed3112877d0b2938" resolved "https://registry.yarnpkg.com/passport-google-auth/-/passport-google-auth-1.0.2.tgz#8b300b5aa442ef433de1d832ed3112877d0b2938"
@ -481,6 +589,28 @@ qs@~6.5.2:
resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
readline-sync@^1.4.9:
version "1.4.10"
resolved "https://registry.yarnpkg.com/readline-sync/-/readline-sync-1.4.10.tgz#41df7fbb4b6312d673011594145705bf56d8873b"
integrity sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==
redis-commands@1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=
dependencies:
redis-errors "^1.0.0"
request@^2.72.0, request@^2.74.0: request@^2.72.0, request@^2.74.0:
version "2.88.2" version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
@ -522,6 +652,11 @@ semver@^5.6.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
sprintf-js@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673"
integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==
sshpk@^1.7.0: sshpk@^1.7.0:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@ -537,11 +672,23 @@ sshpk@^1.7.0:
safer-buffer "^2.0.2" safer-buffer "^2.0.2"
tweetnacl "~0.14.0" tweetnacl "~0.14.0"
standard-as-callback@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
string-template@~1.0.0: string-template@~1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96" resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y= integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
dependencies:
os-tmpdir "~1.0.2"
tough-cookie@~2.5.0: tough-cookie@~2.5.0:
version "2.5.0" version "2.5.0"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"

View File

@ -0,0 +1,54 @@
<script>
import { isActive, url, goto } from "@roxi/routify"
import { onMount } from "svelte"
import {
ActionMenu,
Checkbox,
Body,
MenuItem,
Icon,
Heading,
Avatar,
Search,
Layout,
ProgressCircle,
SideNavigation as Navigation,
SideNavigationItem as Item,
} from "@budibase/bbui"
import api from "builderStore/api"
import { organisation, admin } from "stores/portal"
const MESSAGES = {
apps: "Create your first app",
smtp: "Set up email",
adminUser: "Create your first user",
}
</script>
<ActionMenu>
<div slot="control" class="icon">
<ProgressCircle size="S" value={$admin.onboardingProgress} />
</div>
<MenuItem disabled>
<header class="item">
<Heading size="XXS">Get Started Checklist</Heading>
<ProgressCircle size="S" value={$admin.onboardingProgress} />
</header>
</MenuItem>
{#each Object.keys($admin.checklist) as checklistItem, idx}
<MenuItem>
<div class="item">
<span>{idx + 1}. {MESSAGES[checklistItem]}</span>
<Checkbox value={!!$admin.checklist[checklistItem]} />
</div>
</MenuItem>
{/each}
</ActionMenu>
<style>
.item {
display: grid;
align-items: center;
grid-template-columns: 200px 20px;
}
</style>

View File

@ -0,0 +1,29 @@
<script>
import { onMount } from "svelte"
import { goto } from "@roxi/routify"
import {
SideNavigation as Navigation,
SideNavigationItem as Item,
} from "@budibase/bbui"
import { admin } from "stores/portal"
import LoginForm from "components/login/LoginForm.svelte"
import BuilderSettingsButton from "components/start/BuilderSettingsButton.svelte"
import LogoutButton from "components/start/LogoutButton.svelte"
import Logo from "/assets/budibase-logo.svg"
import api from "builderStore/api"
let checklist
onMount(async () => {
await admin.init()
if (!$admin?.checklist?.adminUser) {
$goto("./admin")
} else {
$goto("./portal")
}
})
</script>
{#if $admin.checklist}
<slot />
{/if}

View File

@ -0,0 +1,69 @@
<script>
import {
Button,
Heading,
Label,
notifications,
Layout,
Input,
Body,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import api from "builderStore/api"
let adminUser = {}
async function save() {
try {
// Save the admin user
const response = await api.post(`/api/admin/users/init`, adminUser)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
notifications.success(`Admin user created.`)
$goto("../portal")
} catch (err) {
notifications.error(`Failed to create admin user.`)
}
}
</script>
<section>
<div class="container">
<header>
<Heading size="M">Create an admin user</Heading>
<Body size="S">The admin user has access to everything in budibase.</Body>
</header>
<div class="config-form">
<Layout gap="S">
<Input label="email" bind:value={adminUser.email} />
<Input
label="password"
type="password"
bind:value={adminUser.password}
/>
<Button cta on:click={save}>Create super admin user</Button>
</Layout>
</div>
</div>
</section>
<style>
section {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
header {
text-align: center;
width: 80%;
margin: 0 auto;
}
.config-form {
margin-bottom: 42px;
}
</style>

View File

@ -23,7 +23,6 @@
<div class="nav-top"> <div class="nav-top">
<Navigation> <Navigation>
<Item href="/builder/" icon="Apps" selected>Apps</Item> <Item href="/builder/" icon="Apps" selected>Apps</Item>
<Item href="/builder/oauth/" icon="OAuth" selected>OAuth</Item>
<Item external href="https://portal.budi.live/" icon="Servers"> <Item external href="https://portal.budi.live/" icon="Servers">
Hosting Hosting
</Item> </Item>

View File

@ -1,25 +1,28 @@
<script> <script>
import { isActive, url } from "@roxi/routify" import { isActive } from "@roxi/routify"
import { onMount } from "svelte" import { onMount } from "svelte"
import { import {
Icon, Icon,
Avatar, Avatar,
Search, Search,
Layout, Layout,
ProgressCircle,
SideNavigation as Navigation, SideNavigation as Navigation,
SideNavigationItem as Item, SideNavigationItem as Item,
} from "@budibase/bbui" } from "@budibase/bbui"
import ConfigChecklist from "components/common/ConfigChecklist.svelte"
import { organisation, apps } from "stores/portal" import { organisation, apps } from "stores/portal"
organisation.init() organisation.init()
apps.load() apps.load()
console.log("loading") let orgName
let orgLogo
let onBoardingProgress, user let user
async function getInfo() { async function getInfo() {
onBoardingProgress = 20 // fetch orgInfo
orgName = "ACME Inc."
orgLogo = "https://via.placeholder.com/150"
user = { name: "John Doe" } user = { name: "John Doe" }
} }
@ -50,15 +53,13 @@
<span>{$organisation?.company || "Budibase"}</span> <span>{$organisation?.company || "Budibase"}</span>
</div> </div>
<div class="onboarding"> <div class="onboarding">
<ProgressCircle size="S" value={onBoardingProgress} /> <ConfigChecklist />
</div> </div>
</div> </div>
<div class="menu"> <div class="menu">
<Navigation> <Navigation>
{#each menu as { title, href, heading }} {#each menu as { title, href, heading }}
<Item selected={$isActive(href)} href={$url(href)} {heading}> <Item selected={$isActive(href)} {href} {heading}>{title}</Item>
{title}
</Item>
{/each} {/each}
</Navigation> </Navigation>
</div> </div>

View File

@ -1,7 +1,36 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import api from "builderStore/api"
const INITIAL_ADMIN_STATE = { export function createAdminStore() {
oauth: [], const { subscribe, set } = writable({})
async function init() {
try {
const response = await api.get("/api/admin/configs/checklist")
const json = await response.json()
const onboardingSteps = Object.keys(json)
const stepsComplete = onboardingSteps.reduce(
(score, step) => score + Number(!!json[step]),
0
)
set({
checklist: json,
onboardingProgress: (stepsComplete / onboardingSteps.length) * 100,
})
} catch (err) {
set({
checklist: null,
})
}
} }
export const admin = writable({ ...INITIAL_ADMIN_STATE }) return {
subscribe,
init,
}
}
export const admin = createAdminStore()

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@ MINIO_ACCESS_KEY=${randomString.generate()}
MINIO_SECRET_KEY=${randomString.generate()} MINIO_SECRET_KEY=${randomString.generate()}
COUCH_DB_PASSWORD=${randomString.generate()} COUCH_DB_PASSWORD=${randomString.generate()}
COUCH_DB_USER=${randomString.generate()} COUCH_DB_USER=${randomString.generate()}
REDIS_PASSWORD=${randomString.generate()}
# This section contains variables that do not need to be altered under normal circumstances # This section contains variables that do not need to be altered under normal circumstances
APP_PORT=4002 APP_PORT=4002

View File

@ -79,7 +79,7 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.0.1", "@budibase/auth": "^0.18.6",
"@budibase/client": "^0.8.16", "@budibase/client": "^0.8.16",
"@budibase/string-templates": "^0.8.16", "@budibase/string-templates": "^0.8.16",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",

View File

@ -37,9 +37,10 @@ async function init() {
PORT: 4001, PORT: 4001,
MINIO_URL: "http://localhost:10000/", MINIO_URL: "http://localhost:10000/",
COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/", COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/",
REDIS_URL: "http://localhost:10000/cache/", REDIS_URL: "localhost:6379",
WORKER_URL: "http://localhost:4002", WORKER_URL: "http://localhost:4002",
JWT_SECRET: "testsecret", JWT_SECRET: "testsecret",
REDIS_PASSWORD: "budibase",
MINIO_ACCESS_KEY: "budibase", MINIO_ACCESS_KEY: "budibase",
MINIO_SECRET_KEY: "budibase", MINIO_SECRET_KEY: "budibase",
COUCH_DB_PASSWORD: "budibase", COUCH_DB_PASSWORD: "budibase",

View File

@ -5,20 +5,16 @@ const compress = require("koa-compress")
const zlib = require("zlib") const zlib = require("zlib")
const { mainRoutes, staticRoutes } = require("./routes") const { mainRoutes, staticRoutes } = require("./routes")
const pkg = require("../../package.json") const pkg = require("../../package.json")
const env = require("../environment")
if (!env.isTest()) {
const bullboard = require("bull-board") const bullboard = require("bull-board")
const expressApp = require("express")() const expressApp = require("express")()
expressApp.use("/bulladmin", bullboard.router) expressApp.use("/bulladmin", bullboard.router)
}
const router = new Router() const router = new Router()
const env = require("../environment")
const NO_AUTH_ENDPOINTS = [
"/health",
"/version",
"webhooks/trigger",
"webhooks/schema",
]
router router
.use( .use(
@ -42,7 +38,11 @@ router
}) })
.use("/health", ctx => (ctx.status = 200)) .use("/health", ctx => (ctx.status = 200))
.use("/version", ctx => (ctx.body = pkg.version)) .use("/version", ctx => (ctx.body = pkg.version))
.use(buildAuthMiddleware(NO_AUTH_ENDPOINTS)) .use(
buildAuthMiddleware(null, {
publicAllowed: true,
})
)
.use(currentApp) .use(currentApp)
// error handling middleware // error handling middleware

View File

@ -402,14 +402,16 @@ describe("/rows", () => {
name: "test", name: "test",
description: "test", description: "test",
attachment: [{ attachment: [{
url: "/test/thing", key: `/assets/${config.getAppId()}/attachment/test/thing.csv`,
}], }],
tableId: table._id, tableId: table._id,
}) })
// the environment needs configured for this // the environment needs configured for this
await setup.switchToSelfHosted(async () => { await setup.switchToSelfHosted(async () => {
const enriched = await outputProcessing(config.getAppId(), table, [row]) const enriched = await outputProcessing(config.getAppId(), table, [row])
expect(enriched[0].attachment[0].url).toBe(`/app-assets/assets/${config.getAppId()}/test/thing`) expect(enriched[0].attachment[0].url).toBe(
`/prod-budi-app-assets/assets/${config.getAppId()}/attachment/test/thing.csv`
)
}) })
}) })
}) })

View File

@ -1,11 +1,16 @@
const CouchDB = require("../db") const CouchDB = require("../db")
const emitter = require("../events/index") const emitter = require("../events/index")
const Queue = require("bull") const env = require("../environment")
const Queue = env.isTest()
? require("../utilities/queue/inMemoryQueue")
: require("bull")
const { setQueues, BullAdapter } = require("bull-board") const { setQueues, BullAdapter } = require("bull-board")
const { getAutomationParams } = require("../db/utils") const { getAutomationParams } = require("../db/utils")
const { coerce } = require("../utilities/rowProcessor") const { coerce } = require("../utilities/rowProcessor")
const { utils } = require("@budibase/auth").redis
let automationQueue = new Queue("automationQueue") const { opts } = utils.getRedisOptions()
let automationQueue = new Queue("automationQueue", { redis: opts })
// Set up queues for bull board admin // Set up queues for bull board admin
setQueues([new BullAdapter(automationQueue)]) setQueues([new BullAdapter(automationQueue)])

View File

@ -32,6 +32,8 @@ module.exports = {
MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY, MINIO_ACCESS_KEY: process.env.MINIO_ACCESS_KEY,
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
USE_QUOTAS: process.env.USE_QUOTAS, USE_QUOTAS: process.env.USE_QUOTAS,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
// environment // environment
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
JEST_WORKER_ID: process.env.JEST_WORKER_ID, JEST_WORKER_ID: process.env.JEST_WORKER_ID,

File diff suppressed because it is too large Load Diff

View File

@ -14,12 +14,13 @@
"scripts": { "scripts": {
"run:docker": "node src/index.js", "run:docker": "node src/index.js",
"dev:stack:init": "node ./scripts/dev/manage.js init", "dev:stack:init": "node ./scripts/dev/manage.js init",
"dev:builder": "npm run dev:stack:init && nodemon src/index.js" "dev:builder": "npm run dev:stack:init && nodemon src/index.js",
"test": "jest --runInBand"
}, },
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "0.0.1", "@budibase/auth": "^0.18.6",
"@budibase/string-templates": "^0.8.16", "@budibase/string-templates": "^0.8.16",
"@koa/router": "^8.0.0", "@koa/router": "^8.0.0",
"aws-sdk": "^2.811.0", "aws-sdk": "^2.811.0",
@ -50,5 +51,11 @@
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"pouchdb-adapter-memory": "^7.2.2", "pouchdb-adapter-memory": "^7.2.2",
"supertest": "^6.1.3" "supertest": "^6.1.3"
},
"jest": {
"testEnvironment": "node",
"setupFiles": [
"./scripts/jestSetup.js"
]
} }
} }

View File

@ -12,6 +12,8 @@ async function init() {
MINIO_SECRET_KEY: "budibase", MINIO_SECRET_KEY: "budibase",
COUCH_DB_USER: "budibase", COUCH_DB_USER: "budibase",
COUCH_DB_PASSWORD: "budibase", COUCH_DB_PASSWORD: "budibase",
REDIS_URL: "localhost:6379",
REDIS_PASSWORD: "budibase",
MINIO_URL: "http://localhost:10000/", MINIO_URL: "http://localhost:10000/",
COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/", COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/",
} }

View File

@ -0,0 +1,5 @@
const env = require("../src/environment")
env._set("NODE_ENV", "jest")
env._set("JWT_SECRET", "test-jwtsecret")
env._set("LOG_LEVEL", "silent")

View File

@ -0,0 +1,125 @@
const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware")
const { Configs, EmailTemplatePurpose } = require("../../../constants")
const CouchDB = require("../../../db")
const { sendEmail, isEmailConfigured } = require("../../../utilities/email")
const { clearCookie, getGlobalUserByEmail, hash } = authPkg.utils
const { Cookies } = authPkg.constants
const { passport } = authPkg.auth
const { checkResetPasswordCode } = require("../../../utilities/redis")
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
function authInternal(ctx, user, err = null) {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
}
exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => {
authInternal(ctx, user, err)
delete user.token
ctx.body = { user }
})(ctx, next)
}
/**
* Reset the user password, used as part of a forgotten password flow.
*/
exports.reset = async ctx => {
const { email } = ctx.request.body
const configured = await isEmailConfigured()
if (!configured) {
ctx.throw(
400,
"Please contact your platform administrator, SMTP is not configured."
)
}
try {
const user = await getGlobalUserByEmail(email)
await sendEmail(email, EmailTemplatePurpose.PASSWORD_RECOVERY, { user })
} catch (err) {
// don't throw any kind of error to the user, this might give away something
}
ctx.body = {
message: "Please check your email for a reset link.",
}
}
/**
* Perform the user password update if the provided reset code is valid.
*/
exports.resetUpdate = async ctx => {
const { resetCode, password } = ctx.request.body
try {
const userId = await checkResetPasswordCode(resetCode)
const db = new CouchDB(GLOBAL_DB)
const user = await db.get(userId)
user.password = await hash(password)
await db.put(user)
ctx.body = {
message: "password reset successfully.",
}
} catch (err) {
ctx.throw(400, "Cannot reset password.")
}
}
exports.logout = async ctx => {
clearCookie(ctx, Cookies.Auth)
ctx.body = { message: "User logged out." }
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
*/
exports.googlePreAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(strategy, {
scope: ["profile", "email"],
})(ctx, next)
}
exports.googleAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, user) => {
authInternal(ctx, user, err)
ctx.redirect("/")
}
)(ctx, next)
}

View File

@ -3,10 +3,15 @@ const {
generateConfigID, generateConfigID,
StaticDatabases, StaticDatabases,
getConfigParams, getConfigParams,
determineScopedConfig, getGlobalUserParams,
getScopedFullConfig,
} = require("@budibase/auth").db } = require("@budibase/auth").db
const fetch = require("node-fetch")
const { Configs } = require("../../../constants") const { Configs } = require("../../../constants")
const email = require("../../../utilities/email") const email = require("../../../utilities/email")
const env = require("../../../environment")
const APP_PREFIX = "app_"
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = StaticDatabases.GLOBAL.name
@ -45,9 +50,12 @@ exports.save = async function (ctx) {
exports.fetch = async function (ctx) { exports.fetch = async function (ctx) {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const response = await db.allDocs( const response = await db.allDocs(
getConfigParams(undefined, { getConfigParams(
{ type: ctx.params.type },
{
include_docs: true, include_docs: true,
}) }
)
) )
ctx.body = response.rows.map(row => row.doc) ctx.body = response.rows.map(row => row.doc)
} }
@ -58,11 +66,10 @@ exports.fetch = async function (ctx) {
*/ */
exports.find = async function (ctx) { exports.find = async function (ctx) {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const userId = ctx.params.user && ctx.params.user._id
const { group } = ctx.query const { userId, groupId } = ctx.query
if (group) { if (groupId && userId) {
const group = await db.get(group) const group = await db.get(groupId)
const userInGroup = group.users.some(groupUser => groupUser === userId) const userInGroup = group.users.some(groupUser => groupUser === userId)
if (!ctx.user.admin && !userInGroup) { if (!ctx.user.admin && !userInGroup) {
ctx.throw(400, `User is not in specified group: ${group}.`) ctx.throw(400, `User is not in specified group: ${group}.`)
@ -71,10 +78,10 @@ exports.find = async function (ctx) {
try { try {
// Find the config with the most granular scope based on context // Find the config with the most granular scope based on context
const scopedConfig = await determineScopedConfig(db, { const scopedConfig = await getScopedFullConfig(db, {
type: ctx.params.type, type: ctx.params.type,
user: userId, user: userId,
group, group: groupId,
}) })
if (scopedConfig) { if (scopedConfig) {
@ -98,3 +105,41 @@ exports.destroy = async function (ctx) {
ctx.throw(err.status, err) ctx.throw(err.status, err)
} }
} }
exports.configChecklist = async function (ctx) {
const db = new CouchDB(GLOBAL_DB)
try {
// TODO: Watch get started video
// Apps exist
let allDbs
if (env.COUCH_DB_URL) {
allDbs = await (await fetch(`${env.COUCH_DB_URL}/_all_dbs`)).json()
} else {
allDbs = await CouchDB.allDbs()
}
const appDbNames = allDbs.filter(dbName => dbName.startsWith(APP_PREFIX))
// They have set up SMTP
const smtpConfig = await getScopedFullConfig(db, {
type: Configs.SMTP,
})
// They have set up an admin user
const users = await db.allDocs(
getGlobalUserParams(null, {
include_docs: true,
})
)
const adminUser = users.rows.some(row => row.doc.admin)
ctx.body = {
apps: appDbNames.length,
smtp: !!smtpConfig,
adminUser,
}
} catch (err) {
ctx.throw(err.status, err)
}
}

View File

@ -1,82 +1,17 @@
const { sendEmail } = require("../../../utilities/email")
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { StaticDatabases, determineScopedConfig } = require("@budibase/auth").db const authPkg = require("@budibase/auth")
const {
EmailTemplatePurpose,
TemplateTypes,
Configs,
} = require("../../../constants")
const { getTemplateByPurpose } = require("../../../constants/templates")
const { getSettingsTemplateContext } = require("../../../utilities/templates")
const { processString } = require("@budibase/string-templates")
const { createSMTPTransport } = require("../../../utilities/email")
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
const TYPE = TemplateTypes.EMAIL
const FULL_EMAIL_PURPOSES = [
EmailTemplatePurpose.INVITATION,
EmailTemplatePurpose.PASSWORD_RECOVERY,
EmailTemplatePurpose.WELCOME,
]
async function buildEmail(purpose, email, user) {
// this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
throw `Unable to build an email of type ${purpose}`
}
let [base, styles, body] = await Promise.all([
getTemplateByPurpose(TYPE, EmailTemplatePurpose.BASE),
getTemplateByPurpose(TYPE, EmailTemplatePurpose.STYLES),
getTemplateByPurpose(TYPE, purpose),
])
if (!base || !styles || !body) {
throw "Unable to build email, missing base components"
}
base = base.contents
styles = styles.contents
body = body.contents
// TODO: need to extend the context as much as possible
const context = {
...(await getSettingsTemplateContext()),
email,
user: user || {},
}
body = await processString(body, context)
styles = await processString(styles, context)
// this should now be the complete email HTML
return processString(base, {
...context,
styles,
body,
})
}
exports.sendEmail = async ctx => { exports.sendEmail = async ctx => {
const { groupId, email, userId, purpose } = ctx.request.body const { groupId, email, userId, purpose } = ctx.request.body
const db = new CouchDB(GLOBAL_DB) let user
const params = {}
if (groupId) {
params.group = groupId
}
params.type = Configs.SMTP
let user = {}
if (userId) { if (userId) {
user = db.get(userId) const db = new CouchDB(GLOBAL_DB)
user = await db.get(userId)
} }
const { config } = await determineScopedConfig(db, params) const response = await sendEmail(email, purpose, { groupId, user })
if (!config) {
ctx.throw(400, "Unable to find SMTP configuration")
}
const transport = createSMTPTransport(config)
const message = {
from: config.from,
subject: config.subject,
to: email,
html: await buildEmail(purpose, email, user),
}
const response = await transport.sendMail(message)
ctx.body = { ctx.body = {
...response, ...response,
message: `Email sent to ${email}.`, message: `Email sent to ${email}.`,

View File

@ -5,10 +5,10 @@ const {
StaticDatabases, StaticDatabases,
} = require("@budibase/auth").db } = require("@budibase/auth").db
const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils
const { UserStatus } = require("../../../constants") const { UserStatus, EmailTemplatePurpose } = require("../../../constants")
const { checkInviteCode } = require("../../../utilities/redis")
const { sendEmail } = require("../../../utilities/email")
const FIRST_USER_EMAIL = "test@test.com"
const FIRST_USER_PASSWORD = "test"
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = StaticDatabases.GLOBAL.name
exports.save = async ctx => { exports.save = async ctx => {
@ -42,7 +42,7 @@ exports.save = async ctx => {
user.status = UserStatus.ACTIVE user.status = UserStatus.ACTIVE
} }
try { try {
const response = await db.post({ const response = await db.put({
password: hashedPassword, password: hashedPassword,
...user, ...user,
}) })
@ -60,14 +60,29 @@ exports.save = async ctx => {
} }
} }
exports.firstUser = async ctx => { exports.adminUser = async ctx => {
const db = new CouchDB(GLOBAL_DB)
const response = await db.allDocs(
getGlobalUserParams(null, {
include_docs: true,
})
)
if (response.rows.some(row => row.doc.admin)) {
ctx.throw(403, "You cannot initialise once an admin user has been created.")
}
const { email, password } = ctx.request.body
ctx.request.body = { ctx.request.body = {
email: FIRST_USER_EMAIL, email: email,
password: FIRST_USER_PASSWORD, password: password,
roles: {}, roles: {},
builder: { builder: {
global: true, global: true,
}, },
admin: {
global: true,
},
} }
await exports.save(ctx) await exports.save(ctx)
} }
@ -114,3 +129,29 @@ exports.find = async ctx => {
} }
ctx.body = user ctx.body = user
} }
exports.invite = async ctx => {
const { email } = ctx.request.body
const existing = await getGlobalUserByEmail(email)
if (existing) {
ctx.throw(400, "Email address already in use.")
}
await sendEmail(email, EmailTemplatePurpose.INVITATION)
ctx.body = {
message: "Invitation has been sent.",
}
}
exports.inviteAccept = async ctx => {
const { inviteCode } = ctx.request.body
try {
const email = await checkInviteCode(inviteCode)
// redirect the request
delete ctx.request.body.inviteCode
ctx.request.body.email = email
// this will flesh out the body response
await exports.save(ctx)
} catch (err) {
ctx.throw(400, "Unable to create new user, invitation invalid.")
}
}

View File

@ -1,93 +0,0 @@
const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware")
const { Configs } = require("../../constants")
const CouchDB = require("../../db")
const { clearCookie } = authPkg.utils
const { Cookies } = authPkg.constants
const { passport } = authPkg.auth
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
delete user.token
ctx.body = { user }
})(ctx, next)
}
exports.logout = async ctx => {
clearCookie(ctx, Cookies.Auth)
ctx.body = { message: "User logged out" }
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
*/
exports.googlePreAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.determineScopedConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(strategy, {
scope: ["profile", "email"],
})(ctx, next)
}
exports.googleAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.determineScopedConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
ctx.redirect("/")
}
)(ctx, next)
}

View File

@ -4,15 +4,38 @@ const zlib = require("zlib")
const { routes } = require("./routes") const { routes } = require("./routes")
const { buildAuthMiddleware } = require("@budibase/auth").auth const { buildAuthMiddleware } = require("@budibase/auth").auth
const NO_AUTH_ENDPOINTS = [ const PUBLIC_ENDPOINTS = [
"/api/admin/users/first", {
"/api/admin/auth", route: "/api/admin/users/init",
"/api/admin/auth/google", method: "POST",
"/api/admin/auth/google/callback", },
{
route: "/api/admin/users/invite/accept",
method: "POST",
},
{
route: "/api/admin/auth",
method: "POST",
},
{
route: "/api/admin/auth/google",
method: "GET",
},
{
route: "/api/admin/auth/google/callback",
method: "GET",
},
{
route: "/api/admin/auth/reset",
method: "POST",
},
{
route: "/api/admin/configs/checklist",
method: "GET",
},
] ]
const router = new Router() const router = new Router()
router router
.use( .use(
compress({ compress({
@ -27,7 +50,7 @@ router
}) })
) )
.use("/health", ctx => (ctx.status = 200)) .use("/health", ctx => (ctx.status = 200))
.use(buildAuthMiddleware(NO_AUTH_ENDPOINTS)) .use(buildAuthMiddleware(PUBLIC_ENDPOINTS))
// for now no public access is allowed to worker (bar health check) // for now no public access is allowed to worker (bar health check)
.use((ctx, next) => { .use((ctx, next) => {
if (!ctx.isAuthenticated) { if (!ctx.isAuthenticated) {

View File

@ -0,0 +1,43 @@
const Router = require("@koa/router")
const authController = require("../../controllers/admin/auth")
const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi")
const router = Router()
function buildAuthValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}).required().unknown(false))
}
function buildResetValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
email: Joi.string().required(),
}).required().unknown(false))
}
function buildResetUpdateValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
resetCode: Joi.string().required(),
password: Joi.string().required(),
}).required().unknown(false))
}
router
.post("/api/admin/auth", buildAuthValidation(), authController.authenticate)
.post("/api/admin/auth/reset", buildResetValidation(), authController.reset)
.post(
"/api/admin/auth/reset/update",
buildResetUpdateValidation(),
authController.resetUpdate
)
.post("/api/admin/auth/logout", authController.logout)
.get("/api/admin/auth/google", authController.googlePreAuth)
.get("/api/admin/auth/google/callback", authController.googleAuth)
module.exports = router

View File

@ -57,14 +57,27 @@ function buildConfigSaveValidation() {
{ is: Configs.GOOGLE, then: googleValidation() } { is: Configs.GOOGLE, then: googleValidation() }
], ],
}), }),
}), }).required(),
) )
} }
function buildConfigGetValidation() {
// prettier-ignore
return joiValidator.params(Joi.object({
type: Joi.string().valid(...Object.values(Configs)).required()
}).unknown(true).required())
}
router router
.post("/api/admin/configs", buildConfigSaveValidation(), controller.save) .post("/api/admin/configs", buildConfigSaveValidation(), controller.save)
.delete("/api/admin/configs/:id", controller.destroy) .delete("/api/admin/configs/:id", controller.destroy)
.get("/api/admin/configs", controller.fetch) .get("/api/admin/configs", controller.fetch)
.get("/api/admin/configs/:type", controller.find) .get("/api/admin/configs/checklist", controller.configChecklist)
.get(
"/api/admin/configs/all/:type",
buildConfigGetValidation(),
controller.fetch
)
.get("/api/admin/configs/:type", buildConfigGetValidation(), controller.find)
module.exports = router module.exports = router

View File

@ -21,14 +21,35 @@ function buildUserSaveValidation() {
.pattern(/.*/, Joi.string()) .pattern(/.*/, Joi.string())
.required() .required()
.unknown(true) .unknown(true)
}).required().unknown(true).optional()) }).required().unknown(true))
}
function buildInviteValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
email: Joi.string().required(),
}).required())
}
function buildInviteAcceptValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
inviteCode: Joi.string().required(),
password: Joi.string().required(),
}).required().unknown(true))
} }
router router
.post("/api/admin/users", buildUserSaveValidation(), controller.save) .post("/api/admin/users", buildUserSaveValidation(), controller.save)
.get("/api/admin/users", controller.fetch) .get("/api/admin/users", controller.fetch)
.post("/api/admin/users/first", controller.firstUser) .post("/api/admin/users/init", controller.adminUser)
.delete("/api/admin/users/:id", controller.destroy) .delete("/api/admin/users/:id", controller.destroy)
.get("/api/admin/users/:id", controller.find) .get("/api/admin/users/:id", controller.find)
.post("/api/admin/users/invite", buildInviteValidation(), controller.invite)
.post(
"/api/admin/users/invite/accept",
buildInviteAcceptValidation(),
controller.inviteAccept
)
module.exports = router module.exports = router

View File

@ -1,12 +0,0 @@
const Router = require("@koa/router")
const authController = require("../controllers/auth")
const router = Router()
router
.post("/api/admin/auth", authController.authenticate)
.get("/api/admin/auth/google", authController.googlePreAuth)
.get("/api/admin/auth/google/callback", authController.googleAuth)
.post("/api/admin/auth/logout", authController.logout)
module.exports = router

View File

@ -3,7 +3,7 @@ const configRoutes = require("./admin/configs")
const groupRoutes = require("./admin/groups") const groupRoutes = require("./admin/groups")
const templateRoutes = require("./admin/templates") const templateRoutes = require("./admin/templates")
const emailRoutes = require("./admin/email") const emailRoutes = require("./admin/email")
const authRoutes = require("./auth") const authRoutes = require("./admin/auth")
const appRoutes = require("./app") const appRoutes = require("./app")
exports.routes = [ exports.routes = [

View File

@ -0,0 +1,49 @@
const setup = require("./utilities")
jest.mock("nodemailer")
const sendMailMock = setup.emailMock()
describe("/api/admin/auth", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let code
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to generate password reset email", async () => {
// initially configure settings
await config.saveSmtpConfig()
await config.saveSettingsConfig()
await config.createUser("test@test.com")
const res = await request
.post(`/api/admin/auth/reset`)
.send({
email: "test@test.com",
})
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ message: "Please check your email for a reset link." })
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/reset?code=")
code = parts[1].split("\"")[0]
expect(code).toBeDefined()
})
it("should allow resetting user password with code", async () => {
const res = await request
.post(`/api/admin/auth/reset/update`)
.send({
password: "newpassword",
resetCode: code,
})
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ message: "password reset successfully." })
})
})

View File

@ -0,0 +1,38 @@
const setup = require("./utilities")
// mock the email system
const sendMailMock = jest.fn()
jest.mock("nodemailer")
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
verify: jest.fn()
})
describe("/api/admin/configs/checklist", () => {
let request = setup.getRequest()
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should return the correct checklist status based on the state of the budibase installation", async () => {
// initially configure settings
await config.saveAdminUser()
await config.saveSmtpConfig()
const res = await request
.get(`/api/admin/configs/checklist`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const checklist = res.body
expect(checklist.apps).toBe(0)
expect(checklist.smtp).toBe(true)
expect(checklist.adminUser).toBe(true)
})
})

View File

@ -2,13 +2,8 @@ const setup = require("./utilities")
const { EmailTemplatePurpose } = require("../../../constants") const { EmailTemplatePurpose } = require("../../../constants")
// mock the email system // mock the email system
const sendMailMock = jest.fn()
jest.mock("nodemailer") jest.mock("nodemailer")
const nodemailer = require("nodemailer") const sendMailMock = setup.emailMock()
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
})
describe("/api/admin/email", () => { describe("/api/admin/email", () => {
let request = setup.getRequest() let request = setup.getRequest()

View File

@ -16,11 +16,13 @@ describe("/api/admin/email", () => {
async function sendRealEmail(purpose) { async function sendRealEmail(purpose) {
await config.saveEtherealSmtpConfig() await config.saveEtherealSmtpConfig()
await config.saveSettingsConfig() await config.saveSettingsConfig()
const user = await config.getUser("test@test.com")
const res = await request const res = await request
.post(`/api/admin/email/send`) .post(`/api/admin/email/send`)
.send({ .send({
email: "test@test.com", email: "test@test.com",
purpose, purpose,
userId: user._id,
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
@ -55,6 +57,6 @@ describe("/api/admin/email", () => {
}) })
it("should be able to send a password recovery email", async () => { it("should be able to send a password recovery email", async () => {
const res = await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY) await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY)
}) })
}) })

View File

@ -0,0 +1,52 @@
const setup = require("./utilities")
jest.mock("nodemailer")
const sendMailMock = setup.emailMock()
describe("/api/admin/users", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let code
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to generate an invitation", async () => {
// initially configure settings
await config.saveSmtpConfig()
await config.saveSettingsConfig()
const res = await request
.post(`/api/admin/users/invite`)
.send({
email: "invite@test.com",
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ message: "Invitation has been sent." })
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/invite?code=")
code = parts[1].split("\"")[0]
expect(code).toBeDefined()
})
it("should be able to create new user from invite", async () => {
const res = await request
.post(`/api/admin/users/invite/accept`)
.send({
password: "newpassword",
inviteCode: code,
})
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toBeDefined()
const user = await config.getUser("invite@test.com")
expect(user).toBeDefined()
expect(user._id).toEqual(res.body._id)
})
})

View File

@ -4,6 +4,7 @@ const supertest = require("supertest")
const { jwt } = require("@budibase/auth").auth const { jwt } = require("@budibase/auth").auth
const { Cookies } = require("@budibase/auth").constants const { Cookies } = require("@budibase/auth").constants
const { Configs, LOGO_URL } = require("../../../../constants") const { Configs, LOGO_URL } = require("../../../../constants")
const { getGlobalUserByEmail } = require("@budibase/auth").utils
class TestConfiguration { class TestConfiguration {
constructor(openServer = true) { constructor(openServer = true) {
@ -53,6 +54,12 @@ class TestConfiguration {
) )
} }
async end() {
if (this.server) {
await this.server.close()
}
}
defaultHeaders() { defaultHeaders() {
const user = { const user = {
_id: "us_uuid1", _id: "us_uuid1",
@ -65,6 +72,25 @@ class TestConfiguration {
} }
} }
async getUser(email) {
return getGlobalUserByEmail(email)
}
async createUser(email = "test@test.com", password = "test") {
const user = await this.getUser(email)
if (user) {
return user
}
await this._req(
{
email,
password,
},
null,
controllers.users.save
)
}
async deleteConfig(type) { async deleteConfig(type) {
try { try {
const cfg = await this._req( const cfg = await this._req(
@ -105,6 +131,22 @@ class TestConfiguration {
) )
} }
async saveOAuthConfig() {
await this.deleteConfig(Configs.GOOGLE)
await this._req(
{
type: Configs.GOOGLE,
config: {
callbackURL: "http://somecallbackurl",
clientID: "clientId",
clientSecret: "clientSecret",
},
},
null,
controllers.config.save
)
}
async saveSmtpConfig() { async saveSmtpConfig() {
await this.deleteConfig(Configs.SMTP) await this.deleteConfig(Configs.SMTP)
await this._req( await this._req(
@ -141,6 +183,17 @@ class TestConfiguration {
controllers.config.save controllers.config.save
) )
} }
async saveAdminUser() {
await this._req(
{
email: "testuser@test.com",
password: "test@test.com",
},
null,
controllers.users.adminUser
)
}
} }
module.exports = TestConfiguration module.exports = TestConfiguration

View File

@ -7,9 +7,9 @@ exports.beforeAll = () => {
request = config.getRequest() request = config.getRequest()
} }
exports.afterAll = () => { exports.afterAll = async () => {
if (config) { if (config) {
config.end() await config.end()
} }
request = null request = null
config = null config = null
@ -28,3 +28,14 @@ exports.getConfig = () => {
} }
return config return config
} }
exports.emailMock = () => {
// mock the email system
const sendMailMock = jest.fn()
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn(),
})
return sendMailMock
}

View File

@ -45,6 +45,8 @@ const TemplateBindings = {
LOGIN_URL: "loginUrl", LOGIN_URL: "loginUrl",
CURRENT_YEAR: "currentYear", CURRENT_YEAR: "currentYear",
CURRENT_DATE: "currentDate", CURRENT_DATE: "currentDate",
RESET_CODE: "resetCode",
INVITE_CODE: "inviteCode",
} }
const TemplateMetadata = { const TemplateMetadata = {

View File

@ -11,7 +11,7 @@ function isTest() {
} }
let LOADED = false let LOADED = false
if (!LOADED && isDev()) { if (!LOADED && isDev() && !isTest()) {
require("dotenv").config() require("dotenv").config()
LOADED = true LOADED = true
} }
@ -26,6 +26,8 @@ module.exports = {
LOG_LEVEL: process.env.LOG_LEVEL, LOG_LEVEL: process.env.LOG_LEVEL,
JWT_SECRET: process.env.JWT_SECRET, JWT_SECRET: process.env.JWT_SECRET,
SALT_ROUNDS: process.env.SALT_ROUNDS, SALT_ROUNDS: process.env.SALT_ROUNDS,
REDIS_URL: process.env.REDIS_URL,
REDIS_PASSWORD: process.env.REDIS_PASSWORD,
/* TODO: to remove - once deployment removed */ /* TODO: to remove - once deployment removed */
SELF_HOST_KEY: process.env.SELF_HOST_KEY, SELF_HOST_KEY: process.env.SELF_HOST_KEY,
COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME, COUCH_DB_USERNAME: process.env.COUCH_DB_USERNAME,

View File

@ -12,10 +12,6 @@ const api = require("./api")
const app = new Koa() const app = new Koa()
if (!env.SELF_HOSTED) {
throw "Currently this service only supports use in self hosting"
}
// set up top level koa middleware // set up top level koa middleware
app.use(koaBody({ multipart: true })) app.use(koaBody({ multipart: true }))

View File

@ -1,6 +1,22 @@
const nodemailer = require("nodemailer") const nodemailer = require("nodemailer")
const CouchDB = require("../db")
const { StaticDatabases, getScopedConfig } = require("@budibase/auth").db
const { EmailTemplatePurpose, TemplateTypes, Configs } = require("../constants")
const { getTemplateByPurpose } = require("../constants/templates")
const { getSettingsTemplateContext } = require("./templates")
const { processString } = require("@budibase/string-templates")
const { getResetPasswordCode, getInviteCode } = require("../utilities/redis")
exports.createSMTPTransport = config => { const GLOBAL_DB = StaticDatabases.GLOBAL.name
const TYPE = TemplateTypes.EMAIL
const FULL_EMAIL_PURPOSES = [
EmailTemplatePurpose.INVITATION,
EmailTemplatePurpose.PASSWORD_RECOVERY,
EmailTemplatePurpose.WELCOME,
]
function createSMTPTransport(config) {
const options = { const options = {
port: config.port, port: config.port,
host: config.host, host: config.host,
@ -15,7 +31,118 @@ exports.createSMTPTransport = config => {
return nodemailer.createTransport(options) return nodemailer.createTransport(options)
} }
async function getLinkCode(purpose, email, user) {
switch (purpose) {
case EmailTemplatePurpose.PASSWORD_RECOVERY:
return getResetPasswordCode(user._id)
case EmailTemplatePurpose.INVITATION:
return getInviteCode(email)
default:
return null
}
}
/**
* Builds an email using handlebars and the templates found in the system (default or otherwise).
* @param {string} purpose the purpose of the email being built, e.g. invitation, password reset.
* @param {string} email the address which it is being sent to for contextual purposes.
* @param {object|null} user If being sent to an existing user then the object can be provided for context.
* @return {Promise<string>} returns the built email HTML if all provided parameters were valid.
*/
async function buildEmail(purpose, email, user) {
// this isn't a full email
if (FULL_EMAIL_PURPOSES.indexOf(purpose) === -1) {
throw `Unable to build an email of type ${purpose}`
}
let [base, styles, body] = await Promise.all([
getTemplateByPurpose(TYPE, EmailTemplatePurpose.BASE),
getTemplateByPurpose(TYPE, EmailTemplatePurpose.STYLES),
getTemplateByPurpose(TYPE, purpose),
])
if (!base || !styles || !body) {
throw "Unable to build email, missing base components"
}
base = base.contents
styles = styles.contents
body = body.contents
// if there is a link code needed this will retrieve it
const code = await getLinkCode(purpose, email, user)
const context = {
...(await getSettingsTemplateContext(purpose, code)),
email,
user: user || {},
}
body = await processString(body, context)
styles = await processString(styles, context)
// this should now be the complete email HTML
return processString(base, {
...context,
styles,
body,
})
}
/**
* Utility function for finding most valid SMTP configuration.
* @param {object} db The CouchDB database which is to be looked up within.
* @param {string|null} groupId If using finer grain control of configs a group can be used.
* @return {Promise<object|null>} returns the SMTP configuration if it exists
*/
async function getSmtpConfiguration(db, groupId = null) {
const params = {
type: Configs.SMTP,
}
if (groupId) {
params.group = groupId
}
return getScopedConfig(db, params)
}
/**
* Checks if a SMTP config exists based on passed in parameters.
* @param groupId
* @return {Promise<boolean>} returns true if there is a configuration that can be used.
*/
exports.isEmailConfigured = async (groupId = null) => {
const db = new CouchDB(GLOBAL_DB)
const config = await getSmtpConfiguration(db, groupId)
return config != null
}
/**
* Given an email address and an email purpose this will retrieve the SMTP configuration and
* send an email using it.
* @param {string} email The email address to send to.
* @param {string} purpose The purpose of the email being sent (e.g. reset password).
* @param {string|undefined} groupId If finer grain controls being used then this will lookup config for group.
* @param {object|undefined} user if sending to an existing user the object can be provided, this is used in the context.
* @return {Promise<object>} returns details about the attempt to send email, e.g. if it is successful; based on
* nodemailer response.
*/
exports.sendEmail = async (email, purpose, { groupId, user } = {}) => {
const db = new CouchDB(GLOBAL_DB)
const config = await getSmtpConfiguration(db, groupId)
if (!config) {
throw "Unable to find SMTP configuration."
}
const transport = createSMTPTransport(config)
const message = {
from: config.from,
subject: config.subject,
to: email,
html: await buildEmail(purpose, email, user),
}
return transport.sendMail(message)
}
/**
* Given an SMTP configuration this runs it through nodemailer to see if it is infact functional.
* @param {object} config an SMTP configuration - this is based on the nodemailer API.
* @return {Promise<boolean>} returns true if the configuration is valid.
*/
exports.verifyConfig = async config => { exports.verifyConfig = async config => {
const transport = exports.createSMTPTransport(config) const transport = createSMTPTransport(config)
await transport.verify() await transport.verify()
} }

View File

@ -0,0 +1,85 @@
const { Client, utils } = require("@budibase/auth").redis
const { newid } = require("@budibase/auth").utils
function getExpirySecondsForDB(db) {
switch (db) {
case utils.Databases.PW_RESETS:
// a hour
return 3600
case utils.Databases.INVITATIONS:
// a day
return 86400
}
}
async function getClient(db) {
return await new Client(db).init()
}
async function writeACode(db, value) {
const client = await getClient(db)
const code = newid()
await client.store(code, value, getExpirySecondsForDB(db))
client.finish()
return code
}
async function getACode(db, code, deleteCode = true) {
const client = await getClient(db)
const value = await client.get(code)
if (!value) {
throw "Invalid code."
}
if (deleteCode) {
await client.delete(code)
}
client.finish()
return value
}
/**
* Given a user ID this will store a code (that is returned) for an hour in redis.
* The user can then return this code for resetting their password (through their reset link).
* @param {string} userId the ID of the user which is to be reset.
* @return {Promise<string>} returns the code that was stored to redis.
*/
exports.getResetPasswordCode = async userId => {
return writeACode(utils.Databases.PW_RESETS, userId)
}
/**
* Given a reset code this will lookup to redis, check if the code is valid and delete if required.
* @param {string} resetCode The code provided via the email link.
* @param {boolean} deleteCode If the code is used/finished with this will delete it - defaults to true.
* @return {Promise<string>} returns the user ID if it is found
*/
exports.checkResetPasswordCode = async (resetCode, deleteCode = true) => {
try {
return getACode(utils.Databases.PW_RESETS, resetCode, deleteCode)
} catch (err) {
throw "Provided information is not valid, cannot reset password - please try again."
}
}
/**
* Generates an invitation code and writes it to redis - which can later be checked for user creation.
* @param {string} email the email address which the code is being sent to (for use later).
* @return {Promise<string>} returns the code that was stored to redis.
*/
exports.getInviteCode = async email => {
return writeACode(utils.Databases.INVITATIONS, email)
}
/**
* Checks that the provided invite code is valid - will return the email address of user that was invited.
* @param {string} inviteCode the invite code that was provided as part of the link.
* @param {boolean} deleteCode whether or not the code should be deleted after retrieval - defaults to true.
* @return {Promise<string>} If the code is valid then an email address will be returned.
*/
exports.checkInviteCode = async (inviteCode, deleteCode = true) => {
try {
return getACode(utils.Databases.INVITATIONS, inviteCode, deleteCode)
} catch (err) {
throw "Invitation is not valid or has expired, please request a new one."
}
}

View File

@ -1,32 +1,28 @@
const CouchDB = require("../db") const CouchDB = require("../db")
const { getConfigParams, StaticDatabases } = require("@budibase/auth").db const { getScopedConfig, StaticDatabases } = require("@budibase/auth").db
const { Configs, TemplateBindings, LOGO_URL } = require("../constants") const {
Configs,
TemplateBindings,
LOGO_URL,
EmailTemplatePurpose,
} = require("../constants")
const { checkSlashesInUrl } = require("./index") const { checkSlashesInUrl } = require("./index")
const env = require("../environment") const env = require("../environment")
const LOCAL_URL = `http://localhost:${env.PORT}` const LOCAL_URL = `http://localhost:${env.PORT}`
const BASE_COMPANY = "Budibase" const BASE_COMPANY = "Budibase"
exports.getSettingsTemplateContext = async () => { exports.getSettingsTemplateContext = async (purpose, code = null) => {
const db = new CouchDB(StaticDatabases.GLOBAL.name) const db = new CouchDB(StaticDatabases.GLOBAL.name)
const response = await db.allDocs( // TODO: use more granular settings in the future if required
getConfigParams(Configs.SETTINGS, { const settings = await getScopedConfig(db, { type: Configs.SETTINGS })
include_docs: true,
})
)
let settings = response.rows.map(row => row.doc)[0] || {}
if (!settings.platformUrl) { if (!settings.platformUrl) {
settings.platformUrl = LOCAL_URL settings.platformUrl = LOCAL_URL
} }
// TODO: need to fully spec out the context
const URL = settings.platformUrl const URL = settings.platformUrl
return { const context = {
[TemplateBindings.LOGO_URL]: settings.logoUrl || LOGO_URL, [TemplateBindings.LOGO_URL]: settings.logoUrl || LOGO_URL,
[TemplateBindings.PLATFORM_URL]: URL, [TemplateBindings.PLATFORM_URL]: URL,
[TemplateBindings.REGISTRATION_URL]: checkSlashesInUrl(
`${URL}/registration`
),
[TemplateBindings.RESET_URL]: checkSlashesInUrl(`${URL}/reset`),
[TemplateBindings.COMPANY]: settings.company || BASE_COMPANY, [TemplateBindings.COMPANY]: settings.company || BASE_COMPANY,
[TemplateBindings.DOCS_URL]: [TemplateBindings.DOCS_URL]:
settings.docsUrl || "https://docs.budibase.com/", settings.docsUrl || "https://docs.budibase.com/",
@ -34,4 +30,20 @@ exports.getSettingsTemplateContext = async () => {
[TemplateBindings.CURRENT_DATE]: new Date().toISOString(), [TemplateBindings.CURRENT_DATE]: new Date().toISOString(),
[TemplateBindings.CURRENT_YEAR]: new Date().getFullYear(), [TemplateBindings.CURRENT_YEAR]: new Date().getFullYear(),
} }
// attach purpose specific context
switch (purpose) {
case EmailTemplatePurpose.PASSWORD_RECOVERY:
context[TemplateBindings.RESET_CODE] = code
context[TemplateBindings.RESET_URL] = checkSlashesInUrl(
`${URL}/reset?code=${code}`
)
break
case EmailTemplatePurpose.INVITATION:
context[TemplateBindings.INVITE_CODE] = code
context[TemplateBindings.REGISTRATION_URL] = checkSlashesInUrl(
`${URL}/invite?code=${code}`
)
break
}
return context
} }

File diff suppressed because it is too large Load Diff