Merge pull request #1414 from Budibase/feature/smtp-templates

System for templating and sending SMTP emails
This commit is contained in:
Michael Drury 2021-04-26 16:08:53 +01:00 committed by GitHub
commit e08df4110a
57 changed files with 6055 additions and 226 deletions

View File

@ -14,6 +14,8 @@ const DocumentTypes = {
USER: "us", USER: "us",
APP: "app", APP: "app",
GROUP: "group", GROUP: "group",
CONFIG: "config",
TEMPLATE: "template",
} }
exports.DocumentTypes = DocumentTypes exports.DocumentTypes = DocumentTypes
@ -46,14 +48,14 @@ exports.getGroupParams = (id = "", otherProps = {}) => {
* Generates a new global user ID. * Generates a new global user ID.
* @returns {string} The new user ID which the user doc can be stored under. * @returns {string} The new user ID which the user doc can be stored under.
*/ */
exports.generateGlobalUserID = () => { exports.generateGlobalUserID = id => {
return `${DocumentTypes.USER}${SEPARATOR}${newid()}` return `${DocumentTypes.USER}${SEPARATOR}${id || newid()}`
} }
/** /**
* Gets parameters for retrieving users. * Gets parameters for retrieving users.
*/ */
exports.getGlobalUserParams = (globalId = "", otherProps = {}) => { exports.getGlobalUserParams = (globalId, otherProps = {}) => {
if (!globalId) { if (!globalId) {
globalId = "" globalId = ""
} }
@ -63,3 +65,98 @@ exports.getGlobalUserParams = (globalId = "", otherProps = {}) => {
endkey: `${DocumentTypes.USER}${SEPARATOR}${globalId}${UNICODE_MAX}`, endkey: `${DocumentTypes.USER}${SEPARATOR}${globalId}${UNICODE_MAX}`,
} }
} }
/**
* Generates a template ID.
* @param ownerId The owner/user of the template, this could be global or a group level.
*/
exports.generateTemplateID = ownerId => {
return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${newid()}`
}
/**
* Gets parameters for retrieving templates. Owner ID must be specified, either global or a group level.
*/
exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => {
if (!templateId) {
templateId = ""
}
let final
if (templateId) {
final = templateId
} else {
final = `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}`
}
return {
...otherProps,
startkey: final,
endkey: `${final}${UNICODE_MAX}`,
}
}
/**
* Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under.
*/
const generateConfigID = ({ type, group, user }) => {
const scope = [type, group, user].filter(Boolean).join(SEPARATOR)
return `${DocumentTypes.CONFIG}${SEPARATOR}${scope}`
}
/**
* Gets parameters for retrieving configurations.
*/
const getConfigParams = ({ type, group, user }, otherProps = {}) => {
const scope = [type, group, user].filter(Boolean).join(SEPARATOR)
return {
...otherProps,
startkey: `${DocumentTypes.CONFIG}${SEPARATOR}${scope}`,
endkey: `${DocumentTypes.CONFIG}${SEPARATOR}${scope}${UNICODE_MAX}`,
}
}
/**
* Returns the most granular configuration document from the DB based on the type, group and userID passed.
* @param {Object} db - db instance to query
* @param {Object} scopes - the type, group and userID scopes of the configuration.
* @returns The most granular configuration document based on the scope.
*/
const determineScopedConfig = async function(db, { type, user, group }) {
const response = await db.allDocs(
getConfigParams(
{ type, user, group },
{
include_docs: true,
}
)
)
const configs = response.rows.map(row => {
const config = row.doc
// Config is specific to a user and a group
if (config._id.includes(generateConfigID({ type, user, group }))) {
config.score = 4
} else if (config._id.includes(generateConfigID({ type, user }))) {
// Config is specific to a user only
config.score = 3
} else if (config._id.includes(generateConfigID({ type, group }))) {
// Config is specific to a group only
config.score = 2
} else if (config._id.includes(generateConfigID({ type }))) {
// Config is specific to a type only
config.score = 1
}
return config
})
// Find the config with the most granular scope based on context
const scopedConfig = configs.sort((a, b) => b.score - a.score)[0]
return scopedConfig
}
exports.generateConfigID = generateConfigID
exports.getConfigParams = getConfigParams
exports.determineScopedConfig = determineScopedConfig

View File

@ -2,7 +2,4 @@ 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,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
GOOGLE_AUTH_CALLBACK_URL: process.env.GOOGLE_AUTH_CALLBACK_URL,
} }

View File

@ -1,31 +1,13 @@
const passport = require("koa-passport") const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
// const GoogleStrategy = require("passport-google-oauth").Strategy
const { setDB, getDB } = require("./db")
const { StaticDatabases } = require("./db/utils") const { StaticDatabases } = require("./db/utils")
const { jwt, local, authenticated } = require("./middleware") const { jwt, local, authenticated, google } = require("./middleware")
const { Cookies, UserStatus } = require("./constants") const { setDB, getDB } = require("./db")
const { hash, compare } = require("./hashing")
const {
getAppId,
setCookie,
getCookie,
clearCookie,
isClient,
getGlobalUserByEmail,
} = require("./utils")
const {
generateGlobalUserID,
getGlobalUserParams,
generateGroupID,
getGroupParams,
} = require("./db/utils")
// Strategies // Strategies
passport.use(new LocalStrategy(local.options, local.authenticate)) passport.use(new LocalStrategy(local.options, local.authenticate))
passport.use(new JwtStrategy(jwt.options, jwt.authenticate)) passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
// passport.use(new GoogleStrategy(google.options, google.authenticate))
passport.serializeUser((user, done) => done(null, user)) passport.serializeUser((user, done) => done(null, user))
@ -45,21 +27,17 @@ module.exports = {
init(pouch) { init(pouch) {
setDB(pouch) setDB(pouch)
}, },
db: require("./db/utils"),
utils: {
...require("./utils"),
...require("./hashing"),
},
auth: {
buildAuthMiddleware: authenticated,
passport, passport,
Cookies, google,
UserStatus, jwt: require("jsonwebtoken"),
},
StaticDatabases, StaticDatabases,
generateGlobalUserID, constants: require("./constants"),
getGlobalUserParams,
generateGroupID,
getGroupParams,
hash,
compare,
getAppId,
setCookie,
getCookie,
clearCookie,
authenticated,
isClient,
getGlobalUserByEmail,
} }

View File

@ -1,18 +1,39 @@
const { Cookies } = require("../constants") const { Cookies } = require("../constants")
const { getCookie } = require("../utils") const database = require("../db")
const { getCookie, clearCookie } = require("../utils")
const { StaticDatabases } = require("../db/utils")
module.exports = async (ctx, next) => { module.exports = (noAuthPatterns = []) => {
const regex = new RegExp(noAuthPatterns.join("|"))
return async (ctx, next) => {
// the path is not authenticated
if (regex.test(ctx.request.url)) {
return next()
}
try { try {
// check the actual user is authenticated first // check the actual user is authenticated first
const authCookie = getCookie(ctx, Cookies.Auth) const authCookie = getCookie(ctx, Cookies.Auth)
if (authCookie) { if (authCookie) {
try {
const db = database.getDB(StaticDatabases.GLOBAL.name)
const user = await db.get(authCookie.userId)
delete user.password
ctx.isAuthenticated = true ctx.isAuthenticated = true
ctx.user = authCookie ctx.user = user
} catch (err) {
// remove the cookie as the use does not exist anymore
clearCookie(ctx, Cookies.Auth)
}
}
// be explicit
if (ctx.isAuthenticated !== true) {
ctx.isAuthenticated = false
} }
await next() return next()
} catch (err) { } catch (err) {
ctx.throw(err.status || 403, err) ctx.throw(err.status || 403, err)
} }
} }
}

View File

@ -1,12 +1,76 @@
const env = require("../../environment") const env = require("../../environment")
const jwt = require("jsonwebtoken")
const database = require("../../db")
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
const { StaticDatabases, generateGlobalUserID } = require("../../db/utils")
exports.options = { async function authenticate(token, tokenSecret, profile, done) {
clientId: env.GOOGLE_CLIENT_ID, // Check the user exists in the instance DB by email
clientSecret: env.GOOGLE_CLIENT_SECRET, const db = database.getDB(StaticDatabases.GLOBAL.name)
callbackURL: env.GOOGLE_AUTH_CALLBACK_URL,
let dbUser
const userId = generateGlobalUserID(profile.id)
try {
// use the google profile id
dbUser = await db.get(userId)
} catch (err) {
console.error("Google user not found. Creating..")
// create the user
const user = {
_id: userId,
provider: profile.provider,
roles: {},
builder: {
global: true,
},
...profile._json,
}
const response = await db.post(user)
dbUser = user
dbUser._rev = response.rev
} }
// exports.authenticate = async function(token, tokenSecret, profile, done) { // authenticate
// // retrieve user ... const payload = {
// fetchUser().then(user => done(null, user)) userId: dbUser._id,
// } builder: dbUser.builder,
email: dbUser.email,
}
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: "1 day",
})
return done(null, dbUser)
}
/**
* Create an instance of the google passport strategy. This wrapper fetches the configuration
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport Google Strategy
*/
exports.strategyFactory = async function(config) {
try {
const { clientID, clientSecret, callbackURL } = config
if (!clientID || !clientSecret || !callbackURL) {
throw new Error(
"Configuration invalid. Must contain google clientID, clientSecret and callbackURL"
)
}
return new GoogleStrategy(
{
clientID: config.clientID,
clientSecret: config.clientSecret,
callbackURL: config.callbackURL,
},
authenticate
)
} catch (err) {
console.error(err)
throw new Error("Error constructing google authentication strategy", err)
}
}

View File

@ -33,8 +33,6 @@ exports.authenticate = async function(email, password, done) {
if (await compare(password, dbUser.password)) { if (await compare(password, dbUser.password)) {
const payload = { const payload = {
userId: dbUser._id, userId: dbUser._id,
builder: dbUser.builder,
email: dbUser.email,
} }
dbUser.token = jwt.sign(payload, env.JWT_SECRET, { dbUser.token = jwt.sign(payload, env.JWT_SECRET, {

View File

@ -92,7 +92,7 @@ exports.setCookie = (ctx, value, name = "builder") => {
* Utility function, simply calls setCookie with an empty string for value * Utility function, simply calls setCookie with an empty string for value
*/ */
exports.clearCookie = (ctx, name) => { exports.clearCookie = (ctx, name) => {
exports.setCookie(ctx, "", name) exports.setCookie(ctx, null, name)
} }
/** /**

View File

@ -39,6 +39,7 @@
<Input outline type="password" on:change bind:value={password} /> <Input outline type="password" on:change bind:value={password} />
<Spacer large /> <Spacer large />
<Button primary on:click={login}>Login</Button> <Button primary on:click={login}>Login</Button>
<a target="_blank" href="/api/admin/auth/google">Sign In With Google</a>
<Button secondary on:click={createTestUser}>Create Test User</Button> <Button secondary on:click={createTestUser}>Create Test User</Button>
</form> </form>

View File

@ -103,7 +103,6 @@
"jimp": "0.16.1", "jimp": "0.16.1",
"joi": "17.2.1", "joi": "17.2.1",
"jsonschema": "1.4.0", "jsonschema": "1.4.0",
"jsonwebtoken": "8.5.1",
"koa": "2.7.0", "koa": "2.7.0",
"koa-body": "4.2.0", "koa-body": "4.2.0",
"koa-compress": "4.0.1", "koa-compress": "4.0.1",

View File

@ -72,7 +72,7 @@ exports.createMetadata = async function(ctx) {
exports.updateSelfMetadata = async function(ctx) { exports.updateSelfMetadata = async function(ctx) {
// overwrite the ID with current users // overwrite the ID with current users
ctx.request.body._id = ctx.user.userId ctx.request.body._id = ctx.user._id
// make sure no stale rev // make sure no stale rev
delete ctx.request.body._rev delete ctx.request.body._rev
await exports.updateMetadata(ctx) await exports.updateMetadata(ctx)

View File

@ -1,14 +1,21 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const { authenticated } = require("@budibase/auth") const { buildAuthMiddleware } = require("@budibase/auth").auth
const currentApp = require("../middleware/currentapp") const currentApp = require("../middleware/currentapp")
const compress = require("koa-compress") const compress = require("koa-compress")
const zlib = require("zlib") const zlib = require("zlib")
const { mainRoutes, authRoutes, staticRoutes } = require("./routes") const { mainRoutes, staticRoutes } = require("./routes")
const pkg = require("../../package.json") const pkg = require("../../package.json")
const router = new Router() const router = new Router()
const env = require("../environment") const env = require("../environment")
const NO_AUTH_ENDPOINTS = [
"/health",
"/version",
"webhooks/trigger",
"webhooks/schema",
]
router router
.use( .use(
compress({ compress({
@ -31,7 +38,7 @@ 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(authenticated) .use(buildAuthMiddleware(NO_AUTH_ENDPOINTS))
.use(currentApp) .use(currentApp)
// error handling middleware // error handling middleware
@ -53,9 +60,6 @@ router.use(async (ctx, next) => {
router.get("/health", ctx => (ctx.status = 200)) router.get("/health", ctx => (ctx.status = 200))
router.use(authRoutes.routes())
router.use(authRoutes.allowedMethods())
// authenticated routes // authenticated routes
for (let route of mainRoutes) { for (let route of mainRoutes) {
router.use(route.routes()) router.use(route.routes())

View File

@ -25,6 +25,7 @@ const backupRoutes = require("./backup")
const devRoutes = require("./dev") const devRoutes = require("./dev")
exports.mainRoutes = [ exports.mainRoutes = [
authRoutes,
deployRoutes, deployRoutes,
layoutRoutes, layoutRoutes,
screenRoutes, screenRoutes,
@ -52,5 +53,4 @@ exports.mainRoutes = [
rowRoutes, rowRoutes,
] ]
exports.authRoutes = authRoutes
exports.staticRoutes = staticRoutes exports.staticRoutes = staticRoutes

View File

@ -93,7 +93,7 @@ describe("/queries", () => {
const query = await config.createQuery() const query = await config.createQuery()
const res = await request const res = await request
.get(`/api/queries/${query._id}`) .get(`/api/queries/${query._id}`)
.set(await config.roleHeaders()) .set(await config.roleHeaders({}))
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.fields).toBeUndefined() expect(res.body.fields).toBeUndefined()

View File

@ -35,7 +35,11 @@ describe("/routing", () => {
}) })
const res = await request const res = await request
.get(`/api/routing/client`) .get(`/api/routing/client`)
.set(await config.roleHeaders("basic@test.com", BUILTIN_ROLE_IDS.BASIC)) .set(await config.roleHeaders({
email: "basic@test.com",
roleId: BUILTIN_ROLE_IDS.BASIC,
builder: false
}))
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.routes).toBeDefined() expect(res.body.routes).toBeDefined()
@ -59,7 +63,11 @@ describe("/routing", () => {
}) })
const res = await request const res = await request
.get(`/api/routing/client`) .get(`/api/routing/client`)
.set(await config.roleHeaders("basic@test.com", BUILTIN_ROLE_IDS.POWER)) .set(await config.roleHeaders({
email: "basic@test.com",
roleId: BUILTIN_ROLE_IDS.POWER,
builder: false,
}))
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
.expect(200) .expect(200)
expect(res.body.routes).toBeDefined() expect(res.body.routes).toBeDefined()

View File

@ -5,7 +5,9 @@ const { basicUser } = setup.structures
const workerRequests = require("../../../utilities/workerRequests") const workerRequests = require("../../../utilities/workerRequests")
jest.mock("../../../utilities/workerRequests", () => ({ jest.mock("../../../utilities/workerRequests", () => ({
getGlobalUsers: jest.fn(), getGlobalUsers: jest.fn(() => {
return {}
}),
saveGlobalUser: jest.fn(() => { saveGlobalUser: jest.fn(() => {
const uuid = require("uuid/v4") const uuid = require("uuid/v4")
return { return {

View File

@ -46,7 +46,10 @@ exports.createRequest = (request, method, url, body) => {
} }
exports.checkBuilderEndpoint = async ({ config, method, url, body }) => { exports.checkBuilderEndpoint = async ({ config, method, url, body }) => {
const headers = await config.login() const headers = await config.login("test@test.com", "test", {
userId: "us_fail",
builder: false,
})
await exports await exports
.createRequest(config.request, method, url, body) .createRequest(config.request, method, url, body)
.set(headers) .set(headers)
@ -62,9 +65,10 @@ exports.checkPermissionsEndpoint = async ({
failRole, failRole,
}) => { }) => {
const password = "PASSWORD" const password = "PASSWORD"
await config.createUser("passUser@budibase.com", password, passRole) let user = await config.createUser("pass@budibase.com", password, passRole)
const passHeader = await config.login("passUser@budibase.com", password, { const passHeader = await config.login("pass@budibase.com", password, {
roleId: passRole, roleId: passRole,
userId: user.globalId,
}) })
await exports await exports
@ -72,9 +76,10 @@ exports.checkPermissionsEndpoint = async ({
.set(passHeader) .set(passHeader)
.expect(200) .expect(200)
await config.createUser("failUser@budibase.com", password, failRole) user = await config.createUser("fail@budibase.com", password, failRole)
const failHeader = await config.login("failUser@budibase.com", password, { const failHeader = await config.login("fail@budibase.com", password, {
roleId: failRole, roleId: failRole,
userId: user.globalId,
}) })
await exports await exports

View File

@ -1,5 +1,5 @@
const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles") const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles")
const { UserStatus } = require("@budibase/auth") const { UserStatus } = require("@budibase/auth").constants
exports.LOGO_URL = exports.LOGO_URL =
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"

View File

@ -107,8 +107,7 @@ exports.getRowParams = (tableId = null, rowId = null, otherProps = {}) => {
return getDocParams(DocumentTypes.ROW, null, otherProps) return getDocParams(DocumentTypes.ROW, null, otherProps)
} }
const endOfKey = const endOfKey = rowId == null ? `${tableId}${SEPARATOR}` : rowId
rowId == null ? `${tableId}${SEPARATOR}` : `${tableId}${SEPARATOR}${rowId}`
return getDocParams(DocumentTypes.ROW, endOfKey, otherProps) return getDocParams(DocumentTypes.ROW, endOfKey, otherProps)
} }

View File

@ -1,11 +1,9 @@
const { getAppId, setCookie, getCookie, Cookies } = require("@budibase/auth") const { getAppId, setCookie, getCookie } = require("@budibase/auth").utils
const { Cookies } = require("@budibase/auth").constants
const { getRole } = require("../utilities/security/roles") const { getRole } = require("../utilities/security/roles")
const { getGlobalUsers } = require("../utilities/workerRequests") const { getGlobalUsers } = require("../utilities/workerRequests")
const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles") const { BUILTIN_ROLE_IDS } = require("../utilities/security/roles")
const { const { generateUserMetadataID } = require("../db/utils")
getGlobalIDFromUserMetadataID,
generateUserMetadataID,
} = require("../db/utils")
module.exports = async (ctx, next) => { module.exports = async (ctx, next) => {
// try to get the appID from the request // try to get the appID from the request
@ -30,8 +28,7 @@ module.exports = async (ctx, next) => {
appCookie.roleId === BUILTIN_ROLE_IDS.PUBLIC) appCookie.roleId === BUILTIN_ROLE_IDS.PUBLIC)
) { ) {
// Different App ID means cookie needs reset, or if the same public user has logged in // Different App ID means cookie needs reset, or if the same public user has logged in
const globalId = getGlobalIDFromUserMetadataID(ctx.user.userId) const globalUser = await getGlobalUsers(ctx, requestAppId, ctx.user._id)
const globalUser = await getGlobalUsers(ctx, requestAppId, globalId)
updateCookie = true updateCookie = true
appId = requestAppId appId = requestAppId
if (globalUser.roles && globalUser.roles[requestAppId]) { if (globalUser.roles && globalUser.roles[requestAppId]) {
@ -49,7 +46,7 @@ module.exports = async (ctx, next) => {
ctx.appId = appId ctx.appId = appId
if (roleId) { if (roleId) {
ctx.roleId = roleId ctx.roleId = roleId
const userId = ctx.user ? generateUserMetadataID(ctx.user.userId) : null const userId = ctx.user ? generateUserMetadataID(ctx.user._id) : null
ctx.user = { ctx.user = {
...ctx.user, ...ctx.user,
// override userID with metadata one // override userID with metadata one

View File

@ -5,7 +5,7 @@ function mockWorker() {
jest.mock("../../utilities/workerRequests", () => ({ jest.mock("../../utilities/workerRequests", () => ({
getGlobalUsers: () => { getGlobalUsers: () => {
return { return {
email: "us_uuid1", _id: "us_uuid1",
roles: { roles: {
"app_test": "BASIC", "app_test": "BASIC",
} }
@ -23,10 +23,14 @@ function mockAuthWithNoCookie() {
jest.resetModules() jest.resetModules()
mockWorker() mockWorker()
jest.mock("@budibase/auth", () => ({ jest.mock("@budibase/auth", () => ({
utils: {
getAppId: jest.fn(), getAppId: jest.fn(),
setCookie: jest.fn(), setCookie: jest.fn(),
getCookie: jest.fn(), getCookie: jest.fn(),
},
constants: {
Cookies: {}, Cookies: {},
},
})) }))
} }
@ -34,15 +38,19 @@ function mockAuthWithCookie() {
jest.resetModules() jest.resetModules()
mockWorker() mockWorker()
jest.mock("@budibase/auth", () => ({ jest.mock("@budibase/auth", () => ({
utils: {
getAppId: () => { getAppId: () => {
return "app_test" return "app_test"
}, },
setCookie: jest.fn(), setCookie: jest.fn(),
getCookie: () => ({appId: "app_different", roleId: "PUBLIC"}), getCookie: () => ({appId: "app_different", roleId: "PUBLIC"}),
},
constants: {
Cookies: { Cookies: {
Auth: "auth", Auth: "auth",
CurrentApp: "currentapp", CurrentApp: "currentapp",
} },
},
})) }))
} }
@ -59,7 +67,8 @@ class TestConfiguration {
setUser() { setUser() {
this.ctx.user = { this.ctx.user = {
userId: "ro_ta_user_us_uuid1", userId: "us_uuid1",
_id: "us_uuid1",
} }
} }
@ -102,7 +111,7 @@ describe("Current app middleware", () => {
async function checkExpected(setCookie) { async function checkExpected(setCookie) {
config.setUser() config.setUser()
await config.executeMiddleware() await config.executeMiddleware()
const cookieFn = require("@budibase/auth").setCookie const cookieFn = require("@budibase/auth").utils.setCookie
if (setCookie) { if (setCookie) {
expect(cookieFn).toHaveBeenCalled() expect(cookieFn).toHaveBeenCalled()
} else { } else {
@ -122,12 +131,16 @@ describe("Current app middleware", () => {
it("should perform correct when no cookie exists", async () => { it("should perform correct when no cookie exists", async () => {
mockReset() mockReset()
jest.mock("@budibase/auth", () => ({ jest.mock("@budibase/auth", () => ({
utils: {
getAppId: () => { getAppId: () => {
return "app_test" return "app_test"
}, },
setCookie: jest.fn(), setCookie: jest.fn(),
getCookie: jest.fn(), getCookie: jest.fn(),
},
constants: {
Cookies: {}, Cookies: {},
},
})) }))
await checkExpected(true) await checkExpected(true)
}) })
@ -135,15 +148,16 @@ describe("Current app middleware", () => {
it("lastly check what occurs when cookie doesn't need updated", async () => { it("lastly check what occurs when cookie doesn't need updated", async () => {
mockReset() mockReset()
jest.mock("@budibase/auth", () => ({ jest.mock("@budibase/auth", () => ({
utils: {
getAppId: () => { getAppId: () => {
return "app_test" return "app_test"
}, },
setCookie: jest.fn(), setCookie: jest.fn(),
getCookie: () => ({appId: "app_test", roleId: "BASIC"}), getCookie: () => ({appId: "app_test", roleId: "BASIC"}),
Cookies: {}, },
constants: { Cookies: {} },
})) }))
await checkExpected(false) await checkExpected(false)
}) })
}) })
}) })

View File

@ -1,5 +1,4 @@
const { BUILTIN_ROLE_IDS } = require("../../utilities/security/roles") const { BUILTIN_ROLE_IDS } = require("../../utilities/security/roles")
const jwt = require("jsonwebtoken")
const env = require("../../environment") const env = require("../../environment")
const { const {
basicTable, basicTable,
@ -15,8 +14,12 @@ const {
const controllers = require("./controllers") const controllers = require("./controllers")
const supertest = require("supertest") const supertest = require("supertest")
const { cleanup } = require("../../utilities/fileSystem") const { cleanup } = require("../../utilities/fileSystem")
const { Cookies } = require("@budibase/auth") const { Cookies } = require("@budibase/auth").constants
const { jwt } = require("@budibase/auth").auth
const { StaticDatabases } = require("@budibase/auth").db
const CouchDB = require("../../db")
const GLOBAL_USER_ID = "us_uuid1"
const EMAIL = "babs@babs.com" const EMAIL = "babs@babs.com"
const PASSWORD = "babs_password" const PASSWORD = "babs_password"
@ -47,6 +50,7 @@ class TestConfiguration {
request.config = { jwtSecret: env.JWT_SECRET } request.config = { jwtSecret: env.JWT_SECRET }
request.appId = this.appId request.appId = this.appId
request.user = { appId: this.appId } request.user = { appId: this.appId }
request.query = {}
request.request = { request.request = {
body: config, body: config,
} }
@ -57,7 +61,27 @@ class TestConfiguration {
return request.body return request.body
} }
async globalUser(id = GLOBAL_USER_ID, builder = true) {
const db = new CouchDB(StaticDatabases.GLOBAL.name)
let existing
try {
existing = await db.get(id)
} catch (err) {
existing = {}
}
const user = {
_id: id,
...existing,
roles: {},
}
if (builder) {
user.builder = { global: true }
}
await db.put(user)
}
async init(appName = "test_application") { async init(appName = "test_application") {
await this.globalUser()
return this.createApp(appName) return this.createApp(appName)
} }
@ -69,17 +93,14 @@ class TestConfiguration {
} }
defaultHeaders() { defaultHeaders() {
const user = { const auth = {
userId: "ro_ta_user_us_uuid1", userId: GLOBAL_USER_ID,
builder: {
global: true,
},
} }
const app = { const app = {
roleId: BUILTIN_ROLE_IDS.BUILDER, roleId: BUILTIN_ROLE_IDS.BUILDER,
appId: this.appId, appId: this.appId,
} }
const authToken = jwt.sign(user, env.JWT_SECRET) const authToken = jwt.sign(auth, env.JWT_SECRET)
const appToken = jwt.sign(app, env.JWT_SECRET) const appToken = jwt.sign(app, env.JWT_SECRET)
const headers = { const headers = {
Accept: "application/json", Accept: "application/json",
@ -104,14 +125,18 @@ class TestConfiguration {
return headers return headers
} }
async roleHeaders(email = EMAIL, roleId = BUILTIN_ROLE_IDS.ADMIN) { async roleHeaders({
email = EMAIL,
roleId = BUILTIN_ROLE_IDS.ADMIN,
builder = false,
}) {
let user let user
try { try {
user = await this.createUser(email, PASSWORD, roleId) user = await this.createUser(email, PASSWORD, roleId)
} catch (err) { } catch (err) {
// allow errors here // allow errors here
} }
return this.login(email, PASSWORD, { roleId, userId: user._id }) return this.login(email, PASSWORD, { roleId, userId: user._id, builder })
} }
async createApp(appName) { async createApp(appName) {
@ -282,7 +307,9 @@ class TestConfiguration {
password = PASSWORD, password = PASSWORD,
roleId = BUILTIN_ROLE_IDS.POWER roleId = BUILTIN_ROLE_IDS.POWER
) { ) {
return this._req( const globalId = `us_${Math.random()}`
await this.globalUser(globalId, roleId === BUILTIN_ROLE_IDS.BUILDER)
const user = await this._req(
{ {
email, email,
password, password,
@ -291,28 +318,34 @@ class TestConfiguration {
null, null,
controllers.user.createMetadata controllers.user.createMetadata
) )
return {
...user,
globalId,
}
} }
async login(email, password, { roleId, userId } = {}) { async login(email, password, { roleId, userId, builder } = {}) {
if (!roleId) { roleId = !roleId ? BUILTIN_ROLE_IDS.BUILDER : roleId
roleId = BUILTIN_ROLE_IDS.BUILDER userId = !userId ? `us_uuid1` : userId
}
if (!this.request) { if (!this.request) {
throw "Server has not been opened, cannot login." throw "Server has not been opened, cannot login."
} }
// make sure the user exists in the global DB
if (roleId !== BUILTIN_ROLE_IDS.PUBLIC) {
await this.globalUser(userId, builder)
}
if (!email || !password) { if (!email || !password) {
await this.createUser() await this.createUser()
} }
// have to fake this // have to fake this
const user = { const auth = {
userId: userId || `us_uuid1`, userId,
email: email || EMAIL,
} }
const app = { const app = {
roleId: roleId, roleId: roleId,
appId: this.appId, appId: this.appId,
} }
const authToken = jwt.sign(user, env.JWT_SECRET) const authToken = jwt.sign(auth, env.JWT_SECRET)
const appToken = jwt.sign(app, env.JWT_SECRET) const appToken = jwt.sign(app, env.JWT_SECRET)
// returning necessary request headers // returning necessary request headers

139
packages/worker/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,139 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Start Server",
"program": "${workspaceFolder}/src/index.js"
},
{
"type": "node",
"request": "launch",
"name": "Jest - All",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": [],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Users",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["user.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Instances",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["instance.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Roles",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["role.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Records",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["record.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Models",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["table.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Views",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["view.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest - Applications",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["application.spec", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Jest Builder",
"program": "${workspaceFolder}/node_modules/.bin/jest",
"args": ["builder", "--runInBand"],
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"disableOptimisticBPs": true,
"windows": {
"program": "${workspaceFolder}/node_modules/jest-cli/bin/jest",
}
},
{
"type": "node",
"request": "launch",
"name": "Initialise Budibase",
"program": "yarn",
"args": ["run", "initialise"],
"console": "externalTerminal"
}
]
}

View File

@ -20,6 +20,7 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "0.0.1", "@budibase/auth": "0.0.1",
"@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",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -35,6 +36,7 @@
"koa-session": "^5.12.0", "koa-session": "^5.12.0",
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"nodemailer": "^6.5.0",
"passport-google-oauth": "^2.0.0", "passport-google-oauth": "^2.0.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
@ -44,7 +46,9 @@
"server-destroy": "^1.0.1" "server-destroy": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"jest": "^26.6.3",
"nodemon": "^2.0.7", "nodemon": "^2.0.7",
"pouchdb-adapter-memory": "^7.2.2" "pouchdb-adapter-memory": "^7.2.2",
"supertest": "^6.1.3"
} }
} }

View File

@ -0,0 +1,103 @@
const CouchDB = require("../../../db")
const {
generateConfigID,
StaticDatabases,
getConfigParams,
determineScopedConfig,
} = require("@budibase/auth").db
const { Configs } = require("../../../constants")
const email = require("../../../utilities/email")
const GLOBAL_DB = StaticDatabases.GLOBAL.name
exports.save = async function(ctx) {
const db = new CouchDB(GLOBAL_DB)
const { type, config } = ctx.request.body
const { group, user } = config
// insert the type into the doc
config.type = type
// Config does not exist yet
if (!config._id) {
config._id = generateConfigID({
type,
group,
user,
})
}
// verify the configuration
switch (type) {
case Configs.SMTP:
await email.verifyConfig(config)
break
}
try {
const response = await db.put(config)
ctx.body = {
type,
_id: response.id,
_rev: response.rev,
}
} catch (err) {
ctx.throw(err.status, err)
}
}
exports.fetch = async function(ctx) {
const db = new CouchDB(GLOBAL_DB)
const response = await db.allDocs(
getConfigParams(undefined, {
include_docs: true,
})
)
ctx.body = response.rows.map(row => row.doc)
}
/**
* Gets the most granular config for a particular configuration type.
* The hierarchy is type -> group -> user.
*/
exports.find = async function(ctx) {
const db = new CouchDB(GLOBAL_DB)
const userId = ctx.params.user && ctx.params.user._id
const { group } = ctx.query
if (group) {
const group = await db.get(group)
const userInGroup = group.users.some(groupUser => groupUser === userId)
if (!ctx.user.admin && !userInGroup) {
ctx.throw(400, `User is not in specified group: ${group}.`)
}
}
try {
// Find the config with the most granular scope based on context
const scopedConfig = await determineScopedConfig(db, {
type: ctx.params.type,
user: userId,
group,
})
if (scopedConfig) {
ctx.body = scopedConfig
} else {
ctx.throw(400, "No configuration exists.")
}
} catch (err) {
ctx.throw(err.status, err)
}
}
exports.destroy = async function(ctx) {
const db = new CouchDB(GLOBAL_DB)
const { id, rev } = ctx.params
try {
await db.remove(id, rev)
ctx.body = { message: "Config deleted successfully" }
} catch (err) {
ctx.throw(err.status, err)
}
}

View File

@ -0,0 +1,84 @@
const CouchDB = require("../../../db")
const { StaticDatabases, determineScopedConfig } = require("@budibase/auth").db
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 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 => {
const { groupId, email, userId, purpose } = ctx.request.body
const db = new CouchDB(GLOBAL_DB)
const params = {}
if (groupId) {
params.group = groupId
}
params.type = Configs.SMTP
let user = {}
if (userId) {
user = db.get(userId)
}
const config = await determineScopedConfig(db, params)
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 = {
...response,
message: `Email sent to ${email}.`,
}
}

View File

@ -1,6 +1,9 @@
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { getGroupParams, StaticDatabases } = require("@budibase/auth") const {
const { generateGroupID } = require("@budibase/auth") getGroupParams,
generateGroupID,
StaticDatabases,
} = require("@budibase/auth").db
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = StaticDatabases.GLOBAL.name

View File

@ -1,7 +0,0 @@
const users = require("./users")
const groups = require("./groups")
module.exports = {
users,
groups,
}

View File

@ -0,0 +1,67 @@
const { generateTemplateID, StaticDatabases } = require("@budibase/auth").db
const { CouchDB } = require("../../../db")
const {
TemplateMetadata,
TemplateBindings,
GLOBAL_OWNER,
} = require("../../../constants")
const { getTemplates } = require("../../../constants/templates")
const GLOBAL_DB = StaticDatabases.GLOBAL.name
exports.save = async ctx => {
const db = new CouchDB(GLOBAL_DB)
const type = ctx.params.type
let template = ctx.request.body
if (!template.ownerId) {
template.ownerId = GLOBAL_OWNER
}
if (!template._id) {
template._id = generateTemplateID(template.ownerId)
}
const response = await db.put({
...template,
type,
})
ctx.body = {
...template,
_rev: response.rev,
}
}
exports.definitions = async ctx => {
ctx.body = {
purpose: TemplateMetadata,
bindings: Object.values(TemplateBindings),
}
}
exports.fetch = async ctx => {
ctx.body = await getTemplates()
}
exports.fetchByType = async ctx => {
ctx.body = await getTemplates({
type: ctx.params.type,
})
}
exports.fetchByOwner = async ctx => {
ctx.body = await getTemplates({
ownerId: ctx.params.ownerId,
})
}
exports.find = async ctx => {
ctx.body = await getTemplates({
id: ctx.params.id,
})
}
exports.destroy = async ctx => {
const db = new CouchDB(GLOBAL_DB)
await db.remove(ctx.params.id, ctx.params.rev)
ctx.message = `Template ${ctx.params.id} deleted.`
ctx.status = 200
}

View File

@ -1,18 +1,17 @@
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { const {
hash,
generateGlobalUserID, generateGlobalUserID,
getGlobalUserParams, getGlobalUserParams,
StaticDatabases, StaticDatabases,
getGlobalUserByEmail, } = require("@budibase/auth").db
} = require("@budibase/auth") const { hash, getGlobalUserByEmail } = require("@budibase/auth").utils
const { UserStatus } = require("../../../constants") const { UserStatus } = require("../../../constants")
const FIRST_USER_EMAIL = "test@test.com" const FIRST_USER_EMAIL = "test@test.com"
const FIRST_USER_PASSWORD = "test" const FIRST_USER_PASSWORD = "test"
const GLOBAL_DB = StaticDatabases.GLOBAL.name const GLOBAL_DB = StaticDatabases.GLOBAL.name
exports.userSave = async ctx => { exports.save = async ctx => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const { email, password, _id } = ctx.request.body const { email, password, _id } = ctx.request.body
@ -70,10 +69,10 @@ exports.firstUser = async ctx => {
global: true, global: true,
}, },
} }
await exports.userSave(ctx) await exports.save(ctx)
} }
exports.userDelete = async ctx => { exports.destroy = async ctx => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const dbUser = await db.get(ctx.params.id) const dbUser = await db.get(ctx.params.id)
await db.remove(dbUser._id, dbUser._rev) await db.remove(dbUser._id, dbUser._rev)
@ -83,7 +82,7 @@ exports.userDelete = async ctx => {
} }
// called internally by app server user fetch // called internally by app server user fetch
exports.userFetch = async ctx => { exports.fetch = async ctx => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
const response = await db.allDocs( const response = await db.allDocs(
getGlobalUserParams(null, { getGlobalUserParams(null, {
@ -101,7 +100,7 @@ exports.userFetch = async ctx => {
} }
// called internally by app server user find // called internally by app server user find
exports.userFind = async ctx => { exports.find = async ctx => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
let user let user
try { try {

View File

@ -1,4 +1,12 @@
const { passport, Cookies, clearCookie } = require("@budibase/auth") 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) => { exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => { return passport.authenticate("local", async (err, user) => {
@ -31,10 +39,55 @@ exports.logout = async ctx => {
ctx.body = { message: "User logged out" } ctx.body = { message: "User logged out" }
} }
exports.googleAuth = async () => { /**
// return passport.authenticate("google") * 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 () => { exports.googleAuth = async (ctx, next) => {
// return passport.authenticate("google") 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

@ -2,6 +2,14 @@ const Router = require("@koa/router")
const compress = require("koa-compress") const compress = require("koa-compress")
const zlib = require("zlib") const zlib = require("zlib")
const { routes } = require("./routes") const { routes } = require("./routes")
const { buildAuthMiddleware } = require("@budibase/auth").auth
const NO_AUTH_ENDPOINTS = [
"/api/admin/users/first",
"/api/admin/auth",
"/api/admin/auth/google",
"/api/admin/auth/google/callback",
]
const router = new Router() const router = new Router()
@ -19,6 +27,14 @@ router
}) })
) )
.use("/health", ctx => (ctx.status = 200)) .use("/health", ctx => (ctx.status = 200))
.use(buildAuthMiddleware(NO_AUTH_ENDPOINTS))
// for now no public access is allowed to worker (bar health check)
.use((ctx, next) => {
if (!ctx.isAuthenticated) {
ctx.throw(403, "Unauthorized - no public worker access")
}
return next()
})
// error handling middleware // error handling middleware
router.use(async (ctx, next) => { router.use(async (ctx, next) => {

View File

@ -0,0 +1,67 @@
const Router = require("@koa/router")
const controller = require("../../controllers/admin/configs")
const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi")
const { Configs } = require("../../../constants")
const router = Router()
function smtpValidation() {
// prettier-ignore
return Joi.object({
port: Joi.number().required(),
host: Joi.string().required(),
from: Joi.string().email().required(),
secure: Joi.boolean().optional(),
selfSigned: Joi.boolean().optional(),
auth: Joi.object({
type: Joi.string().valid("login", "oauth2", null),
user: Joi.string().required(),
pass: Joi.string().valid("", null),
}).optional(),
}).unknown(true)
}
function settingValidation() {
// prettier-ignore
return Joi.object({
platformUrl: Joi.string().valid("", null),
logoUrl: Joi.string().valid("", null),
docsUrl: Joi.string().valid("", null),
company: Joi.string().required(),
}).unknown(true)
}
function googleValidation() {
// prettier-ignore
return Joi.object({
clientID: Joi.string().required(),
clientSecret: Joi.string().required(),
callbackURL: Joi.string().required(),
}).unknown(true)
}
function buildConfigSaveValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
type: Joi.string().valid(...Object.values(Configs)).required(),
config: Joi.alternatives()
.conditional("type", {
switch: [
{ is: Configs.SMTP, then: smtpValidation() },
{ is: Configs.SETTINGS, then: settingValidation() },
{ is: Configs.ACCOUNT, then: Joi.object().unknown(true) },
{ is: Configs.GOOGLE, then: googleValidation() }
],
}),
}),
)
}
router
.post("/api/admin/configs", buildConfigSaveValidation(), controller.save)
.delete("/api/admin/configs/:id", controller.destroy)
.get("/api/admin/configs", controller.fetch)
.get("/api/admin/configs/:type", controller.find)
module.exports = router

View File

@ -0,0 +1,24 @@
const Router = require("@koa/router")
const controller = require("../../controllers/admin/email")
const { EmailTemplatePurpose } = require("../../../constants")
const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi")
const router = Router()
function buildEmailSendValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
email: Joi.string().email(),
groupId: Joi.string().allow("", null),
purpose: Joi.string().allow(...Object.values(EmailTemplatePurpose)),
}).required().unknown(true))
}
router.post(
"/api/admin/email/send",
buildEmailSendValidation(),
controller.sendEmail
)
module.exports = router

View File

@ -1,7 +1,6 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../../controllers/admin/groups") const controller = require("../../controllers/admin/groups")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const { authenticated } = require("@budibase/auth")
const Joi = require("joi") const Joi = require("joi")
const router = Router() const router = Router()
@ -25,14 +24,9 @@ function buildGroupSaveValidation() {
} }
router router
.post( .post("/api/admin/groups", buildGroupSaveValidation(), controller.save)
"/api/admin/groups", .get("/api/admin/groups", controller.fetch)
buildGroupSaveValidation(), .delete("/api/admin/groups/:id", controller.destroy)
authenticated, .get("/api/admin/groups/:id", controller.find)
controller.save
)
.delete("/api/admin/groups/:id", authenticated, controller.destroy)
.get("/api/admin/groups", authenticated, controller.fetch)
.get("/api/admin/groups/:id", authenticated, controller.find)
module.exports = router module.exports = router

View File

@ -0,0 +1,31 @@
const Router = require("@koa/router")
const controller = require("../../controllers/admin/templates")
const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi")
const { TemplatePurpose, TemplateTypes } = require("../../../constants")
const router = Router()
function buildTemplateSaveValidation() {
// prettier-ignore
return joiValidator.body(Joi.object({
_id: Joi.string().allow(null, ""),
_rev: Joi.string().allow(null, ""),
ownerId: Joi.string().allow(null, ""),
name: Joi.string().allow(null, ""),
contents: Joi.string().required(),
purpose: Joi.string().required().valid(...Object.values(TemplatePurpose)),
type: Joi.string().required().valid(...Object.values(TemplateTypes)),
}).required().unknown(true).optional())
}
router
.get("/api/admin/template/definitions", controller.definitions)
.post("/api/admin/template", buildTemplateSaveValidation(), controller.save)
.get("/api/admin/template", controller.fetch)
.get("/api/admin/template/:type", controller.fetchByType)
.get("/api/admin/template/:ownerId", controller.fetchByOwner)
.get("/api/admin/template/:id", controller.find)
.delete("/api/admin/template/:id/:rev", controller.destroy)
module.exports = router

View File

@ -1,7 +1,6 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../../controllers/admin/users") const controller = require("../../controllers/admin/users")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const { authenticated } = require("@budibase/auth")
const Joi = require("joi") const Joi = require("joi")
const router = Router() const router = Router()
@ -26,15 +25,10 @@ function buildUserSaveValidation() {
} }
router router
.post( .post("/api/admin/users", buildUserSaveValidation(), controller.save)
"/api/admin/users", .get("/api/admin/users", controller.fetch)
buildUserSaveValidation(),
authenticated,
controller.userSave
)
.post("/api/admin/users/first", controller.firstUser) .post("/api/admin/users/first", controller.firstUser)
.delete("/api/admin/users/:id", authenticated, controller.userDelete) .delete("/api/admin/users/:id", controller.destroy)
.get("/api/admin/users", authenticated, controller.userFetch) .get("/api/admin/users/:id", controller.find)
.get("/api/admin/users/:id", authenticated, controller.userFind)
module.exports = router module.exports = router

View File

@ -1,9 +1,8 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../controllers/app") const controller = require("../controllers/app")
const { authenticated } = require("@budibase/auth")
const router = Router() const router = Router()
router.get("/api/apps", authenticated, controller.getApps) router.get("/api/apps", controller.getApps)
module.exports = router module.exports = router

View File

@ -1,19 +1,12 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const { passport } = require("@budibase/auth")
const authController = require("../controllers/auth") const authController = require("../controllers/auth")
const router = Router() const router = Router()
router router
.post("/api/admin/auth", authController.authenticate) .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) .post("/api/admin/auth/logout", authController.logout)
.get("/api/auth/google", passport.authenticate("google"))
.get(
"/api/auth/google/callback",
passport.authenticate("google", {
successRedirect: "/app",
failureRedirect: "/",
})
)
module.exports = router module.exports = router

View File

@ -1,6 +1,17 @@
const userRoutes = require("./admin/users") const userRoutes = require("./admin/users")
const configRoutes = require("./admin/configs")
const groupRoutes = require("./admin/groups") const groupRoutes = require("./admin/groups")
const templateRoutes = require("./admin/templates")
const emailRoutes = require("./admin/email")
const authRoutes = require("./auth") const authRoutes = require("./auth")
const appRoutes = require("./app") const appRoutes = require("./app")
exports.routes = [userRoutes, groupRoutes, authRoutes, appRoutes] exports.routes = [
configRoutes,
userRoutes,
groupRoutes,
authRoutes,
appRoutes,
templateRoutes,
emailRoutes,
]

View File

@ -0,0 +1,42 @@
const setup = require("./utilities")
const { EmailTemplatePurpose } = require("../../../constants")
// mock the email system
const sendMailMock = jest.fn()
jest.mock("nodemailer")
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
})
describe("/api/admin/email", () => {
let request = setup.getRequest()
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
it("should be able to send an email (with mocking)", async () => {
// initially configure settings
await config.saveSmtpConfig()
await config.saveSettingsConfig()
const res = await request
.post(`/api/admin/email/send`)
.send({
email: "test@test.com",
purpose: EmailTemplatePurpose.INVITATION,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBeDefined()
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
expect(emailCall.subject).toBe("Hello!")
expect(emailCall.html).not.toContain("Invalid Binding")
})
})

View File

@ -0,0 +1,60 @@
const setup = require("./utilities")
const { EmailTemplatePurpose } = require("../../../constants")
const nodemailer = require("nodemailer")
const fetch = require("node-fetch")
describe("/api/admin/email", () => {
let request = setup.getRequest()
let config = setup.getConfig()
beforeAll(async () => {
await config.init()
})
afterAll(setup.afterAll)
async function sendRealEmail(purpose) {
await config.saveEtherealSmtpConfig()
await config.saveSettingsConfig()
const res = await request
.post(`/api/admin/email/send`)
.send({
email: "test@test.com",
purpose,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBeDefined()
const testUrl = nodemailer.getTestMessageUrl(res.body)
expect(testUrl).toBeDefined()
const response = await fetch(testUrl)
const text = await response.text()
let toCheckFor
switch (purpose) {
case EmailTemplatePurpose.WELCOME:
toCheckFor = `Thanks for getting started with Budibase's Budibase platform.`
break
case EmailTemplatePurpose.INVITATION:
toCheckFor = `Use the button below to set up your account and get started:`
break
case EmailTemplatePurpose.PASSWORD_RECOVERY:
toCheckFor = `You recently requested to reset your password for your Budibase account in your Budibase platform`
break
}
expect(text).toContain(toCheckFor)
}
it("should be able to send a welcome email", async () => {
await sendRealEmail(EmailTemplatePurpose.WELCOME)
})
it("should be able to send a invitation email", async () => {
await sendRealEmail(EmailTemplatePurpose.INVITATION)
})
it("should be able to send a password recovery email", async () => {
const res = await sendRealEmail(EmailTemplatePurpose.PASSWORD_RECOVERY)
})
})

View File

@ -0,0 +1,146 @@
const env = require("../../../../environment")
const controllers = require("./controllers")
const supertest = require("supertest")
const { jwt } = require("@budibase/auth").auth
const { Cookies } = require("@budibase/auth").constants
const { Configs, LOGO_URL } = require("../../../../constants")
class TestConfiguration {
constructor(openServer = true) {
if (openServer) {
env.PORT = 4003
this.server = require("../../../../index")
// we need the request for logging in, involves cookies, hard to fake
this.request = supertest(this.server)
}
}
getRequest() {
return this.request
}
async _req(config, params, controlFunc) {
const request = {}
// fake cookies, we don't need them
request.cookies = { set: () => {}, get: () => {} }
request.config = { jwtSecret: env.JWT_SECRET }
request.appId = this.appId
request.user = { appId: this.appId }
request.query = {}
request.request = {
body: config,
}
if (params) {
request.params = params
}
await controlFunc(request)
return request.body
}
async init() {
// create a test user
await this._req(
{
email: "test@test.com",
password: "test",
_id: "us_uuid1",
builder: {
global: true,
},
},
null,
controllers.users.save
)
}
defaultHeaders() {
const user = {
_id: "us_uuid1",
userId: "us_uuid1",
}
const authToken = jwt.sign(user, env.JWT_SECRET)
return {
Accept: "application/json",
Cookie: [`${Cookies.Auth}=${authToken}`],
}
}
async deleteConfig(type) {
try {
const cfg = await this._req(
null,
{
type,
},
controllers.config.find
)
if (cfg) {
await this._req(
null,
{
id: cfg._id,
rev: cfg._rev,
},
controllers.config.destroy
)
}
} catch (err) {
// don't need to handle error
}
}
async saveSettingsConfig() {
await this.deleteConfig(Configs.SETTINGS)
await this._req(
{
type: Configs.SETTINGS,
config: {
platformUrl: "http://localhost:10000",
logoUrl: LOGO_URL,
company: "Budibase",
},
},
null,
controllers.config.save
)
}
async saveSmtpConfig() {
await this.deleteConfig(Configs.SMTP)
await this._req(
{
type: Configs.SMTP,
config: {
port: 12345,
host: "smtptesthost.com",
from: "testfrom@test.com",
subject: "Hello!",
},
},
null,
controllers.config.save
)
}
async saveEtherealSmtpConfig() {
await this.deleteConfig(Configs.SMTP)
await this._req(
{
type: Configs.SMTP,
config: {
port: 587,
host: "smtp.ethereal.email",
secure: false,
auth: {
user: "don.bahringer@ethereal.email",
pass: "yCKSH8rWyUPbnhGYk9",
},
},
},
null,
controllers.config.save
)
}
}
module.exports = TestConfiguration

