mirror of https://github.com/Budibase/budibase.git
111 changed files with 3617 additions and 1305 deletions
@ -0,0 +1,78 @@ |
|||
const { Headers } = require("../constants") |
|||
const { buildMatcherRegex, matches } = require("./matchers") |
|||
|
|||
/** |
|||
* GET, HEAD and OPTIONS methods are considered safe operations |
|||
* |
|||
* POST, PUT, PATCH, and DELETE methods, being state changing verbs, |
|||
* should have a CSRF token attached to the request |
|||
*/ |
|||
const EXCLUDED_METHODS = ["GET", "HEAD", "OPTIONS"] |
|||
|
|||
/** |
|||
* There are only three content type values that can be used in cross domain requests. |
|||
* If any other value is used, e.g. application/json, the browser will first make a OPTIONS |
|||
* request which will be protected by CORS. |
|||
*/ |
|||
const INCLUDED_CONTENT_TYPES = [ |
|||
"application/x-www-form-urlencoded", |
|||
"multipart/form-data", |
|||
"text/plain", |
|||
] |
|||
|
|||
/** |
|||
* Validate the CSRF token generated aganst the user session. |
|||
* Compare the token with the x-csrf-token header. |
|||
* |
|||
* If the token is not found within the request or the value provided |
|||
* does not match the value within the user session, the request is rejected. |
|||
* |
|||
* CSRF protection provided using the 'Synchronizer Token Pattern' |
|||
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#synchronizer-token-pattern
|
|||
* |
|||
*/ |
|||
module.exports = (opts = { noCsrfPatterns: [] }) => { |
|||
const noCsrfOptions = buildMatcherRegex(opts.noCsrfPatterns) |
|||
return async (ctx, next) => { |
|||
// don't apply for excluded paths
|
|||
const found = matches(ctx, noCsrfOptions) |
|||
if (found) { |
|||
return next() |
|||
} |
|||
|
|||
// don't apply for the excluded http methods
|
|||
if (EXCLUDED_METHODS.indexOf(ctx.method) !== -1) { |
|||
return next() |
|||
} |
|||
|
|||
// don't apply when the content type isn't supported
|
|||
let contentType = ctx.get("content-type") |
|||
? ctx.get("content-type").toLowerCase() |
|||
: "" |
|||
if ( |
|||
!INCLUDED_CONTENT_TYPES.filter(type => contentType.includes(type)).length |
|||
) { |
|||
return next() |
|||
} |
|||
|
|||
// don't apply csrf when the internal api key has been used
|
|||
if (ctx.internal) { |
|||
return next() |
|||
} |
|||
|
|||
// apply csrf when there is a token in the session (new logins)
|
|||
// in future there should be a hard requirement that the token is present
|
|||
const userToken = ctx.user.csrfToken |
|||
if (!userToken) { |
|||
return next() |
|||
} |
|||
|
|||
// reject if no token in request or mismatch
|
|||
const requestToken = ctx.get(Headers.CSRF_TOKEN) |
|||
if (!requestToken || requestToken !== userToken) { |
|||
ctx.throw(403, "Invalid CSRF token") |
|||
} |
|||
|
|||
return next() |
|||
} |
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
const { getScopedConfig } = require("../../../db/utils") |
|||
const { getGlobalDB } = require("../../../tenancy") |
|||
const google = require("../google") |
|||
const { Configs, Cookies } = require("../../../constants") |
|||
const { clearCookie, getCookie } = require("../../../utils") |
|||
const { getDB } = require("../../../db") |
|||
|
|||
async function preAuth(passport, ctx, next) { |
|||
const db = getGlobalDB() |
|||
// get the relevant config
|
|||
const config = await getScopedConfig(db, { |
|||
type: Configs.GOOGLE, |
|||
workspace: ctx.query.workspace, |
|||
}) |
|||
const publicConfig = await getScopedConfig(db, { |
|||
type: Configs.SETTINGS, |
|||
}) |
|||
let callbackUrl = `${publicConfig.platformUrl}/api/global/auth/datasource/google/callback` |
|||
const strategy = await google.strategyFactory(config, callbackUrl) |
|||
|
|||
if (!ctx.query.appId || !ctx.query.datasourceId) { |
|||
ctx.throw(400, "appId and datasourceId query params not present.") |
|||
} |
|||
|
|||
return passport.authenticate(strategy, { |
|||
scope: ["profile", "email", "https://www.googleapis.com/auth/spreadsheets"], |
|||
accessType: "offline", |
|||
prompt: "consent", |
|||
})(ctx, next) |
|||
} |
|||
|
|||
async function postAuth(passport, ctx, next) { |
|||
const db = getGlobalDB() |
|||
|
|||
const config = await getScopedConfig(db, { |
|||
type: Configs.GOOGLE, |
|||
workspace: ctx.query.workspace, |
|||
}) |
|||
|
|||
const publicConfig = await getScopedConfig(db, { |
|||
type: Configs.SETTINGS, |
|||
}) |
|||
|
|||
let callbackUrl = `${publicConfig.platformUrl}/api/global/auth/datasource/google/callback` |
|||
const strategy = await google.strategyFactory( |
|||
config, |
|||
callbackUrl, |
|||
(accessToken, refreshToken, profile, done) => { |
|||
clearCookie(ctx, Cookies.DatasourceAuth) |
|||
done(null, { accessToken, refreshToken }) |
|||
} |
|||
) |
|||
|
|||
const authStateCookie = getCookie(ctx, Cookies.DatasourceAuth) |
|||
|
|||
return passport.authenticate( |
|||
strategy, |
|||
{ successRedirect: "/", failureRedirect: "/error" }, |
|||
async (err, tokens) => { |
|||
// update the DB for the datasource with all the user info
|
|||
const db = getDB(authStateCookie.appId) |
|||
const datasource = await db.get(authStateCookie.datasourceId) |
|||
if (!datasource.config) { |
|||
datasource.config = {} |
|||
} |
|||
datasource.config.auth = { type: "google", ...tokens } |
|||
await db.put(datasource) |
|||
ctx.redirect( |
|||
`/builder/app/${authStateCookie.appId}/data/datasource/${authStateCookie.datasourceId}` |
|||
) |
|||
} |
|||
)(ctx, next) |
|||
} |
|||
|
|||
exports.preAuth = preAuth |
|||
exports.postAuth = postAuth |
|||
@ -1,34 +0,0 @@ |
|||
import { writable } from "svelte/store" |
|||
import api, { get } from "../api" |
|||
|
|||
const INITIAL_HOSTING_UI_STATE = { |
|||
appUrl: "", |
|||
deployedApps: {}, |
|||
deployedAppNames: [], |
|||
deployedAppUrls: [], |
|||
} |
|||
|
|||
export const getHostingStore = () => { |
|||
const store = writable({ ...INITIAL_HOSTING_UI_STATE }) |
|||
store.actions = { |
|||
fetch: async () => { |
|||
const response = await api.get("/api/hosting/urls") |
|||
const urls = await response.json() |
|||
store.update(state => { |
|||
state.appUrl = urls.app |
|||
return state |
|||
}) |
|||
}, |
|||
fetchDeployedApps: async () => { |
|||
let deployments = await (await get("/api/hosting/apps")).json() |
|||
store.update(state => { |
|||
state.deployedApps = deployments |
|||
state.deployedAppNames = Object.values(deployments).map(app => app.name) |
|||
state.deployedAppUrls = Object.values(deployments).map(app => app.url) |
|||
return state |
|||
}) |
|||
return deployments |
|||
}, |
|||
} |
|||
return store |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
<script> |
|||
import { ActionButton } from "@budibase/bbui" |
|||
import GoogleLogo from "assets/google-logo.png" |
|||
import { store } from "builderStore" |
|||
import { auth } from "stores/portal" |
|||
|
|||
export let preAuthStep |
|||
export let datasource |
|||
|
|||
$: tenantId = $auth.tenantId |
|||
</script> |
|||
|
|||
<ActionButton |
|||
on:click={async () => { |
|||
let ds = datasource |
|||
if (!ds) { |
|||
ds = await preAuthStep() |
|||
} |
|||
window.open( |
|||
`/api/global/auth/${tenantId}/datasource/google?datasourceId=${datasource._id}&appId=${$store.appId}`, |
|||
"_blank" |
|||
) |
|||
}} |
|||
> |
|||
<div class="inner"> |
|||
<img src={GoogleLogo} alt="google icon" /> |
|||
<p>Sign in with Google</p> |
|||
</div> |
|||
</ActionButton> |
|||
|
|||
<style> |
|||
.inner { |
|||
display: flex; |
|||
flex-direction: row; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding-top: var(--spacing-xs); |
|||
padding-bottom: var(--spacing-xs); |
|||
} |
|||
.inner img { |
|||
width: 18px; |
|||
margin: 3px 10px 3px 3px; |
|||
} |
|||
.inner p { |
|||
margin: 0; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,184 @@ |
|||
<script> |
|||
export let width = "100" |
|||
export let height = "100" |
|||
</script> |
|||
|
|||
<svg |
|||
{width} |
|||
{height} |
|||
version="1.0" |
|||
xmlns="http://www.w3.org/2000/svg" |
|||
viewBox="0 0 50 80" |
|||
preserveAspectRatio="xMidYMid meet" |
|||
> |
|||
<defs> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-1" |
|||
/> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-3" |
|||
/> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-5" |
|||
/> |
|||
<linearGradient |
|||
x1="50.0053945%" |
|||
y1="8.58610612%" |
|||
x2="50.0053945%" |
|||
y2="100.013939%" |
|||
id="linearGradient-7" |
|||
> |
|||
<stop stop-color="#263238" stop-opacity="0.2" offset="0%" /> |
|||
<stop stop-color="#263238" stop-opacity="0.02" offset="100%" /> |
|||
</linearGradient> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-8" |
|||
/> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-10" |
|||
/> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-12" |
|||
/> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="path-14" |
|||
/> |
|||
<radialGradient |
|||
cx="3.16804688%" |
|||
cy="2.71744318%" |
|||
fx="3.16804688%" |
|||
fy="2.71744318%" |
|||
r="161.248516%" |
|||
gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)" |
|||
id="radialGradient-16" |
|||
> |
|||
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%" /> |
|||
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%" /> |
|||
</radialGradient> |
|||
</defs> |
|||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> |
|||
<g |
|||
id="Consumer-Apps-Sheets-Large-VD-R8-" |
|||
transform="translate(-451.000000, -451.000000)" |
|||
> |
|||
<g id="Hero" transform="translate(0.000000, 63.000000)"> |
|||
<g id="Personal" transform="translate(277.000000, 299.000000)"> |
|||
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)"> |
|||
<g id="Group"> |
|||
<g id="Clipped"> |
|||
<mask id="mask-2" fill="white"> |
|||
<use xlink:href="#path-1" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z" |
|||
id="Path" |
|||
fill="#0F9D58" |
|||
fill-rule="nonzero" |
|||
mask="url(#mask-2)" |
|||
/> |
|||
</g> |
|||
<g id="Clipped"> |
|||
<mask id="mask-4" fill="white"> |
|||
<use xlink:href="#path-3" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<path |
|||
d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z" |
|||
id="Shape" |
|||
fill="#F1F1F1" |
|||
fill-rule="nonzero" |
|||
mask="url(#mask-4)" |
|||
/> |
|||
</g> |
|||
<g id="Clipped"> |
|||
<mask id="mask-6" fill="white"> |
|||
<use xlink:href="#path-5" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<polygon |
|||
id="Path" |
|||
fill="url(#linearGradient-7)" |
|||
fill-rule="nonzero" |
|||
mask="url(#mask-6)" |
|||
points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75" |
|||
/> |
|||
</g> |
|||
<g id="Clipped"> |
|||
<mask id="mask-9" fill="white"> |
|||
<use xlink:href="#path-8" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<g id="Group" mask="url(#mask-9)"> |
|||
<g transform="translate(26.625000, -2.958333)"> |
|||
<path |
|||
d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z" |
|||
id="Path" |
|||
fill="#87CEAC" |
|||
fill-rule="nonzero" |
|||
/> |
|||
</g> |
|||
</g> |
|||
</g> |
|||
<g id="Clipped"> |
|||
<mask id="mask-11" fill="white"> |
|||
<use xlink:href="#path-10" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<path |
|||
d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z" |
|||
id="Path" |
|||
fill-opacity="0.2" |
|||
fill="#FFFFFF" |
|||
fill-rule="nonzero" |
|||
mask="url(#mask-11)" |
|||
/> |
|||
</g> |
|||
<g id="Clipped"> |
|||
<mask id="mask-13" fill="white"> |
|||
<use xlink:href="#path-12" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<path |
|||
d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z" |
|||
id="Path" |
|||
fill-opacity="0.2" |
|||
fill="#263238" |
|||
fill-rule="nonzero" |
|||
mask="url(#mask-13)" |
|||
/> |
|||
</g> |
|||
<g id="Clipped"> |
|||
<mask id="mask-15" fill="white"> |
|||
<use xlink:href="#path-14" /> |
|||
</mask> |
|||
<g id="SVGID_1_" /> |
|||
<path |
|||
d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z" |
|||
id="Path" |
|||
fill-opacity="0.1" |
|||
fill="#263238" |
|||
fill-rule="nonzero" |
|||
mask="url(#mask-15)" |
|||
/> |
|||
</g> |
|||
</g> |
|||
<path |
|||
d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" |
|||
id="Path" |
|||
fill="url(#radialGradient-16)" |
|||
fill-rule="nonzero" |
|||
/> |
|||
</g> |
|||
</g> |
|||
</g> |
|||
</g> |
|||
</g> |
|||
</svg> |
|||
@ -0,0 +1,29 @@ |
|||
<script> |
|||
import { ModalContent, Body, Layout } from "@budibase/bbui" |
|||
import { IntegrationNames } from "constants/backend" |
|||
import cloneDeep from "lodash/cloneDeepWith" |
|||
import GoogleButton from "../_components/GoogleButton.svelte" |
|||
import { saveDatasource as save } from "builderStore/datasource" |
|||
|
|||
export let integration |
|||
export let modal |
|||
|
|||
// kill the reference so the input isn't saved |
|||
let datasource = cloneDeep(integration) |
|||
</script> |
|||
|
|||
<ModalContent |
|||
title={`Connect to ${IntegrationNames[datasource.type]}`} |
|||
onCancel={() => modal.show()} |
|||
cancelText="Back" |
|||
size="L" |
|||
> |
|||
<Layout noPadding> |
|||
<Body size="XS" |
|||
>Authenticate with your google account to use the {IntegrationNames[ |
|||
datasource.type |
|||
]} integration.</Body |
|||
> |
|||
</Layout> |
|||
<GoogleButton preAuthStep={() => save(datasource, true)} /> |
|||
</ModalContent> |
|||
@ -0,0 +1,33 @@ |
|||
<script> |
|||
import { Select, Label } from "@budibase/bbui" |
|||
import { currentAsset } from "builderStore" |
|||
import { findAllMatchingComponents } from "builderStore/componentUtils" |
|||
|
|||
export let parameters |
|||
|
|||
$: components = findAllMatchingComponents($currentAsset.props, component => |
|||
component._component.endsWith("s3upload") |
|||
) |
|||
</script> |
|||
|
|||
<div class="root"> |
|||
<Label small>S3 Upload Component</Label> |
|||
<Select |
|||
bind:value={parameters.componentId} |
|||
options={components} |
|||
getOptionLabel={x => x._instanceName} |
|||
getOptionValue={x => x._id} |
|||
/> |
|||
</div> |
|||
|
|||
<style> |
|||
.root { |
|||
display: grid; |
|||
column-gap: var(--spacing-l); |
|||
row-gap: var(--spacing-s); |
|||
grid-template-columns: 120px 1fr; |
|||
align-items: center; |
|||
max-width: 400px; |
|||
margin: 0 auto; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,15 @@ |
|||
<script> |
|||
import { Select } from "@budibase/bbui" |
|||
import { datasources } from "stores/backend" |
|||
|
|||
export let value = null |
|||
|
|||
$: dataSources = $datasources.list |
|||
.filter(ds => ds.source === "S3" && !ds.config?.endpoint) |
|||
.map(ds => ({ |
|||
label: ds.name, |
|||
value: ds._id, |
|||
})) |
|||
</script> |
|||
|
|||
<Select options={dataSources} {value} on:change /> |
|||
@ -1,120 +1,75 @@ |
|||
<script> |
|||
import { writable, get as svelteGet } from "svelte/store" |
|||
import { |
|||
notifications, |
|||
Input, |
|||
Modal, |
|||
ModalContent, |
|||
Body, |
|||
} from "@budibase/bbui" |
|||
import { hostingStore } from "builderStore" |
|||
import { notifications, Input, ModalContent, Body } from "@budibase/bbui" |
|||
import { apps } from "stores/portal" |
|||
import { string, object } from "yup" |
|||
import { onMount } from "svelte" |
|||
import { capitalise } from "helpers" |
|||
import { APP_NAME_REGEX } from "constants" |
|||
|
|||
const values = writable({ name: null }) |
|||
const errors = writable({}) |
|||
const touched = writable({}) |
|||
const validator = { |
|||
name: string() |
|||
.trim() |
|||
.required("Your application must have a name") |
|||
.matches( |
|||
APP_NAME_REGEX, |
|||
"App name must be letters, numbers and spaces only" |
|||
), |
|||
} |
|||
import { createValidationStore } from "helpers/validation/yup" |
|||
import * as appValidation from "helpers/validation/yup/app" |
|||
|
|||
export let app |
|||
|
|||
let modal |
|||
let valid = false |
|||
let dirty = false |
|||
$: checkValidity($values, validator) |
|||
$: { |
|||
// prevent validation by setting name to undefined without an app |
|||
if (app) { |
|||
$values.name = app?.name |
|||
} |
|||
} |
|||
const values = writable({ name: "", url: null }) |
|||
const validation = createValidationStore() |
|||
$: validation.check($values) |
|||
|
|||
onMount(async () => { |
|||
await hostingStore.actions.fetchDeployedApps() |
|||
const existingAppNames = svelteGet(hostingStore).deployedAppNames |
|||
validator.name = string() |
|||
.trim() |
|||
.required("Your application must have a name") |
|||
.matches( |
|||
APP_NAME_REGEX, |
|||
"App name must be letters, numbers and spaces only" |
|||
) |
|||
.test( |
|||
"non-existing-app-name", |
|||
"Another app with the same name already exists", |
|||
value => { |
|||
return !existingAppNames.some( |
|||
appName => dirty && appName.toLowerCase() === value.toLowerCase() |
|||
) |
|||
} |
|||
) |
|||
$values.name = app.name |
|||
$values.url = app.url |
|||
setupValidation() |
|||
}) |
|||
|
|||
const checkValidity = async (values, validator) => { |
|||
const obj = object().shape(validator) |
|||
Object.keys(validator).forEach(key => ($errors[key] = null)) |
|||
try { |
|||
await obj.validate(values, { abortEarly: false }) |
|||
} catch (validationErrors) { |
|||
validationErrors.inner.forEach(error => { |
|||
$errors[error.path] = capitalise(error.message) |
|||
}) |
|||
} |
|||
valid = await obj.isValid(values) |
|||
const setupValidation = async () => { |
|||
const applications = svelteGet(apps) |
|||
appValidation.name(validation, { apps: applications, currentApp: app }) |
|||
appValidation.url(validation, { apps: applications, currentApp: app }) |
|||
// init validation |
|||
validation.check($values) |
|||
} |
|||
|
|||
async function updateApp() { |
|||
try { |
|||
// Update App |
|||
await apps.update(app.instance._id, { name: $values.name.trim() }) |
|||
hide() |
|||
const body = { |
|||
name: $values.name.trim(), |
|||
} |
|||
if ($values.url) { |
|||
body.url = $values.url.trim() |
|||
} |
|||
await apps.update(app.instance._id, body) |
|||
} catch (error) { |
|||
console.error(error) |
|||
notifications.error(error) |
|||
} |
|||
} |
|||
|
|||
export const show = () => { |
|||
modal.show() |
|||
} |
|||
export const hide = () => { |
|||
modal.hide() |
|||
} |
|||
|
|||
const onCancel = () => { |
|||
hide() |
|||
} |
|||
|
|||
const onShow = () => { |
|||
dirty = false |
|||
// auto add slash to url |
|||
$: { |
|||
if ($values.url && !$values.url.startsWith("/")) { |
|||
$values.url = `/${$values.url}` |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}> |
|||
<ModalContent |
|||
title={"Edit app"} |
|||
confirmText={"Save"} |
|||
onConfirm={updateApp} |
|||
disabled={!(valid && dirty)} |
|||
> |
|||
<Body size="S">Update the name of your app.</Body> |
|||
<Input |
|||
bind:value={$values.name} |
|||
error={$touched.name && $errors.name} |
|||
on:blur={() => ($touched.name = true)} |
|||
on:change={() => (dirty = true)} |
|||
label="Name" |
|||
/> |
|||
</ModalContent> |
|||
</Modal> |
|||
<ModalContent |
|||
title={"Edit app"} |
|||
confirmText={"Save"} |
|||
onConfirm={updateApp} |
|||
disabled={!$validation.valid} |
|||
> |
|||
<Body size="S">Update the name of your app.</Body> |
|||
<Input |
|||
bind:value={$values.name} |
|||
error={$validation.touched.name && $validation.errors.name} |
|||
on:blur={() => ($validation.touched.name = true)} |
|||
label="Name" |
|||
/> |
|||
<Input |
|||
bind:value={$values.url} |
|||
error={$validation.touched.url && $validation.errors.url} |
|||
on:blur={() => ($validation.touched.url = true)} |
|||
label="URL" |
|||
placeholder={$values.name |
|||
? "/" + encodeURIComponent($values.name).toLowerCase() |
|||
: "/"} |
|||
/> |
|||
</ModalContent> |
|||
|
|||
@ -0,0 +1,83 @@ |
|||
import { string, mixed } from "yup" |
|||
import { APP_NAME_REGEX, APP_URL_REGEX } from "constants" |
|||
|
|||
export const name = (validation, { apps, currentApp } = { apps: [] }) => { |
|||
validation.addValidator( |
|||
"name", |
|||
string() |
|||
.trim() |
|||
.required("Your application must have a name") |
|||
.matches( |
|||
APP_NAME_REGEX, |
|||
"App name must be letters, numbers and spaces only" |
|||
) |
|||
.test( |
|||
"non-existing-app-name", |
|||
"Another app with the same name already exists", |
|||
value => { |
|||
if (!value) { |
|||
// exit early, above validator will fail
|
|||
return true |
|||
} |
|||
if (currentApp) { |
|||
// filter out the current app if present
|
|||
apps = apps.filter(app => app.appId !== currentApp.appId) |
|||
} |
|||
return !apps |
|||
.map(app => app.name) |
|||
.some(appName => appName.toLowerCase() === value.toLowerCase()) |
|||
} |
|||
) |
|||
) |
|||
} |
|||
|
|||
export const url = (validation, { apps, currentApp } = { apps: [] }) => { |
|||
validation.addValidator( |
|||
"url", |
|||
string() |
|||
.nullable() |
|||
.matches(APP_URL_REGEX, "App URL must not contain spaces") |
|||
.test( |
|||
"non-existing-app-url", |
|||
"Another app with the same URL already exists", |
|||
value => { |
|||
// url is nullable
|
|||
if (!value) { |
|||
return true |
|||
} |
|||
if (currentApp) { |
|||
// filter out the current app if present
|
|||
apps = apps.filter(app => app.appId !== currentApp.appId) |
|||
} |
|||
return !apps |
|||
.map(app => app.url) |
|||
.some(appUrl => appUrl?.toLowerCase() === value.toLowerCase()) |
|||
} |
|||
) |
|||
.test("valid-url", "Not a valid URL", value => { |
|||
// url is nullable
|
|||
if (!value) { |
|||
return true |
|||
} |
|||
// make it clear that this is a url path and cannot be a full url
|
|||
return ( |
|||
value.startsWith("/") && |
|||
!value.includes("http") && |
|||
!value.includes("www") && |
|||
!value.includes(".") && |
|||
value.length > 1 // just '/' is not valid
|
|||
) |
|||
}) |
|||
) |
|||
} |
|||
|
|||
export const file = (validation, { template } = {}) => { |
|||
const templateToUse = |
|||
template && Object.keys(template).length === 0 ? null : template |
|||
validation.addValidator( |
|||
"file", |
|||
templateToUse?.fromFile |
|||
? mixed().required("Please choose a file to import") |
|||
: null |
|||
) |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
import { capitalise } from "helpers" |
|||
import { object } from "yup" |
|||
import { writable, get } from "svelte/store" |
|||
import { notifications } from "@budibase/bbui" |
|||
|
|||
export const createValidationStore = () => { |
|||
const DEFAULT = { |
|||
errors: {}, |
|||
touched: {}, |
|||
valid: false, |
|||
} |
|||
|
|||
const validator = {} |
|||
const validation = writable(DEFAULT) |
|||
|
|||
const addValidator = (propertyName, propertyValidator) => { |
|||
if (!propertyValidator || !propertyName) { |
|||
return |
|||
} |
|||
validator[propertyName] = propertyValidator |
|||
} |
|||
|
|||
const check = async values => { |
|||
const obj = object().shape(validator) |
|||
// clear the previous errors
|
|||
const properties = Object.keys(validator) |
|||
properties.forEach(property => (get(validation).errors[property] = null)) |
|||
|
|||
let validationError = false |
|||
try { |
|||
await obj.validate(values, { abortEarly: false }) |
|||
} catch (error) { |
|||
if (!error.inner) { |
|||
notifications.error("Unexpected validation error", error) |
|||
validationError = true |
|||
} else { |
|||
error.inner.forEach(err => { |
|||
validation.update(store => { |
|||
store.errors[err.path] = capitalise(err.message) |
|||
return store |
|||
}) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
let valid |
|||
if (properties.length && !validationError) { |
|||
valid = await obj.isValid(values) |
|||
} else { |
|||
// don't say valid until validators have been loaded
|
|||
valid = false |
|||
} |
|||
|
|||
validation.update(store => { |
|||
store.valid = valid |
|||
return store |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
subscribe: validation.subscribe, |
|||
set: validation.set, |
|||
check, |
|||
addValidator, |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -0,0 +1,143 @@ |
|||
<script> |
|||
import Field from "./Field.svelte" |
|||
import { CoreDropzone, ProgressCircle } from "@budibase/bbui" |
|||
import { getContext, onMount, onDestroy } from "svelte" |
|||
|
|||
export let datasourceId |
|||
export let bucket |
|||
export let key |
|||
export let field |
|||
export let label |
|||
export let disabled = false |
|||
export let validation |
|||
|
|||
let fieldState |
|||
let fieldApi |
|||
|
|||
const { API, notificationStore, uploadStore } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
// 5GB cap per item sent via S3 REST API |
|||
const MaxFileSize = 1000000000 * 5 |
|||
|
|||
// Actual file data to upload |
|||
let data |
|||
let loading = false |
|||
|
|||
const handleFileTooLarge = () => { |
|||
notificationStore.actions.warning( |
|||
"Files cannot exceed 5GB. Please try again with a smaller file." |
|||
) |
|||
} |
|||
|
|||
// Process the file input and return a serializable structure expected by |
|||
// the dropzone component to display the file |
|||
const processFiles = async fileList => { |
|||
return await new Promise(resolve => { |
|||
if (!fileList?.length) { |
|||
return [] |
|||
} |
|||
|
|||
// Don't read in non-image files |
|||
data = fileList[0] |
|||
if (!data.type?.startsWith("image")) { |
|||
resolve([ |
|||
{ |
|||
name: data.name, |
|||
type: data.type, |
|||
}, |
|||
]) |
|||
} |
|||
|
|||
// Read image files and display as preview |
|||
const reader = new FileReader() |
|||
reader.addEventListener( |
|||
"load", |
|||
() => { |
|||
resolve([ |
|||
{ |
|||
url: reader.result, |
|||
name: data.name, |
|||
type: data.type, |
|||
}, |
|||
]) |
|||
}, |
|||
false |
|||
) |
|||
reader.readAsDataURL(fileList[0]) |
|||
}) |
|||
} |
|||
|
|||
const upload = async () => { |
|||
loading = true |
|||
try { |
|||
const res = await API.externalUpload(datasourceId, bucket, key, data) |
|||
notificationStore.actions.success("File uploaded successfully") |
|||
loading = false |
|||
return res |
|||
} catch (error) { |
|||
notificationStore.actions.error(`Error uploading file: ${error}`) |
|||
} |
|||
} |
|||
|
|||
onMount(() => { |
|||
uploadStore.actions.registerFileUpload($component.id, upload) |
|||
}) |
|||
|
|||
onDestroy(() => { |
|||
uploadStore.actions.unregisterFileUpload($component.id) |
|||
}) |
|||
</script> |
|||
|
|||
<Field |
|||
{label} |
|||
{field} |
|||
{disabled} |
|||
{validation} |
|||
type="s3upload" |
|||
bind:fieldState |
|||
bind:fieldApi |
|||
defaultValue={[]} |
|||
> |
|||
<div class="content"> |
|||
{#if fieldState} |
|||
<CoreDropzone |
|||
value={fieldState.value} |
|||
disabled={loading || fieldState.disabled} |
|||
error={fieldState.error} |
|||
on:change={e => { |
|||
fieldApi.setValue(e.detail) |
|||
}} |
|||
{processFiles} |
|||
{handleFileTooLarge} |
|||
maximum={1} |
|||
fileSizeLimit={MaxFileSize} |
|||
/> |
|||
{/if} |
|||
{#if loading} |
|||
<div class="overlay" /> |
|||
<div class="loading"> |
|||
<ProgressCircle /> |
|||
</div> |
|||
{/if} |
|||
</div> |
|||
</Field> |
|||
|
|||
<style> |
|||
.content { |
|||
position: relative; |
|||
} |
|||
.overlay, |
|||
.loading { |
|||
position: absolute; |
|||
top: 0; |
|||
height: 100%; |
|||
width: 100%; |
|||
display: grid; |
|||
place-items: center; |
|||
} |
|||
.overlay { |
|||
background-color: var(--spectrum-global-color-gray-50); |
|||
opacity: 0.5; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,42 @@ |
|||
import { writable, get } from "svelte/store" |
|||
|
|||
export const createUploadStore = () => { |
|||
const store = writable([]) |
|||
|
|||
// Registers a new file upload component
|
|||
const registerFileUpload = (componentId, callback) => { |
|||
if (!componentId || !callback) { |
|||
return |
|||
} |
|||
|
|||
store.update(state => { |
|||
state.push({ |
|||
componentId, |
|||
callback, |
|||
}) |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
// Unregisters a file upload component
|
|||
const unregisterFileUpload = componentId => { |
|||
store.update(state => state.filter(c => c.componentId !== componentId)) |
|||
} |
|||
|
|||
// Processes a file upload for a given component ID
|
|||
const processFileUpload = async componentId => { |
|||
if (!componentId) { |
|||
return |
|||
} |
|||
|
|||
const component = get(store).find(c => c.componentId === componentId) |
|||
return await component?.callback() |
|||
} |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
actions: { registerFileUpload, unregisterFileUpload, processFileUpload }, |
|||
} |
|||
} |
|||
|
|||
export const uploadStore = createUploadStore() |
|||
File diff suppressed because it is too large
@ -1,5 +1,5 @@ |
|||
{ |
|||
"watch": ["src", "../auth"], |
|||
"watch": ["src", "../backend-core"], |
|||
"ext": "js,ts,json", |
|||
"ignore": ["src/**/*.spec.ts", "src/**/*.spec.js"], |
|||
"exec": "ts-node src/index.ts" |
|||
|
|||
@ -1,22 +0,0 @@ |
|||
const CouchDB = require("../../db") |
|||
const { getDeployedApps } = require("../../utilities/workerRequests") |
|||
const { getScopedConfig } = require("@budibase/backend-core/db") |
|||
const { Configs } = require("@budibase/backend-core/constants") |
|||
const { checkSlashesInUrl } = require("../../utilities") |
|||
|
|||
exports.fetchUrls = async ctx => { |
|||
const appId = ctx.appId |
|||
const db = new CouchDB(appId) |
|||
const settings = await getScopedConfig(db, { type: Configs.SETTINGS }) |
|||
let appUrl = "http://localhost:10000/app" |
|||
if (settings && settings["platformUrl"]) { |
|||
appUrl = checkSlashesInUrl(`${settings["platformUrl"]}/app`) |
|||
} |
|||
ctx.body = { |
|||
app: appUrl, |
|||
} |
|||
} |
|||
|
|||
exports.getDeployedApps = async ctx => { |
|||
ctx.body = await getDeployedApps() |
|||
} |
|||
@ -1,13 +0,0 @@ |
|||
const Router = require("@koa/router") |
|||
const controller = require("../controllers/hosting") |
|||
const authorized = require("../../middleware/authorized") |
|||
const { BUILDER } = require("@budibase/backend-core/permissions") |
|||
|
|||
const router = Router() |
|||
|
|||
router |
|||
.get("/api/hosting/urls", authorized(BUILDER), controller.fetchUrls) |
|||
// this isn't risky, doesn't return anything about apps other than names and URLs
|
|||
.get("/api/hosting/apps", controller.getDeployedApps) |
|||
|
|||
module.exports = router |
|||
@ -1,36 +0,0 @@ |
|||
// mock out node fetch for this
|
|||
jest.mock("node-fetch") |
|||
|
|||
const { checkBuilderEndpoint } = require("./utilities/TestFunctions") |
|||
const setup = require("./utilities") |
|||
|
|||
describe("/hosting", () => { |
|||
let request = setup.getRequest() |
|||
let config = setup.getConfig() |
|||
let app |
|||
|
|||
afterAll(setup.afterAll) |
|||
|
|||
beforeEach(async () => { |
|||
app = await config.init() |
|||
}) |
|||
|
|||
describe("fetchUrls", () => { |
|||
it("should be able to fetch current app URLs", async () => { |
|||
const res = await request |
|||
.get(`/api/hosting/urls`) |
|||
.set(config.defaultHeaders()) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(res.body.app).toEqual(`http://localhost:10000/app`) |
|||
}) |
|||
|
|||
it("should apply authorization to endpoint", async () => { |
|||
await checkBuilderEndpoint({ |
|||
config, |
|||
method: "GET", |
|||
url: `/api/hosting/urls`, |
|||
}) |
|||
}) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,98 @@ |
|||
jest.mock("node-fetch") |
|||
jest.mock("aws-sdk", () => ({ |
|||
config: { |
|||
update: jest.fn(), |
|||
}, |
|||
DynamoDB: { |
|||
DocumentClient: jest.fn(), |
|||
}, |
|||
S3: jest.fn(() => ({ |
|||
getSignedUrl: jest.fn(() => { |
|||
return "my-url" |
|||
}), |
|||
})), |
|||
})) |
|||
|
|||
const setup = require("./utilities") |
|||
|
|||
describe("/attachments", () => { |
|||
let request = setup.getRequest() |
|||
let config = setup.getConfig() |
|||
let app |
|||
|
|||
afterAll(setup.afterAll) |
|||
|
|||
beforeEach(async () => { |
|||
app = await config.init() |
|||
}) |
|||
|
|||
describe("generateSignedUrls", () => { |
|||
let datasource |
|||
|
|||
beforeEach(async () => { |
|||
datasource = await config.createDatasource({ |
|||
datasource: { |
|||
type: "datasource", |
|||
name: "Test", |
|||
source: "S3", |
|||
config: {}, |
|||
}, |
|||
}) |
|||
}) |
|||
|
|||
it("should be able to generate a signed upload URL", async () => { |
|||
const bucket = "foo" |
|||
const key = "bar" |
|||
const res = await request |
|||
.post(`/api/attachments/${datasource._id}/url`) |
|||
.send({ bucket, key }) |
|||
.set(config.defaultHeaders()) |
|||
.expect("Content-Type", /json/) |
|||
.expect(200) |
|||
expect(res.body.signedUrl).toEqual("my-url") |
|||
expect(res.body.publicUrl).toEqual( |
|||
`https://${bucket}.s3.eu-west-1.amazonaws.com/${key}` |
|||
) |
|||
}) |
|||
|
|||
it("should handle an invalid datasource ID", async () => { |
|||
const res = await request |
|||
.post(`/api/attachments/foo/url`) |
|||
.send({ |
|||
bucket: "foo", |
|||
key: "bar", |
|||
}) |
|||
.set(config.defaultHeaders()) |
|||
.expect("Content-Type", /json/) |
|||
.expect(400) |
|||
expect(res.body.message).toEqual( |
|||
"The specified datasource could not be found" |
|||
) |
|||
}) |
|||
|
|||
it("should require a bucket parameter", async () => { |
|||
const res = await request |
|||
.post(`/api/attachments/${datasource._id}/url`) |
|||
.send({ |
|||
bucket: undefined, |
|||
key: "bar", |
|||
}) |
|||
.set(config.defaultHeaders()) |
|||
.expect("Content-Type", /json/) |
|||
.expect(400) |
|||
expect(res.body.message).toEqual("bucket and key values are required") |
|||
}) |
|||
|
|||
it("should require a key parameter", async () => { |
|||
const res = await request |
|||
.post(`/api/attachments/${datasource._id}/url`) |
|||
.send({ |
|||
bucket: "foo", |
|||
}) |
|||
.set(config.defaultHeaders()) |
|||
.expect("Content-Type", /json/) |
|||
.expect(400) |
|||
expect(res.body.message).toEqual("bucket and key values are required") |
|||
}) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,353 @@ |
|||
import { |
|||
DatasourceFieldTypes, |
|||
Integration, |
|||
QueryJson, |
|||
QueryTypes, |
|||
} from "../definitions/datasource" |
|||
import { OAuth2Client } from "google-auth-library" |
|||
import { DatasourcePlus } from "./base/datasourcePlus" |
|||
import { Row, Table, TableSchema } from "../definitions/common" |
|||
import { buildExternalTableId } from "./utils" |
|||
import { DataSourceOperation, FieldTypes } from "../constants" |
|||
import { GoogleSpreadsheet } from "google-spreadsheet" |
|||
import { table } from "console" |
|||
|
|||
module GoogleSheetsModule { |
|||
const { getGlobalDB } = require("@budibase/backend-core/tenancy") |
|||
const { getScopedConfig } = require("@budibase/backend-core/db") |
|||
const { Configs } = require("@budibase/backend-core/constants") |
|||
|
|||
interface GoogleSheetsConfig { |
|||
spreadsheetId: string |
|||
auth: OAuthClientConfig |
|||
} |
|||
|
|||
interface OAuthClientConfig { |
|||
appId: string |
|||
accessToken: string |
|||
refreshToken: string |
|||
} |
|||
|
|||
const SCHEMA: Integration = { |
|||
plus: true, |
|||
auth: { |
|||
type: "google", |
|||
}, |
|||
relationships: false, |
|||
docs: "https://developers.google.com/sheets/api/quickstart/nodejs", |
|||
description: |
|||
"Create and collaborate on online spreadsheets in real-time and from any device. ", |
|||
friendlyName: "Google Sheets", |
|||
datasource: { |
|||
spreadsheetId: { |
|||
type: DatasourceFieldTypes.STRING, |
|||
required: true, |
|||
}, |
|||
}, |
|||
query: { |
|||
create: { |
|||
type: QueryTypes.FIELDS, |
|||
fields: { |
|||
sheet: { |
|||
type: DatasourceFieldTypes.STRING, |
|||
required: true, |
|||
}, |
|||
row: { |
|||
type: QueryTypes.JSON, |
|||
required: true, |
|||
}, |
|||
}, |
|||
}, |
|||
read: { |
|||
type: QueryTypes.FIELDS, |
|||
fields: { |
|||
sheet: { |
|||
type: DatasourceFieldTypes.STRING, |
|||
required: true, |
|||
}, |
|||
}, |
|||
}, |
|||
update: { |
|||
type: QueryTypes.FIELDS, |
|||
fields: { |
|||
sheet: { |
|||
type: DatasourceFieldTypes.STRING, |
|||
required: true, |
|||
}, |
|||
rowIndex: { |
|||
type: DatasourceFieldTypes.STRING, |
|||
required: true, |
|||
}, |
|||
row: { |
|||
type: QueryTypes.JSON, |
|||
required: true, |
|||
}, |
|||
}, |
|||
}, |
|||
delete: { |
|||
type: QueryTypes.FIELDS, |
|||
fields: { |
|||
sheet: { |
|||
type: DatasourceFieldTypes.STRING, |
|||
required: true, |
|||
}, |
|||
rowIndex: { |
|||
type: DatasourceFieldTypes.NUMBER, |
|||
required: true, |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
class GoogleSheetsIntegration implements DatasourcePlus { |
|||
private readonly config: GoogleSheetsConfig |
|||
private client: any |
|||
public tables: Record<string, Table> = {} |
|||
public schemaErrors: Record<string, string> = {} |
|||
|
|||
constructor(config: GoogleSheetsConfig) { |
|||
this.config = config |
|||
const spreadsheetId = this.cleanSpreadsheetUrl(this.config.spreadsheetId) |
|||
this.client = new GoogleSpreadsheet(spreadsheetId) |
|||
} |
|||
|
|||
/** |
|||
* Pull the spreadsheet ID out from a valid google sheets URL |
|||
* @param spreadsheetId - the URL or standard spreadsheetId of the google sheet |
|||
* @returns spreadsheet Id of the google sheet |
|||
*/ |
|||
cleanSpreadsheetUrl(spreadsheetId: string) { |
|||
if (!spreadsheetId) { |
|||
throw new Error( |
|||
"You must set a spreadsheet ID in your configuration to fetch tables." |
|||
) |
|||
} |
|||
const parts = spreadsheetId.split("/") |
|||
return parts.length > 5 ? parts[5] : spreadsheetId |
|||
} |
|||
|
|||
async connect() { |
|||
try { |
|||
// Initialise oAuth client
|
|||
const db = getGlobalDB() |
|||
const googleConfig = await getScopedConfig(db, { |
|||
type: Configs.GOOGLE, |
|||
}) |
|||
const oauthClient = new OAuth2Client({ |
|||
clientId: googleConfig.clientID, |
|||
clientSecret: googleConfig.clientSecret, |
|||
}) |
|||
oauthClient.credentials.access_token = this.config.auth.accessToken |
|||
oauthClient.credentials.refresh_token = this.config.auth.refreshToken |
|||
this.client.useOAuth2Client(oauthClient) |
|||
await this.client.loadInfo() |
|||
} catch (err) { |
|||
console.error("Error connecting to google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async buildSchema(datasourceId: string) { |
|||
await this.connect() |
|||
const sheets = await this.client.sheetsByIndex |
|||
const tables: Record<string, Table> = {} |
|||
for (let sheet of sheets) { |
|||
// must fetch rows to determine schema
|
|||
await sheet.getRows() |
|||
// build schema
|
|||
const schema: TableSchema = {} |
|||
|
|||
// build schema from headers
|
|||
for (let header of sheet.headerValues) { |
|||
schema[header] = { |
|||
name: header, |
|||
type: FieldTypes.STRING, |
|||
} |
|||
} |
|||
|
|||
// create tables
|
|||
tables[sheet.title] = { |
|||
_id: buildExternalTableId(datasourceId, sheet.title), |
|||
name: sheet.title, |
|||
primary: ["rowNumber"], |
|||
schema, |
|||
} |
|||
} |
|||
|
|||
this.tables = tables |
|||
} |
|||
|
|||
async query(json: QueryJson) { |
|||
const sheet = json.endpoint.entityId |
|||
|
|||
const handlers = { |
|||
[DataSourceOperation.CREATE]: () => |
|||
this.create({ sheet, row: json.body }), |
|||
[DataSourceOperation.READ]: () => this.read({ sheet }), |
|||
[DataSourceOperation.UPDATE]: () => |
|||
this.update({ |
|||
// exclude the header row and zero index
|
|||
rowIndex: json.extra?.idFilter?.equal?.rowNumber - 2, |
|||
sheet, |
|||
row: json.body, |
|||
}), |
|||
[DataSourceOperation.DELETE]: () => |
|||
this.delete({ |
|||
// exclude the header row and zero index
|
|||
rowIndex: json.extra?.idFilter?.equal?.rowNumber - 2, |
|||
sheet, |
|||
}), |
|||
[DataSourceOperation.CREATE_TABLE]: () => |
|||
this.createTable(json?.table?.name), |
|||
[DataSourceOperation.UPDATE_TABLE]: () => this.updateTable(json.table), |
|||
[DataSourceOperation.DELETE_TABLE]: () => |
|||
this.deleteTable(json?.table?.name), |
|||
} |
|||
|
|||
const internalQueryMethod = handlers[json.endpoint.operation] |
|||
|
|||
return await internalQueryMethod() |
|||
} |
|||
|
|||
buildRowObject(headers: string[], values: string[], rowNumber: number) { |
|||
const rowObject: { rowNumber: number; [key: string]: any } = { rowNumber } |
|||
for (let i = 0; i < headers.length; i++) { |
|||
rowObject._id = rowNumber |
|||
rowObject[headers[i]] = values[i] |
|||
} |
|||
return rowObject |
|||
} |
|||
|
|||
async createTable(name?: string) { |
|||
try { |
|||
await this.connect() |
|||
const sheet = await this.client.addSheet({ title: name }) |
|||
return sheet |
|||
} catch (err) { |
|||
console.error("Error creating new table in google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async updateTable(table?: any) { |
|||
try { |
|||
await this.connect() |
|||
const sheet = await this.client.sheetsByTitle[table.name] |
|||
await sheet.loadHeaderRow() |
|||
|
|||
if (table._rename) { |
|||
const headers = [] |
|||
for (let header of sheet.headerValues) { |
|||
if (header === table._rename.old) { |
|||
headers.push(table._rename.updated) |
|||
} else { |
|||
headers.push(header) |
|||
} |
|||
} |
|||
await sheet.setHeaderRow(headers) |
|||
} else { |
|||
let newField = Object.keys(table.schema).find( |
|||
key => !sheet.headerValues.includes(key) |
|||
) |
|||
await sheet.setHeaderRow([...sheet.headerValues, newField]) |
|||
} |
|||
} catch (err) { |
|||
console.error("Error updating table in google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async deleteTable(sheet: any) { |
|||
try { |
|||
await this.connect() |
|||
const sheetToDelete = await this.client.sheetsByTitle[sheet] |
|||
return await sheetToDelete.delete() |
|||
} catch (err) { |
|||
console.error("Error deleting table in google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async create(query: { sheet: string; row: any }) { |
|||
try { |
|||
await this.connect() |
|||
const sheet = await this.client.sheetsByTitle[query.sheet] |
|||
const rowToInsert = |
|||
typeof query.row === "string" ? JSON.parse(query.row) : query.row |
|||
const row = await sheet.addRow(rowToInsert) |
|||
return [ |
|||
this.buildRowObject(sheet.headerValues, row._rawData, row._rowNumber), |
|||
] |
|||
} catch (err) { |
|||
console.error("Error writing to google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async read(query: { sheet: string }) { |
|||
try { |
|||
await this.connect() |
|||
const sheet = await this.client.sheetsByTitle[query.sheet] |
|||
const rows = await sheet.getRows() |
|||
const headerValues = sheet.headerValues |
|||
const response = [] |
|||
for (let row of rows) { |
|||
response.push( |
|||
this.buildRowObject(headerValues, row._rawData, row._rowNumber) |
|||
) |
|||
} |
|||
return response |
|||
} catch (err) { |
|||
console.error("Error reading from google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async update(query: { sheet: string; rowIndex: number; row: any }) { |
|||
try { |
|||
await this.connect() |
|||
const sheet = await this.client.sheetsByTitle[query.sheet] |
|||
const rows = await sheet.getRows() |
|||
const row = rows[query.rowIndex] |
|||
if (row) { |
|||
const updateValues = query.row |
|||
for (let key in updateValues) { |
|||
row[key] = updateValues[key] |
|||
} |
|||
await row.save() |
|||
return [ |
|||
this.buildRowObject( |
|||
sheet.headerValues, |
|||
row._rawData, |
|||
row._rowNumber |
|||
), |
|||
] |
|||
} else { |
|||
throw new Error("Row does not exist.") |
|||
} |
|||
} catch (err) { |
|||
console.error("Error reading from google sheets", err) |
|||
throw err |
|||
} |
|||
} |
|||
|
|||
async delete(query: { sheet: string; rowIndex: number }) { |
|||
await this.connect() |
|||
const sheet = await this.client.sheetsByTitle[query.sheet] |
|||
const rows = await sheet.getRows() |
|||
const row = rows[query.rowIndex] |
|||
if (row) { |
|||
await row.delete() |
|||
return [{ deleted: query.rowIndex }] |
|||
} else { |
|||
throw new Error("Row does not exist.") |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
schema: SCHEMA, |
|||
integration: GoogleSheetsIntegration, |
|||
} |
|||
} |
|||
@ -1,3 +1,3 @@ |
|||
{ |
|||
"watch": ["src", "../auth"] |
|||
"watch": ["src", "../backend-core"] |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue