Browse Source

feat: scripts wip

pull/9323/head
DarkPhoenix2704 2 months ago
parent
commit
69e9205471
  1. 152
      packages/nc-gui/components/scripts/index.html
  2. 148
      packages/nc-gui/components/scripts/index.vue
  3. 28
      packages/nc-gui/components/smartsheet/Topbar.vue
  4. 4
      packages/nc-gui/components/tabs/Smartsheet.vue
  5. 17
      packages/nc-gui/composables/useScripts.ts
  6. 1
      packages/nc-gui/lang/en.json
  7. 245
      packages/nocodb/src/controllers/scripts.controller.ts
  8. 2
      packages/nocodb/src/modules/noco.module.ts
  9. 35
      packages/nocodb/src/schema/swagger.json

152
packages/nc-gui/components/scripts/index.html

@ -0,0 +1,152 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Secure Script Executor</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.30.1/min/vs/loader.js"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: 'Arial', sans-serif;
}
#editor, #output {
height: calc(100vh - 7rem);
}
.console-line {
padding: 2px 5px;
border-bottom: 1px solid #e2e8f0;
}
.log { color: #2d3748; }
.error { color: #e53e3e; }
.warn { color: #d69e2e; }
</style>
</head>
<body class="bg-gray-100">
<nav class="bg-blue-600 text-white p-4 flex justify-between items-center">
<h1 class="text-xl font-bold">Secure Script Executor</h1>
<button onclick="runScript()" class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded transition duration-300 ease-in-out transform hover:scale-105">
Run Script
</button>
</nav>
<div class="flex flex-1 overflow-hidden">
<div id="editor" class="w-1/2 border-r border-gray-300"></div>
<div class="w-1/2 flex flex-col">
<div class="bg-gray-200 p-2 font-bold">Console Output</div>
<div id="output" class="flex-1 overflow-auto bg-white p-2"></div>
</div>
</div>
<footer class="bg-gray-200 p-2 text-center text-sm text-gray-600">
Secure Script Executor &copy; 2023
</footer>
<script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.30.1/min/vs' } })
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: 'console.log("Hello World");',
language: 'javascript',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false },
})
})
let scriptWorker
function createWorker() {
const workerCode = `
'use strict';
// Security restrictions
const restrictedGlobals = ['window', 'document', 'location', 'top', 'parent', 'frames', 'opener'];
restrictedGlobals.forEach(name => {
Object.defineProperty(self, name, {
get: () => {
throw new ReferenceError(name + ' is not defined');
},
configurable: false
});
});
// Restricted access to APIs
self.XMLHttpRequest = undefined;
self.fetch = undefined;
self.WebSocket = undefined;
self.localStorage = undefined;
self.sessionStorage = undefined;
self.console = {
log: (...args) => self.postMessage({type: 'log', message: args.join(' ')}),
error: (...args) => self.postMessage({type: 'error', message: args.join(' ')}),
warn: (...args) => self.postMessage({type: 'warn', message: args.join(' ')})
};
// Importing NocoDB SDK in the worker
import { Api } from 'https://cdn.jsdelivr.net/npm/nocodb-sdk@0.255.2/+esm';
// Initialize NocoDB SDK in the worker
const api = new Api({
baseURL: 'http://localhost:8080',
axiosConfig: {
headers: {
'xc-auth': 'undefined',
},
},
});
self.onmessage = function(event) {
const script = event.data.script;
try {
eval(script);
} catch (error) {
self.console.error(error.toString());
}
self.postMessage({type: 'done'});
};
`
const blob = new Blob([workerCode], { type: 'application/javascript' })
return new Worker(URL.createObjectURL(blob), { type: 'module' })
}
function runScript() {
const script = window.editor.getValue()
const output = document.getElementById('output')
output.innerHTML = ''
if (scriptWorker) {
scriptWorker.terminate()
}
scriptWorker = createWorker()
scriptWorker.onmessage = function (event) {
const { type, message } = event.data
if (type === 'done') {
scriptWorker.terminate()
scriptWorker = null
} else {
const line = document.createElement('div')
line.textContent = message
line.className = `console-line ${type}`
output.appendChild(line)
}
}
scriptWorker.onerror = function (error) {
const line = document.createElement('div')
line.textContent = `Worker error: ${error.message}`
line.className = 'console-line error'
output.appendChild(line)
scriptWorker.terminate()
scriptWorker = null
}
scriptWorker.postMessage({ script })
}
</script>
</body>
</html>

148
packages/nc-gui/components/scripts/index.vue

@ -0,0 +1,148 @@
<script setup lang="ts">
import { loadPyodide } from 'pyodide'
const props = defineProps<{
modalVisible: boolean
}>()
const emits = defineEmits(['update:modalVisible'])
const modalVisible = useVModel(props, 'modalVisible', emits)
const { execScript } = useScripts()
const script = ref('')
const closeModel = () => {
modalVisible.value = false
}
const loading = ref(false)
const response = ref('')
const executeJSScript = async () => {
loading.value = true
try {
const res = await execScript(script.value)
response.value = JSON.stringify(res, null, 2)
} catch (e) {
response.value = e.message
} finally {
loading.value = false
}
}
const executePythonScript = async () => {
loading.value = true
try {
const pyodide = await loadPyodide()
const res = await pyodide.runPythonAsync(script.value)
response.value = JSON.stringify(res, null, 2)
} catch (e) {
response.value = e.message
} finally {
loading.value = false
}
}
const supportedDocs = [
{
title: 'Scripting Guide',
href: 'https://docs.nocodb.com/docs/scripting-guide',
},
{
title: 'Scripting API',
href: 'https://docs.nocodb.com/docs/scripting-api',
},
{
title: 'Scripting Examples',
href: 'https://docs.nocodb.com/docs/scripting-examples',
},
]
</script>
<template>
<NcModal v-model:visible="modalVisible" :show-separator="true" size="large" wrap-class-name="nc-modal-scripts-create-edit">
<template #header>
<div class="flex w-full items-center p-4 justify-between">
<div class="flex items-center gap-3">
<GeneralIcon class="text-gray-900 text-2xl" icon="ncScript" />
<span class="text-gray-900 font-semibold text-xl"> Scripts Editor </span>
</div>
<div class="flex justify-end items-center gap-3">
<NcButton :loading="loading" type="primary" size="small" data-testid="nc-test-script" @click="executeJSScript">
Test JS Script
</NcButton>
<NcButton :loading="loading" type="primary" size="small" data-testid="nc-test-script" @click="executePythonScript">
Test Python Script
</NcButton>
<NcButton type="text" size="small" data-testid="nc-close-scripts-modal" @click.stop="closeModel">
<GeneralIcon icon="close" />
</NcButton>
</div>
</div>
</template>
<div class="flex bg-white rounded-b-2xl h-[calc(100%_-_66px)]">
<div
ref="containerElem"
class="h-full flex-1 flex flex-col gap-8 overflow-y-auto scroll-smooth nc-scrollbar-thin px-12 py-6 mx-auto"
>
<iframe
src="http://localhost:8080/api/v2/scripts"
class="iframe-container"
sandbox="allow-scripts allow-forms allow-same-origin allow-modals allow-popups allow-presentation"
allow="geolocation *; microphone *; camera *; midi *; encrypted-media *;"
referrerpolicy="no-referrer"
loading="lazy"
importance="high"
height="100%"
frameborder="0"
scrolling="no"
></iframe>
</div>
<div class="h-full bg-gray-50 border-l-1 w-80 p-5 rounded-br-2xl border-gray-200">
<div class="w-full flex flex-col gap-3">
<h2 class="text-sm text-gray-700 font-semibold !my-0">{{ $t('labels.supportDocs') }}</h2>
<div>
<div v-for="(doc, idx) of supportedDocs" :key="idx" class="flex items-center gap-1">
<div class="h-7 w-7 flex items-center justify-center">
<GeneralIcon icon="bookOpen" class="flex-none w-4 h-4 text-gray-500" />
</div>
<NuxtLink
:href="doc.href"
target="_blank"
rel="noopener noreferrer"
class="!text-gray-500 text-sm !no-underline !hover:underline"
>
{{ doc.title }}
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</NcModal>
</template>
<style lang="scss">
.nc-modal-scripts-create-edit {
z-index: 1050;
a {
@apply !no-underline !text-gray-700 !hover:text-primary;
}
.nc-modal {
@apply !p-0;
height: min(calc(100vh - 100px), 1024px);
max-height: min(calc(100vh - 100px), 1024px) !important;
}
.nc-modal-header {
@apply !mb-0 !pb-0;
}
}
</style>

28
packages/nc-gui/components/smartsheet/Topbar.vue

@ -8,6 +8,8 @@ const isPublic = inject(IsPublicInj, ref(false))
const { isViewsLoading } = storeToRefs(useViewsStore())
const { isScriptsEnabled } = useScripts()
const { isMobileMode } = storeToRefs(useConfigStore())
const { appInfo } = useGlobal()
@ -78,6 +80,32 @@ const topbarBreadcrumbItemWidth = computed(() => {
</div>
</NcButton>
<div v-else-if="!isSharedBase && !extensionsEgg" class="w-[15px] h-[15px] cursor-pointer" @dblclick="onEggClick" />
<NcButton
v-if="!isSharedBase"
v-e="['c:scripts-toggle']"
type="secondary"
size="small"
class="nc-topbar-scripts-btn"
:class="{ '!bg-brand-50 !hover:bg-brand-100/70 !text-brand-500': isScriptsEnabled }"
data-testid="nc-topbar-extension-btn"
@click="isScriptsEnabled = !isScriptsEnabled"
>
<div class="flex items-center justify-center min-w-[28.69px]">
<GeneralIcon
icon="ncScript"
class="w-4 h-4 !stroke-transparent"
:class="{ 'border-l-1 border-transparent': isScriptsEnabled }"
/>
<span
class="overflow-hidden trasition-all duration-200"
:class="{ 'w-[0px] ': isScriptsEnabled, 'ml-1 w-[54px]': !isScriptsEnabled }"
>
{{ $t('general.scripts') }}
</span>
</div>
</NcButton>
<div v-if="!isSharedBase">
<LazySmartsheetTopbarCmdK />
</div>

4
packages/nc-gui/components/tabs/Smartsheet.vue

@ -18,6 +18,8 @@ const { isMobileMode } = useGlobal()
const activeTab = toRef(props, 'activeTab')
const { isScriptsEnabled } = useScripts()
const route = useRoute()
const meta = computed<TableType | undefined>(() => {
@ -236,6 +238,8 @@ const onReady = () => {
</div>
</Pane>
<ExtensionsPane ref="extensionPaneRef" />
<Scripts :modal-visible="isScriptsEnabled" />
</Splitpanes>
<SmartsheetDetails v-else />
</div>

17
packages/nc-gui/composables/useScripts.ts

@ -0,0 +1,17 @@
export const useScripts = createSharedComposable(() => {
const isScriptsEnabled = ref(false)
const { $api } = useNuxtApp()
const execScript = async (code: string) => {
const data = $api.scripts.scriptExec({
body: { code },
})
console.log(data)
return data
}
return { isScriptsEnabled, execScript }
})

1
packages/nc-gui/lang/en.json

@ -93,6 +93,7 @@
"none": "None"
},
"general": {
"scripts": "Scripts",
"configure": "Configure",
"switch": "Switch",
"on": "On",

245
packages/nocodb/src/controllers/scripts.controller.ts

@ -0,0 +1,245 @@
import {
Body,
Controller,
Get,
Post,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { CodeInterpreter } from '@e2b/code-interpreter';
import Sandbox from 'v8-sandbox';
import { Response } from 'express';
import request from 'supertest';
import { GlobalGuard } from '~/guards/global/global.guard';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { TenantContext } from '~/decorators/tenant-context.decorator';
import { NcContext, NcRequest } from '~/interface/config';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class ScriptsController {
constructor() {}
@Post(['/api/v2/scripts/exec'])
async scriptExec(
@TenantContext() context: NcContext,
@Body() body,
@Req() req: NcRequest,
) {
console.log(body);
const sandbox = new Sandbox();
const { error, output, value } = await sandbox.execute({
code: body.code,
timeout: 1000,
});
console.log(value, output, error);
// return output;
const sandboxE2b = await CodeInterpreter.create({
apiKey: 'e2b_28eedec39c0398d545f5cfa3e2b2332a13d3c4e2',
});
const x = await sandboxE2b.notebook.execCell(body.code);
console.log(x);
return { x, output };
}
@Get(['/api/v2/scripts'])
async scriptHTML(@Res() res: Response, @Req() req: NcRequest) {
res.setHeader('Content-Type', 'text/html');
res.setHeader('Cache-Control', 'no-store, max-age=0');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader(
'Permissions-Policy',
'geolocation=(), microphone=(), camera=(), midi=(), encrypted-media=()',
);
res.setHeader(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains; preload',
);
const cspDirectives = [
"default-src 'none'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://cdnjs.cloudflare.com https://cdn.jsdelivr.net",
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com",
`connect-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net http://localhost:8080`,
"font-src 'self' https://cdnjs.cloudflare.com",
"worker-src 'self' blob:",
'child-src blob:',
"base-uri 'none'",
"form-action 'none'",
];
// res.setHeader('Content-Security-Policy', cspDirectives.join('; '));
res.send(
generateHTML({
token: req.headers['Xc-Auth'] as string,
url: 'http://localhost:8080',
}),
);
}
}
const generateHTML = (config: { token: string; url: string }) => {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Secure Script Executor</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.30.1/min/vs/loader.js"></script>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<style>
body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: 'Arial', sans-serif;
}
#editor, #output {
height: calc(100vh - 7rem);
}
.console-line {
padding: 2px 5px;
border-bottom: 1px solid #e2e8f0;
}
.log { color: #2d3748; }
.error { color: #e53e3e; }
.warn { color: #d69e2e; }
</style>
</head>
<body class="bg-gray-100">
<nav class="bg-blue-600 text-white p-4 flex justify-between items-center">
<h1 class="text-xl font-bold">Secure Script Executor</h1>
<button onclick="runScript()" class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded transition duration-300 ease-in-out transform hover:scale-105">
Run Script
</button>
</nav>
<div class="flex flex-1 overflow-hidden">
<div id="editor" class="w-1/2 border-r border-gray-300"></div>
<div class="w-1/2 flex flex-col">
<div class="bg-gray-200 p-2 font-bold">Console Output</div>
<div id="output" class="flex-1 overflow-auto bg-white p-2"></div>
</div>
</div>
<footer class="bg-gray-200 p-2 text-center text-sm text-gray-600">
Secure Script Executor &copy; 2023
</footer>
<script>
require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.30.1/min/vs' } })
require(['vs/editor/editor.main'], function () {
window.editor = monaco.editor.create(document.getElementById('editor'), {
value: 'console.log("Hello World");',
language: 'javascript',
theme: 'vs-dark',
automaticLayout: true,
minimap: { enabled: false },
})
})
let scriptWorker;
function createWorker() {
const workerCode = \`
'use strict';
// Security restrictions
const restrictedGlobals = ['window', 'document', 'location', 'top', 'parent', 'frames', 'opener'];
restrictedGlobals.forEach(name => {
Object.defineProperty(self, name, {
get: () => {
throw new ReferenceError(name + ' is not defined');
},
configurable: false
});
});
// Restricted access to APIs
self.XMLHttpRequest = undefined;
self.fetch = undefined;
self.WebSocket = undefined;
self.localStorage = undefined;
self.sessionStorage = undefined;
self.console = {
log: (...args) => self.postMessage({type: 'log', message: args.join(' ')}),
error: (...args) => self.postMessage({type: 'error', message: args.join(' ')}),
warn: (...args) => self.postMessage({type: 'warn', message: args.join(' ')})
};
// Importing NocoDB SDK in the worker
import { Api } from 'https://cdn.jsdelivr.net/npm/nocodb-sdk@0.255.2/+esm';
// Initialize NocoDB SDK in the worker
const api = new Api({
baseURL: '${config.url}',
axiosConfig: {
headers: {
'xc-auth': '${config.token}',
},
},
});
self.onmessage = function(event) {
const script = event.data.script;
try {
eval(script);
} catch (error) {
self.console.error(error.toString());
}
self.postMessage({type: 'done'});
};
\`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob), { type: 'module' });
}
function runScript() {
const script = window.editor.getValue();
const output = document.getElementById('output');
output.innerHTML = '';
if (scriptWorker) {
scriptWorker.terminate();
}
scriptWorker = createWorker();
scriptWorker.onmessage = function (event) {
const { type, message } = event.data;
if (type === 'done') {
scriptWorker.terminate();
scriptWorker = null;
} else {
const line = document.createElement('div');
line.textContent = message;
line.className = 'console-line ' + type;
output.appendChild(line);
}
};
scriptWorker.onerror = function (error) {
const line = document.createElement('div');
line.textContent = 'Worker error: ' + error.message;
line.className = 'console-line error';
output.appendChild(line);
scriptWorker.terminate();
scriptWorker = null;
};
scriptWorker.postMessage({ script });
}
</script>
</body>
</html>
`;
};

2
packages/nocodb/src/modules/noco.module.ts

@ -124,6 +124,7 @@ import { CalendarDatasController } from '~/controllers/calendars-datas.controlle
import { CalendarDatasService } from '~/services/calendar-datas.service';
import { IntegrationsController } from '~/controllers/integrations.controller';
import { IntegrationsService } from '~/services/integrations.service';
import { ScriptsController } from '~/controllers/scripts.controller';
export const nocoModuleMetadata = {
imports: [
@ -196,6 +197,7 @@ export const nocoModuleMetadata = {
OldDatasController,
PublicDatasController,
PublicDatasExportController,
ScriptsController,
]
: []),
],

35
packages/nocodb/src/schema/swagger.json

@ -18130,6 +18130,41 @@
"description": "Get dynamic command palette suggestions based on scope"
}
},
"/api/v2/scripts/exec": {
"post": {
"summary": "Exec Scripts",
"operationId": "script-exec",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"type": "object"
}
}
}
}
}
}
}
},
"tags": [
"Scripts"
],
"description": "Execute scripts",
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
]
}
},
"/api/v2/extensions/{baseId}": {
"parameters": [
{

Loading…
Cancel
Save