View File

@ -0,0 +1,7 @@
module.exports = {
email: require("../../../controllers/admin/email"),
groups: require("../../../controllers/admin/groups"),
config: require("../../../controllers/admin/configs"),
templates: require("../../../controllers/admin/templates"),
users: require("../../../controllers/admin/users"),
}

View File

@ -0,0 +1,30 @@
const TestConfig = require("./TestConfiguration")
let request, config
exports.beforeAll = () => {
config = new TestConfig()
request = config.getRequest()
}
exports.afterAll = () => {
if (config) {
config.end()
}
request = null
config = null
}
exports.getRequest = () => {
if (!request) {
exports.beforeAll()
}
return request
}
exports.getConfig = () => {
if (!config) {
exports.beforeAll()
}
return config
}

View File

@ -1,3 +1,6 @@
exports.LOGO_URL =
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
exports.UserStatus = { exports.UserStatus = {
ACTIVE: "active", ACTIVE: "active",
INACTIVE: "inactive", INACTIVE: "inactive",
@ -6,3 +9,77 @@ exports.UserStatus = {
exports.Groups = { exports.Groups = {
ALL_USERS: "all_users", ALL_USERS: "all_users",
} }
exports.Configs = {
SETTINGS: "settings",
ACCOUNT: "account",
SMTP: "smtp",
GOOGLE: "google",
}
const TemplateTypes = {
EMAIL: "email",
}
const EmailTemplatePurpose = {
BASE: "base",
STYLES: "styles",
PASSWORD_RECOVERY: "password_recovery",
INVITATION: "invitation",
WELCOME: "welcome",
CUSTOM: "custom",
}
const TemplateBindings = {
PLATFORM_URL: "platformUrl",
COMPANY: "company",
LOGO_URL: "logoUrl",
STYLES: "styles",
BODY: "body",
REGISTRATION_URL: "registrationUrl",
EMAIL: "email",
RESET_URL: "resetUrl",
USER: "user",
REQUEST: "request",
DOCS_URL: "docsUrl",
LOGIN_URL: "loginUrl",
CURRENT_YEAR: "currentYear",
CURRENT_DATE: "currentDate",
}
const TemplateMetadata = {
[TemplateTypes.EMAIL]: [
{
name: "Styling",
purpose: EmailTemplatePurpose.STYLES,
bindings: ["url", "company", "companyUrl", "styles", "body"],
},
{
name: "Base Format",
purpose: EmailTemplatePurpose.BASE,
bindings: ["company", "registrationUrl"],
},
{
name: "Password Recovery",
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
},
{
name: "New User Invitation",
purpose: EmailTemplatePurpose.INVITATION,
},
{
name: "Custom",
purpose: EmailTemplatePurpose.CUSTOM,
},
],
}
// all purpose combined
exports.TemplatePurpose = {
...EmailTemplatePurpose,
}
exports.TemplateTypes = TemplateTypes
exports.EmailTemplatePurpose = EmailTemplatePurpose
exports.TemplateMetadata = TemplateMetadata
exports.TemplateBindings = TemplateBindings
exports.GLOBAL_OWNER = "global"

View File

@ -0,0 +1,49 @@
<!-- Based on templates: https://github.com/wildbit/postmark-templates/blob/master/templates/plain -->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" />
<title></title>
<style type="text/css" rel="stylesheet" media="all">
{{ styles }}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
</head>
<body>
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="email-masthead">
<a href="{{ platformUrl }}" class="f-fallback email-masthead_name">
{{ company }}
</a>
</td>
</tr>
{{ body }}
<tr>
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
<p class="f-fallback sub align-center">&copy; {{ currentYear }} {{ company }}. All rights reserved.</p>
</td>
</tr>
</table>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,73 @@
const { readStaticFile } = require("../../utilities/fileSystem")
const {
EmailTemplatePurpose,
TemplateTypes,
TemplatePurpose,
GLOBAL_OWNER,
} = require("../index")
const { join } = require("path")
const CouchDB = require("../../db")
const { getTemplateParams, StaticDatabases } = require("@budibase/auth").db
exports.EmailTemplates = {
[EmailTemplatePurpose.PASSWORD_RECOVERY]: readStaticFile(
join(__dirname, "passwordRecovery.hbs")
),
[EmailTemplatePurpose.INVITATION]: readStaticFile(
join(__dirname, "invitation.hbs")
),
[EmailTemplatePurpose.BASE]: readStaticFile(join(__dirname, "base.hbs")),
[EmailTemplatePurpose.STYLES]: readStaticFile(join(__dirname, "style.hbs")),
[EmailTemplatePurpose.WELCOME]: readStaticFile(
join(__dirname, "welcome.hbs")
),
}
exports.addBaseTemplates = (templates, type = null) => {
let purposeList
switch (type) {
case TemplateTypes.EMAIL:
purposeList = Object.values(EmailTemplatePurpose)
break
default:
purposeList = Object.values(TemplatePurpose)
break
}
for (let purpose of purposeList) {
// check if a template exists already for purpose
if (templates.find(template => template.purpose === purpose)) {
continue
}
if (exports.EmailTemplates[purpose]) {
templates.push({
contents: exports.EmailTemplates[purpose],
purpose,
type,
})
}
}
return templates
}
exports.getTemplates = async ({ ownerId, type, id } = {}) => {
const db = new CouchDB(StaticDatabases.GLOBAL.name)
const response = await db.allDocs(
getTemplateParams(ownerId || GLOBAL_OWNER, id, {
include_docs: true,
})
)
let templates = response.rows.map(row => row.doc)
// should only be one template with ID
if (id) {
return templates[0]
}
if (type) {
templates = templates.filter(template => template.type === type)
}
return exports.addBaseTemplates(templates, type)
}
exports.getTemplateByPurpose = async (type, purpose) => {
const templates = await exports.getTemplates({ type })
return templates.find(template => template.purpose === purpose)
}

View File

@ -0,0 +1,50 @@
<!-- Based on template: https://github.com/wildbit/postmark-templates/blob/master/templates/plain/user-invitation/content.html -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi, {{ email }}!</h1>
<p>
{{#if request}}
{{ request.inviter }} has invited you to use {{ company }}'s Budibase platform.<
{{else}}
You've been invited to use {{ company }}'s Budibase platform.
{{/if}}
Use the button below to set up your account and get started:
</p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{ registrationUrl }}" class="f-fallback button" target="_blank">Set up account</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<p>If you have any questions contact your Budibase platform administrator.</p>
<p>Welcome aboard,
<br>The {{ company }} Team</p>
<p><strong>P.S.</strong> Need help getting started? Check out our <a href="{{ docsUrl }}">help documentation</a>.</p>
<!-- Sub copy -->
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">{{ registrationUrl }}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>

View File

@ -0,0 +1,45 @@
<!-- Based on template: https://github.com/wildbit/postmark-templates/blob/master/templates/plain/password-reset/content.html -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Hi {{ email }},</h1>
<p>You recently requested to reset your password for your {{ company }} account in your Budibase platform. Use the button below to reset it. <strong>This password reset is only valid for the next 24 hours.</strong></p>
<!-- Action -->
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
<tr>
<td align="center">
<a href="{{ resetUrl }}" class="f-fallback button button--green" target="_blank">Reset your password</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{#if request}}
<p>For security, this request was received from a {{ request.os }} device.</p>
{{/if}}
<p>If you did not request a password reset, please ignore this email or contact support if you have questions.</p>
<p>Thanks,
<br>The {{ company }} Team</p>
<!-- Sub copy -->
<table class="body-sub" role="presentation">
<tr>
<td>
<p class="f-fallback sub">If youre having trouble with the button above, copy and paste the URL below into your web browser.</p>
<p class="f-fallback sub">{{ resetUrl }}</p>
</td>
</tr>
</table>
</div>
</td>
</tr>
</table>
</td>
</tr>

View File

@ -0,0 +1,408 @@
/* Based on templates: https://github.com/wildbit/postmark-templates/blob/master/templates/plain */
/* Base ------------------------------ */
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+Pro&display=swap');
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Source Sans Pro", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #FFF;
color: #333;
}
p {
color: #333;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-footer {
width: 570px;
margin: 0 auto;
padding: 0;
-premailer-width: 570px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 35px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3,
span,
.purchase_item {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
:root {
color-scheme: light dark;
supported-color-schemes: light dark;
}

View File

@ -0,0 +1,45 @@
<!-- Based on template: https://github.com/wildbit/postmark-templates/blob/master/templates/plain/welcome/content.html -->
<tr>
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
<div class="f-fallback">
<h1>Welcome, {{ email }}!</h1>
<p>Thanks for getting started with {{ company }}'s Budibase platform.</p>
<p>For reference, here's how to login:</p>
<table class="attributes" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="attributes_content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="attributes_item">
<span class="f-fallback">
<strong>Login Page:</strong> <a href="{{ loginUrl }}">{{ loginUrl }}</a>
</span>
</td>
</tr>
{{#if request}}
<tr>
<td class="attributes_item">
<span class="f-fallback">
<strong>Username:</strong> {{ request.loginUsername }}
</span>
</td>
</tr>
{{/if}}
</table>
</td>
</tr>
</table>
<p>If you have any questions get in contact with your {{ company }}'s Budibase platform administration team.</p>
<p>Thanks,
<br>The {{ company }} Team</p>
<p><strong>P.S.</strong> Need immediate help getting started? Check out our <a href="{{ docsUrl }}">help documentation</a>.</p>
</div>
</td>
</tr>
</table>
</td>
</tr>

View File

@ -5,7 +5,7 @@ require("@budibase/auth").init(CouchDB)
const Koa = require("koa") const Koa = require("koa")
const destroyable = require("server-destroy") const destroyable = require("server-destroy")
const koaBody = require("koa-body") const koaBody = require("koa-body")
const { passport } = require("@budibase/auth") const { passport } = require("@budibase/auth").auth
const logger = require("koa-pino-logger") const logger = require("koa-pino-logger")
const http = require("http") const http = require("http")
const api = require("./api") const api = require("./api")

View File

@ -0,0 +1,21 @@
const nodemailer = require("nodemailer")
exports.createSMTPTransport = config => {
const options = {
port: config.port,
host: config.host,
secure: config.secure || false,
auth: config.auth,
}
if (config.selfSigned) {
options.tls = {
rejectUnauthorized: false,
}
}
return nodemailer.createTransport(options)
}
exports.verifyConfig = async config => {
const transport = exports.createSMTPTransport(config)
await transport.verify()
}

View File

@ -0,0 +1,5 @@
const { readFileSync } = require("fs")
exports.readStaticFile = path => {
return readFileSync(path, "utf-8")
}

View File

@ -0,0 +1,9 @@
/**
* Makes sure that a URL has the correct number of slashes, while maintaining the
* http(s):// double slashes.
* @param {string} url The URL to test and remove any extra double slashes.
* @return {string} The updated url.
*/
exports.checkSlashesInUrl = url => {
return url.replace(/(https?:\/\/)|(\/)+/g, "$1$2")
}

View File

@ -0,0 +1,37 @@
const CouchDB = require("../db")
const { getConfigParams, StaticDatabases } = require("@budibase/auth").db
const { Configs, TemplateBindings, LOGO_URL } = require("../constants")
const { checkSlashesInUrl } = require("./index")
const env = require("../environment")
const LOCAL_URL = `http://localhost:${env.PORT}`
const BASE_COMPANY = "Budibase"
exports.getSettingsTemplateContext = async () => {
const db = new CouchDB(StaticDatabases.GLOBAL.name)
const response = await db.allDocs(
getConfigParams(Configs.SETTINGS, {
include_docs: true,
})
)
let settings = response.rows.map(row => row.doc)[0] || {}
if (!settings.platformUrl) {
settings.platformUrl = LOCAL_URL
}
// TODO: need to fully spec out the context
const URL = settings.platformUrl
return {
[TemplateBindings.LOGO_URL]: settings.logoUrl || LOGO_URL,
[TemplateBindings.PLATFORM_URL]: URL,
[TemplateBindings.REGISTRATION_URL]: checkSlashesInUrl(
`${URL}/registration`
),
[TemplateBindings.RESET_URL]: checkSlashesInUrl(`${URL}/reset`),
[TemplateBindings.COMPANY]: settings.company || BASE_COMPANY,
[TemplateBindings.DOCS_URL]:
settings.docsUrl || "https://docs.budibase.com/",
[TemplateBindings.LOGIN_URL]: checkSlashesInUrl(`${URL}/login`),
[TemplateBindings.CURRENT_DATE]: new Date().toISOString(),
[TemplateBindings.CURRENT_YEAR]: new Date().getFullYear(),
}
}

File diff suppressed because it is too large Load Diff