Browse Source

Merge branch 'develop' into develop

pull/5531/head
nith2001 2 years ago committed by GitHub
parent
commit
24cd94948d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .run/Run NocoDB Sqlite.run.xml
  2. 95
      build-local-docker-image.sh
  3. 13860
      package-lock.json
  4. 52
      packages/nc-gui/components/cell/DateTimePicker.vue
  5. 33
      packages/nc-gui/components/cell/SingleSelect.vue
  6. 4
      packages/nc-gui/components/dashboard/TreeView.vue
  7. 4
      packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue
  8. 7
      packages/nc-gui/components/dlg/ProjectDuplicate.vue
  9. 2
      packages/nc-gui/components/dlg/TableDuplicate.vue
  10. 7
      packages/nc-gui/components/smartsheet/Cell.vue
  11. 8
      packages/nc-gui/components/smartsheet/Gallery.vue
  12. 6
      packages/nc-gui/components/smartsheet/Grid.vue
  13. 28
      packages/nc-gui/components/smartsheet/column/FormulaOptions.vue
  14. 2
      packages/nc-gui/components/smartsheet/header/Cell.vue
  15. 3
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue
  16. 5
      packages/nc-gui/components/virtual-cell/BelongsTo.vue
  17. 6
      packages/nc-gui/components/virtual-cell/Formula.vue
  18. 5
      packages/nc-gui/components/virtual-cell/HasMany.vue
  19. 3
      packages/nc-gui/components/virtual-cell/Lookup.vue
  20. 5
      packages/nc-gui/components/virtual-cell/ManyToMany.vue
  21. 4
      packages/nc-gui/components/virtual-cell/components/ItemChip.vue
  22. 4
      packages/nc-gui/components/virtual-cell/components/ListChildItems.vue
  23. 6
      packages/nc-gui/components/virtual-cell/components/ListItems.vue
  24. 3
      packages/nc-gui/composables/useApi/interceptors.ts
  25. 6
      packages/nc-gui/composables/useGridViewColumnWidth.ts
  26. 2
      packages/nc-gui/composables/useMultiSelect/convertCellData.ts
  27. 54
      packages/nc-gui/composables/useMultiSelect/index.ts
  28. 27
      packages/nc-gui/composables/useSharedFormViewStore.ts
  29. 1
      packages/nc-gui/context/index.ts
  30. 12
      packages/nc-gui/lang/fr.json
  31. 8
      packages/nc-gui/lang/ru.json
  32. 8
      packages/nc-gui/lang/zh-Hans.json
  33. 2
      packages/nc-gui/lib/types.ts
  34. 1
      packages/nc-gui/nuxt-shim.d.ts
  35. 14
      packages/nc-gui/package-lock.json
  36. 13
      packages/nc-gui/pages/index/index/index.vue
  37. 2
      packages/nc-gui/plugins/a.dayjs.ts
  38. 17
      packages/nc-gui/plugins/jobs.ts
  39. 7
      packages/nc-gui/store/project.ts
  40. 23
      packages/nc-gui/utils/cell.ts
  41. 8
      packages/nc-gui/utils/dateTimeUtils.ts
  42. 1
      packages/nc-gui/utils/index.ts
  43. 2
      packages/nc-lib-gui/package.json
  44. 10
      packages/noco-docs/content/en/engineering/development-setup.md
  45. 4
      packages/nocodb-sdk/package-lock.json
  46. 2
      packages/nocodb-sdk/package.json
  47. 103
      packages/nocodb-sdk/src/lib/Api.ts
  48. 2
      packages/nocodb-sdk/src/lib/formulaHelpers.ts
  49. 10
      packages/nocodb/.eslintrc.js
  50. 4
      packages/nocodb/Dockerfile
  51. 5
      packages/nocodb/Dockerfile.local
  52. 32
      packages/nocodb/package-lock.json
  53. 6
      packages/nocodb/package.json
  54. 84
      packages/nocodb/src/Noco.ts
  55. 60
      packages/nocodb/src/app.module.ts
  56. 14
      packages/nocodb/src/cache/RedisCacheMgr.ts
  57. 2
      packages/nocodb/src/cache/RedisMockCacheMgr.ts
  58. 19
      packages/nocodb/src/connection/connection.spec.ts
  59. 37
      packages/nocodb/src/connection/connection.ts
  60. 1
      packages/nocodb/src/controllers/api-tokens.controller.ts
  61. 1
      packages/nocodb/src/controllers/attachments.controller.ts
  62. 1
      packages/nocodb/src/controllers/audits.controller.ts
  63. 1
      packages/nocodb/src/controllers/bases.controller.ts
  64. 1
      packages/nocodb/src/controllers/bulk-data-alias.controller.ts
  65. 1
      packages/nocodb/src/controllers/columns.controller.ts
  66. 1
      packages/nocodb/src/controllers/data-alias-export.controller.ts
  67. 3
      packages/nocodb/src/controllers/data-alias-nested.controller.ts
  68. 1
      packages/nocodb/src/controllers/data-alias.controller.ts
  69. 4
      packages/nocodb/src/controllers/filters.controller.ts
  70. 1
      packages/nocodb/src/controllers/form-columns.controller.ts
  71. 1
      packages/nocodb/src/controllers/galleries.controller.ts
  72. 1
      packages/nocodb/src/controllers/grid-columns.controller.ts
  73. 1
      packages/nocodb/src/controllers/grids.controller.ts
  74. 1
      packages/nocodb/src/controllers/maps.controller.ts
  75. 1
      packages/nocodb/src/controllers/meta-diffs.controller.ts
  76. 1
      packages/nocodb/src/controllers/model-visibilities.controller.ts
  77. 1
      packages/nocodb/src/controllers/old-datas/old-datas.controller.ts
  78. 1
      packages/nocodb/src/controllers/org-users.controller.ts
  79. 10
      packages/nocodb/src/controllers/plugins.controller.ts
  80. 1
      packages/nocodb/src/controllers/project-users.controller.ts
  81. 77
      packages/nocodb/src/controllers/projects.controller.ts
  82. 1
      packages/nocodb/src/controllers/shared-bases.controller.ts
  83. 1
      packages/nocodb/src/controllers/sorts.controller.ts
  84. 1
      packages/nocodb/src/controllers/tables.controller.ts
  85. 117
      packages/nocodb/src/controllers/users/users.controller.ts
  86. 3
      packages/nocodb/src/controllers/view-columns.controller.ts
  87. 6
      packages/nocodb/src/db/BaseModelSql.ts
  88. 320
      packages/nocodb/src/db/BaseModelSqlv2.ts
  89. 12
      packages/nocodb/src/db/CustomKnex.ts
  90. 2
      packages/nocodb/src/db/conditionV2.ts
  91. 54
      packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts
  92. 8
      packages/nocodb/src/db/functionMappings/mssql.ts
  93. 2
      packages/nocodb/src/db/functionMappings/mysql.ts
  94. 6
      packages/nocodb/src/db/functionMappings/sqlite.ts
  95. 2
      packages/nocodb/src/db/mapFunctionName.ts
  96. 2
      packages/nocodb/src/db/sql-client/lib/SqlClientFactory.ts
  97. 12
      packages/nocodb/src/db/sql-migrator/lib/KnexMigrator.ts
  98. 281
      packages/nocodb/src/helpers/initAdminFromEnv.ts
  99. 3
      packages/nocodb/src/index.ts
  100. 6
      packages/nocodb/src/init.ts
  101. Some files were not shown because too many files have changed in this diff Show More

2
.run/Run NocoDB Sqlite.run.xml

@ -12,4 +12,4 @@
</envs>
<method v="2" />
</configuration>
</component>
</component>

95
build-local-docker-image.sh

@ -8,35 +8,70 @@
# 3. build nocodb
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
LOG_FILE=${SCRIPT_DIR}/build-local-docker-image.log
ERROR=""
#build nocodb-sdk
echo "Building nocodb-sdk"
cd ${SCRIPT_DIR}/packages/nocodb-sdk
npm ci
npm run build
# build nc-gui
echo "Building nc-gui"
export NODE_OPTIONS="--max_old_space_size=16384"
# generate static build of nc-gui
cd ${SCRIPT_DIR}/packages/nc-gui
npm ci
npm run generate
# copy nc-gui build to nocodb dir
rsync -rvzh --delete ./dist/ ${SCRIPT_DIR}/packages/nocodb/docker/nc-gui/
#build nocodb
# build nocodb ( pack nocodb-sdk and nc-gui )
cd ${SCRIPT_DIR}/packages/nocodb
npm install
EE=true ./node_modules/.bin/webpack --config webpack.local.config.js
# remove nocodb-sdk since it's packed with the build
npm uninstall --save nocodb-sdk
# build docker
docker build . -f Dockerfile.local -t nocodb-local
echo 'docker image with tag "nocodb-local" built sussessfully. Use below sample command to run the container'
echo 'docker run -d -p 3333:8080 --name nocodb-local nocodb-local '
function build_sdk(){
#build nocodb-sdk
cd ${SCRIPT_DIR}/packages/nocodb-sdk
npm ci || ERROR="sdk build failed"
npm run build || ERROR="sdk build failed"
}
function build_gui(){
# build nc-gui
export NODE_OPTIONS="--max_old_space_size=16384"
# generate static build of nc-gui
cd ${SCRIPT_DIR}/packages/nc-gui
npm ci || ERROR="gui build failed"
npm run generate || ERROR="gui build failed"
}
function copy_gui_artifacts(){
# copy nc-gui build to nocodb dir
rsync -rvzh --delete ./dist/ ${SCRIPT_DIR}/packages/nocodb/docker/nc-gui/ || ERROR="copy_gui_artifacts failed"
}
function package_nocodb(){
#build nocodb
# build nocodb ( pack nocodb-sdk and nc-gui )
cd ${SCRIPT_DIR}/packages/nocodb
npm install || ERROR="package_nocodb failed"
EE=true ./node_modules/.bin/webpack --config webpack.local.config.js || ERROR="package_nocodb failed"
}
function build_image(){
# build docker
docker build . -f Dockerfile.local -t nocodb-local || ERROR="build_image failed"
}
function log_message(){
if [[ ${ERROR} != "" ]];
then
>&2 echo "build failed, Please check build-local-docker-image.log for more details"
>&2 echo "ERROR: ${ERROR}"
exit 1
else
echo 'docker image with tag "nocodb-local" built sussessfully. Use below sample command to run the container'
echo 'docker run -d -p 3333:8080 --name nocodb-local nocodb-local '
fi
}
echo "Info: Building nocodb-sdk" | tee ${LOG_FILE}
build_sdk 1>> ${LOG_FILE} 2>> ${LOG_FILE}
echo "Info: Building nc-gui" | tee -a ${LOG_FILE}
build_gui 1>> ${LOG_FILE} 2>> ${LOG_FILE}
echo "Info: copy nc-gui build to nocodb dir" | tee -a ${LOG_FILE}
copy_gui_artifacts 1>> ${LOG_FILE} 2>> ${LOG_FILE}
echo "Info: build nocodb, package nocodb-sdk and nc-gui" | tee -a ${LOG_FILE}
package_nocodb 1>> ${LOG_FILE} 2>> ${LOG_FILE}
if [[ ${ERROR} == "" ]]; then
echo "Info: building docker image" | tee -a ${LOG_FILE}
build_image 1>> ${LOG_FILE} 2>> ${LOG_FILE}
fi
log_message | tee -a ${LOG_FILE}

13860
package-lock.json generated

File diff suppressed because it is too large Load Diff

52
packages/nc-gui/components/cell/DateTimePicker.vue

@ -18,13 +18,14 @@ import {
interface Props {
modelValue?: string | null
isPk?: boolean
isUpdatedFromCopyNPaste: Record<string, boolean>
}
const { modelValue, isPk } = defineProps<Props>()
const { modelValue, isPk, isUpdatedFromCopyNPaste } = defineProps<Props>()
const emit = defineEmits(['update:modelValue'])
const { isMysql } = useProject()
const { isMssql, isXcdbBase } = useProject()
const { showNull } = useGlobal()
@ -44,6 +45,8 @@ const dateTimeFormat = $computed(() => {
return `${dateFormat} ${timeFormat}`
})
let localModelValue = modelValue ? dayjs(modelValue).utc().local() : undefined
let localState = $computed({
get() {
if (!modelValue) {
@ -55,7 +58,45 @@ let localState = $computed({
return undefined
}
return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)
const isXcDB = isXcdbBase(column.value.base_id)
// cater copy and paste
// when copying a datetime cell, the copied value would be local time
// when pasting a datetime cell, UTC (xcdb) will be saved in DB
// we convert back to local time
if (column.value.title! in (isUpdatedFromCopyNPaste ?? {})) {
localModelValue = dayjs(modelValue).utc().local()
return localModelValue
}
// ext db
if (!isXcDB) {
return /^\d+$/.test(modelValue) ? dayjs(+modelValue) : dayjs(modelValue)
}
if (isMssql(column.value.base_id)) {
// e.g. 2023-04-29T11:41:53.000Z
return dayjs(modelValue)
}
// if cdf is defined, that means the value is auto-generated
// hence, show the local time
if (column?.value?.cdf) {
return dayjs(modelValue).utc().local()
}
// if localModelValue is defined, show localModelValue instead
// localModelValue is set in setter below
if (localModelValue) {
const res = localModelValue
// resetting localModelValue here
// e.g. save in expanded form -> render the correct modelValue
localModelValue = undefined
return res
}
// empty cell - use modelValue in local time
return dayjs(modelValue).utc().local()
},
set(val?: dayjs.Dayjs) {
if (!val) {
@ -64,7 +105,10 @@ let localState = $computed({
}
if (val.isValid()) {
emit('update:modelValue', val?.format(isMysql(column.value.base_id) ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ'))
// setting localModelValue to cater NOW function in date picker
localModelValue = dayjs(val)
// send the payload in UTC format
emit('update:modelValue', dayjs(val).utc().format('YYYY-MM-DD HH:mm:ssZ'))
}
},
})

33
packages/nc-gui/components/cell/SingleSelect.vue

@ -68,6 +68,10 @@ const { isPg, isMysql } = useProject()
// temporary until it's add the option to column meta
const tempSelectedOptState = ref<string>()
const isNewOptionCreateEnabled = computed(
() => !isPublic.value && !disableOptionCreation && (hasRole('owner', true) || hasRole('creator', true)),
)
const options = computed<(SelectOptionType & { value: string })[]>(() => {
if (column?.value.colOptions) {
const opts = column.value.colOptions
@ -93,11 +97,9 @@ const editAllowed = computed(() => (hasEditRoles.value || isForm.value) && (acti
const vModel = computed({
get: () => tempSelectedOptState.value ?? modelValue,
set: (val) => {
if (isOptionMissing.value && val === searchVal.value) {
if (val && isNewOptionCreateEnabled.value && (options.value ?? []).every((op) => op.title !== val)) {
tempSelectedOptState.value = val
return addIfMissingAndSave().finally(() => {
tempSelectedOptState.value = undefined
})
return addIfMissingAndSave()
}
emit('update:modelValue', val || null)
},
@ -146,10 +148,11 @@ useSelectedCellKeyupListener(isOpen, (e) => {
})
async function addIfMissingAndSave() {
if (!searchVal.value || isPublic.value) return false
if (!tempSelectedOptState.value || isPublic.value) return false
const newOptValue = searchVal.value
const newOptValue = tempSelectedOptState.value
searchVal.value = ''
tempSelectedOptState.value = undefined
if (newOptValue && !options.value.some((o) => o.title === newOptValue)) {
try {
@ -305,17 +308,7 @@ const selectedOpt = computed(() => {
</span>
</a-tag>
</a-select-option>
<a-select-option
v-if="
searchVal &&
isOptionMissing &&
!isPublic &&
!disableOptionCreation &&
(hasRole('owner', true) || hasRole('creator', true))
"
:key="searchVal"
:value="searchVal"
>
<a-select-option v-if="searchVal && isOptionMissing && isNewOptionCreateEnabled" :key="searchVal" :value="searchVal">
<div class="flex gap-2 text-gray-500 items-center h-full">
<component :is="iconMap.plusThick" class="min-w-4" />
<div class="text-xs whitespace-normal">
@ -351,12 +344,6 @@ const selectedOpt = computed(() => {
@apply !px-0;
}
:deep(.ant-select-selection-search) {
// following a-select with mode = multiple | tags
// initial width will block @mouseover in Grid.vue
@apply !w-[5px];
}
:deep(.ant-select-selection-search-input) {
@apply !text-xs;
}

4
packages/nc-gui/components/dashboard/TreeView.vue

@ -399,8 +399,8 @@ const duplicateTable = async (table: TableType) => {
const { close } = useDialog(resolveComponent('DlgTableDuplicate'), {
'modelValue': isOpen,
'table': table,
'onOk': async (jobData: { name: string; id: string }) => {
$jobs.subscribe({ name: jobData.name, id: jobData.id }, undefined, async (status: string, data?: any) => {
'onOk': async (jobData: { id: string }) => {
$jobs.subscribe({ id: jobData.id }, undefined, async (status: string, data?: any) => {
if (status === JobStatus.COMPLETED) {
await loadTables()
const newTable = tables.value.find((el) => el.id === data?.result?.id)

4
packages/nc-gui/components/dashboard/settings/data-sources/CreateBase.vue

@ -495,7 +495,7 @@ watch(
</a-form-item>
<div class="flex items-right justify-end gap-2">
<!-- Use Connection URL -->
<a-button type="primary" class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true">
<a-button class="nc-extdb-btn-import-url !rounded-md" @click.stop="importURLDlg = true">
{{ $t('activity.useConnectionUrl') }}
</a-button>
</div>
@ -611,7 +611,7 @@ watch(
<a-form-item class="flex justify-end !mt-5">
<div class="flex justify-end gap-2">
<a-button type="text" class="nc-extdb-btn-test-connection !rounded-md" @click="testConnection">
<a-button type="primary" class="nc-extdb-btn-test-connection !rounded-md" @click="testConnection">
{{ $t('activity.testDbConn') }}
</a-button>

7
packages/nc-gui/components/dlg/ProjectDuplicate.vue

@ -34,7 +34,12 @@ const isLoading = ref(false)
const _duplicate = async () => {
isLoading.value = true
try {
const jobData = await api.project.duplicate(props.project.id as string, optionsToExclude.value)
const jobData = await api.project.duplicate(props.project.id as string, {
options: optionsToExclude.value,
project: {
meta: props.project.meta,
},
})
props.onOk(jobData as any)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))

2
packages/nc-gui/components/dlg/TableDuplicate.vue

@ -34,7 +34,7 @@ const isLoading = ref(false)
const _duplicate = async () => {
isLoading.value = true
try {
const jobData = await api.dbTable.duplicate(props.table.project_id!, props.table.id!, optionsToExclude.value)
const jobData = await api.dbTable.duplicate(props.table.project_id!, props.table.id!, { options: optionsToExclude.value })
props.onOk(jobData as any)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))

7
packages/nc-gui/components/smartsheet/Cell.vue

@ -209,7 +209,12 @@ onUnmounted(() => {
<LazyCellMultiSelect v-else-if="isMultiSelect(column)" v-model="vModel" :row-index="props.rowIndex" />
<LazyCellDatePicker v-else-if="isDate(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellYearPicker v-else-if="isYear(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker v-else-if="isDateTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellDateTimePicker
v-else-if="isDateTime(column, abstractType)"
v-model="vModel"
:is-pk="isPrimaryKey(column)"
:is-updated-from-copy-n-paste="currentRow.rowMeta.isUpdatedFromCopyNPaste"
/>
<LazyCellTimePicker v-else-if="isTime(column, abstractType)" v-model="vModel" :is-pk="isPrimaryKey(column)" />
<LazyCellRating v-else-if="isRating(column)" v-model="vModel" />
<LazyCellDuration v-else-if="isDuration(column)" v-model="vModel" />

8
packages/nc-gui/components/smartsheet/Gallery.vue

@ -7,6 +7,7 @@ import {
IsFormInj,
IsGalleryInj,
IsGridInj,
IsPublicInj,
MetaInj,
NavigateDir,
OpenNewRecordFormHookInj,
@ -62,6 +63,8 @@ provide(IsGridInj, ref(false))
provide(PaginationDataInj, paginationData)
provide(ChangePageInj, changePage)
const isPublic = inject(IsPublicInj, ref(false))
const fields = inject(FieldsInj, ref([]))
const route = useRoute()
@ -125,6 +128,10 @@ const attachments = (record: any): Attachment[] => {
}
const expandForm = (row: RowType, state?: Record<string, any>) => {
if (isPublic.value) {
return
}
const rowId = extractPkFromRow(row.row, meta.value!.columns!)
if (rowId) {
@ -234,6 +241,7 @@ watch(view, async (nextView) => {
:data-testid="`nc-gallery-card-${record.row.id}`"
@click="expandFormClick($event, record)"
@contextmenu="showContextMenu($event, { row: rowIndex })"
:style="isPublic ? { cursor: 'default' } : { cursor: 'pointer' }"
>
<template v-if="galleryData?.fk_cover_image_col_id" #cover>
<a-carousel v-if="!reloadAttachments && attachments(record).length" autoplay class="gallery-carousel" arrows>

6
packages/nc-gui/components/smartsheet/Grid.vue

@ -316,6 +316,12 @@ const {
return
}
// See DateTimePicker.vue for details
data.value[ctx.row].rowMeta.isUpdatedFromCopyNPaste = {
...data.value[ctx.row].rowMeta.isUpdatedFromCopyNPaste,
[ctx.updatedColumnTitle || columnObj.title]: true,
}
// update/save cell value
await updateOrSaveRow(rowObj, ctx.updatedColumnTitle || columnObj.title)
},

28
packages/nc-gui/components/smartsheet/column/FormulaOptions.vue

@ -149,28 +149,29 @@ function parseAndValidateFormula(formula: string) {
function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = new Set()) {
if (parsedTree.type === JSEPNode.CALL_EXP) {
const calleeName = parsedTree.callee.name.toUpperCase()
// validate function name
if (!availableFunctions.includes(parsedTree.callee.name)) {
errors.add(`'${parsedTree.callee.name}' function is not available`)
if (!availableFunctions.includes(calleeName)) {
errors.add(`'${calleeName}' function is not available`)
}
// validate arguments
const validation = formulas[parsedTree.callee.name] && formulas[parsedTree.callee.name].validation
const validation = formulas[calleeName] && formulas[calleeName].validation
if (validation && validation.args) {
if (validation.args.rqd !== undefined && validation.args.rqd !== parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required ${validation.args.rqd} arguments`)
errors.add(`'${calleeName}' required ${validation.args.rqd} arguments`)
} else if (validation.args.min !== undefined && validation.args.min > parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required minimum ${validation.args.min} arguments`)
errors.add(`'${calleeName}' required minimum ${validation.args.min} arguments`)
} else if (validation.args.max !== undefined && validation.args.max < parsedTree.arguments.length) {
errors.add(`'${parsedTree.callee.name}' required maximum ${validation.args.max} arguments`)
errors.add(`'${calleeName}' required maximum ${validation.args.max} arguments`)
}
}
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstMeta(arg, errors))
// validate data type
if (parsedTree.callee.type === JSEPNode.IDENTIFIER) {
const expectedType = formulas[parsedTree.callee.name].type
const expectedType = formulas[calleeName.toUpperCase()].type
if (expectedType === formulaTypes.NUMERIC) {
if (parsedTree.callee.name === 'WEEKDAY') {
if (calleeName === 'WEEKDAY') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
@ -202,7 +203,7 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
parsedTree.arguments.map((arg: Record<string, any>) => validateAgainstType(arg, expectedType, null, typeErrors))
}
} else if (expectedType === formulaTypes.DATE) {
if (parsedTree.callee.name === 'DATEADD') {
if (calleeName === 'DATEADD') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
@ -236,7 +237,7 @@ function validateAgainstMeta(parsedTree: any, errors = new Set(), typeErrors = n
},
typeErrors,
)
} else if (parsedTree.callee.name === 'DATETIME_DIFF') {
} else if (calleeName === 'DATETIME_DIFF') {
// parsedTree.arguments[0] = date
validateAgainstType(
parsedTree.arguments[0],
@ -504,8 +505,9 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t
typeErrors.add(`${formulaTypes.NUMERIC} type is found but ${expectedType} type is expected`)
}
} else if (parsedTree.type === JSEPNode.CALL_EXP) {
if (formulas[parsedTree.callee.name]?.type && expectedType !== formulas[parsedTree.callee.name].type) {
typeErrors.add(`${expectedType} not matched with ${formulas[parsedTree.callee.name].type}`)
const calleeName = parsedTree.callee.name.toUpperCase()
if (formulas[calleeName]?.type && expectedType !== formulas[calleeName].type) {
typeErrors.add(`${expectedType} not matched with ${formulas[calleeName].type}`)
}
}
return typeErrors
@ -514,7 +516,7 @@ function validateAgainstType(parsedTree: any, expectedType: string, func: any, t
function getRootDataType(parsedTree: any): any {
// given a parse tree, return the data type of it
if (parsedTree.type === JSEPNode.CALL_EXP) {
return formulas[parsedTree.callee.name].type
return formulas[parsedTree.callee.name.toUpperCase()].type
} else if (parsedTree.type === JSEPNode.IDENTIFIER) {
const col = supportedColumns.value.find((c) => c.title === parsedTree.name) as Record<string, any>
if (col?.uidt === UITypes.Formula) {

2
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -52,7 +52,7 @@ const openHeaderMenu = () => {
<span
v-if="column"
class="name"
:class="{ 'cursor-pointer': !isForm && isUIAllowed('edit-column') }"
:class="{ 'cursor-pointer': !isForm && isUIAllowed('edit-column') && !hideMenu }"
style="white-space: pre-line"
:title="column.title"
@dblclick="openHeaderMenu"

3
packages/nc-gui/components/smartsheet/toolbar/ColumnFilter.vue

@ -225,7 +225,8 @@ defineExpose({
/>
<span v-else :key="`${i}dummy`" />
<div :key="`${i}nested`" class="flex">
<span v-if="!i" class="flex items-center">{{ $t('labels.where') }}</span>
<div v-else :key="`${i}nested`" class="flex bob">
<a-select
v-model:value="filter.logical_op"
:dropdown-match-select-width="false"

5
packages/nc-gui/components/virtual-cell/BelongsTo.vue

@ -7,6 +7,7 @@ import {
ColumnInj,
IsFormInj,
IsLockedInj,
IsUnderLookupInj,
ReadonlyInj,
ReloadRowDataHookInj,
RowInj,
@ -36,6 +37,8 @@ const isForm = inject(IsFormInj, ref(false))
const isLocked = inject(IsLockedInj, ref(false))
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const { isUIAllowed } = useUIPermission()
const listItemsDlg = ref(false)
@ -89,7 +92,7 @@ useSelectedCellKeyupListener(active, (e: KeyboardEvent) => {
</div>
<div
v-if="!readOnly && !isLocked && (isUIAllowed('xcDatatableEditable') || isForm)"
v-if="!readOnly && !isLocked && (isUIAllowed('xcDatatableEditable') || isForm) && !isUnderLookup"
class="flex justify-end gap-1 min-h-[30px] items-center"
>
<GeneralIcon

6
packages/nc-gui/components/virtual-cell/Formula.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import type { ColumnType } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, replaceUrlsWithLink, useProject } from '#imports'
import { CellValueInj, ColumnInj, computed, handleTZ, inject, renderValue, replaceUrlsWithLink, useProject } from '#imports'
// todo: column type doesn't have required property `error` - throws in typecheck
const column = inject(ColumnInj) as Ref<ColumnType & { colOptions: { error: any } }>
@ -10,7 +10,9 @@ const cellValue = inject(CellValueInj)
const { isPg } = useProject()
const result = computed(() => (isPg(column.value.base_id) ? handleTZ(cellValue?.value) : cellValue?.value))
const result = computed(() =>
isPg(column.value.base_id) ? renderValue(handleTZ(cellValue?.value)) : renderValue(cellValue?.value),
)
const urls = computed(() => replaceUrlsWithLink(result.value))

5
packages/nc-gui/components/virtual-cell/HasMany.vue

@ -16,6 +16,7 @@ import {
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useUIPermission,
IsUnderLookupInj
} from '#imports'
const column = inject(ColumnInj)!
@ -32,6 +33,8 @@ const readOnly = inject(ReadonlyInj, ref(false))
const isLocked = inject(IsLockedInj)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const listItemsDlg = ref(false)
const childListDlg = ref(false)
@ -112,7 +115,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
</template>
</div>
<div v-if="!isLocked" class="flex justify-end gap-1 min-h-[30px] items-center">
<div v-if="!isLocked && !isUnderLookup" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="select-none transform text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"

3
packages/nc-gui/components/virtual-cell/Lookup.vue

@ -5,6 +5,7 @@ import {
CellUrlDisableOverlayInj,
CellValueInj,
ColumnInj,
IsUnderLookupInj,
MetaInj,
computed,
inject,
@ -70,6 +71,8 @@ const arrValue = computed(() => {
provide(MetaInj, lookupTableMeta)
provide(IsUnderLookupInj, ref(true))
provide(CellUrlDisableOverlayInj, ref(true))
const { showEditNonEditableFieldWarning, showClearNonEditableFieldWarning, activateShowEditNonEditableFieldWarning } =

5
packages/nc-gui/components/virtual-cell/ManyToMany.vue

@ -7,6 +7,7 @@ import {
ColumnInj,
IsFormInj,
IsLockedInj,
IsUnderLookupInj,
ReadonlyInj,
ReloadRowDataHookInj,
RowInj,
@ -34,6 +35,8 @@ const readOnly = inject(ReadonlyInj, ref(false))
const isLocked = inject(IsLockedInj)
const isUnderLookup = inject(IsUnderLookupInj, ref(false))
const listItemsDlg = ref(false)
const childListDlg = ref(false)
@ -114,7 +117,7 @@ useSelectedCellKeyupListener(inject(ActiveCellInj, ref(false)), (e: KeyboardEven
</template>
</div>
<div v-if="!isLocked" class="flex justify-end gap-1 min-h-[30px] items-center">
<div v-if="!isLocked && !isUnderLookup" class="flex justify-end gap-1 min-h-[30px] items-center">
<GeneralIcon
icon="expand"
class="text-sm nc-action-icon text-gray-500/50 hover:text-gray-500 nc-arrow-expand"

4
packages/nc-gui/components/virtual-cell/components/ItemChip.vue

@ -7,9 +7,9 @@ import {
iconMap,
inject,
ref,
renderValue,
useExpandedFormDetached,
useLTARStoreOrThrow,
useUIPermission,
} from '#imports'
interface Props {
@ -60,7 +60,7 @@ export default {
:class="{ active }"
@click="openExpandedForm"
>
<span class="name">{{ value }}</span>
<span class="name">{{ renderValue(value) }}</span>
<div v-show="active || isForm" v-if="!readOnly && !isLocked && isUIAllowed('xcDatatableEditable')" class="flex items-center">
<component

4
packages/nc-gui/components/virtual-cell/components/ListChildItems.vue

@ -13,10 +13,10 @@ import {
iconMap,
inject,
ref,
renderValue,
useLTARStoreOrThrow,
useSmartsheetRowStoreOrThrow,
useVModel,
watch,
} from '#imports'
const props = defineProps<{ modelValue?: boolean; cellValue: any }>()
@ -148,7 +148,7 @@ const onClick = (row: Row) => {
>
<div class="flex items-center">
<div class="flex-1 overflow-hidden min-w-0">
{{ row[relatedTableDisplayValueProp] }}
{{ renderValue(row[relatedTableDisplayValueProp]) }}
<span class="text-gray-400 text-[11px] ml-1">(Primary key : {{ getRelatedTableRowId(row) }})</span>
</div>

6
packages/nc-gui/components/virtual-cell/components/ListItems.vue

@ -12,11 +12,11 @@ import {
inject,
isDrawerExist,
ref,
renderValue,
useLTARStoreOrThrow,
useSelectedCellKeyupListener,
useSmartsheetRowStoreOrThrow,
useVModel,
watch,
} from '#imports'
const props = defineProps<{ modelValue: boolean }>()
@ -213,7 +213,7 @@ watch(vModel, (nextVal) => {
<component :is="iconMap.reload" class="cursor-pointer text-gray-500 nc-reload" @click="loadChildrenExcludedList" />
<!-- Add new record -->
<!-- Add new record -->
<a-button v-if="!isPublic" type="primary" size="small" @click="expandedFormDlg = true">
{{ $t('activity.addNewRecord') }}
</a-button>
@ -229,7 +229,7 @@ watch(vModel, (nextVal) => {
:class="{ 'nc-selected-row': selectedRowIndex === i }"
@click="linkRow(refRow)"
>
{{ refRow[relatedTableDisplayValueProp] }}
{{ renderValue(refRow[relatedTableDisplayValueProp]) }}
<span class="hidden group-hover:(inline) text-gray-400 text-[11px] ml-1">
({{ $t('labels.primaryKey') }} : {{ getRelatedTableRowId(refRow) }})
</span>

3
packages/nc-gui/composables/useApi/interceptors.ts

@ -67,9 +67,8 @@ export function addAxiosInterceptors(api: Api<any>) {
})
.catch(async (error) => {
await state.signOut()
// todo: handle new user
navigateTo('/signIn')
if (!route.meta.public) navigateTo('/signIn')
return Promise.reject(error)
})

6
packages/nc-gui/composables/useGridViewColumnWidth.ts

@ -13,7 +13,7 @@ import {
watch,
} from '#imports'
export function useGridViewColumnWidth(view: Ref<ViewType | undefined>) {
export function useGridViewColumnWidth(view: Ref<(ViewType & { columns?: GridColumnType[] }) | undefined>) {
const { css, load: loadCss, unload: unloadCss } = useStyleTag('')
const { isUIAllowed } = useUIPermission()
@ -52,7 +52,9 @@ export function useGridViewColumnWidth(view: Ref<ViewType | undefined>) {
const loadGridViewColumns = async () => {
if (!view.value?.id && !isPublic.value) return
const colsData: GridColumnType[] = (isPublic.value ? columns.value : await $api.dbView.gridColumnsList(view.value!.id!)) ?? []
const colsData: GridColumnType[] =
(isPublic.value ? view.value?.columns : await $api.dbView.gridColumnsList(view.value!.id!)) ?? []
gridViewCols.value = colsData.reduce<Record<string, GridColumnType>>(
(o, col) => ({
...o,

2
packages/nc-gui/composables/useMultiSelect/convertCellData.ts

@ -42,7 +42,7 @@ export default function convertCellData(
if (!parsedDateTime.isValid()) {
throw new Error('Not a valid datetime value')
}
return parsedDateTime.format(dateFormat)
return parsedDateTime.utc().format('YYYY-MM-DD HH:mm:ssZ')
}
case UITypes.Time: {
let parsedTime = dayjs(value)

54
packages/nc-gui/composables/useMultiSelect/index.ts

@ -1,3 +1,4 @@
import dayjs from 'dayjs'
import type { MaybeRef } from '@vueuse/core'
import type { ColumnType, LinkToAnotherRecordType, TableType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
@ -7,6 +8,7 @@ import convertCellData from './convertCellData'
import type { Nullable, Row } from '~/lib'
import {
copyTable,
dateFormats,
extractPkFromRow,
extractSdkResponseErrorMsg,
isMac,
@ -14,6 +16,7 @@ import {
message,
reactive,
ref,
timeFormats,
unref,
useCopy,
useEventListener,
@ -79,6 +82,20 @@ export function useMultiSelect(
activeCell.col = col
}
function constructDateTimeFormat(column: ColumnType) {
const dateFormat = constructDateFormat(column)
const timeFormat = constructTimeFormat(column)
return `${dateFormat} ${timeFormat}`
}
function constructDateFormat(column: ColumnType) {
return parseProp(column?.meta)?.date_format ?? dateFormats[0]
}
function constructTimeFormat(column: ColumnType) {
return parseProp(column?.meta)?.time_format ?? timeFormats[0]
}
async function copyValue(ctx?: Cell) {
try {
if (selectedRange.start !== null && selectedRange.end !== null && !selectedRange.isSingleCell()) {
@ -106,6 +123,43 @@ export function useMultiSelect(
if (typeof textToCopy === 'object') {
textToCopy = JSON.stringify(textToCopy)
}
if (columnObj.uidt === UITypes.Formula) {
textToCopy = textToCopy.replace(/\b(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2})\b/g, (d: string) => {
// TODO(timezone): retrieve the format from the corresponding column meta
// assume hh:mm at this moment
return dayjs(d).utc().local().format('YYYY-MM-DD HH:mm')
})
}
if (columnObj.uidt === UITypes.DateTime || columnObj.uidt === UITypes.Time) {
// remove `"`
// e.g. "2023-05-12T08:03:53.000Z" -> 2023-05-12T08:03:53.000Z
textToCopy = textToCopy.replace(/["']/g, '')
const isMySQL = isMysql(columnObj.base_id)
let d = dayjs(textToCopy)
if (!d.isValid()) {
// insert a datetime value, copy the value without refreshing
// e.g. textToCopy = 2023-05-12T03:49:25.000Z
// feed custom parse format
d = dayjs(textToCopy, isMySQL ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD HH:mm:ssZ')
}
// users can change the datetime format in UI
// `textToCopy` would be always in YYYY-MM-DD HH:mm:ss(Z / +xx:yy) format
// therefore, here we reformat to the correct datetime format based on the meta
textToCopy = d.format(
columnObj.uidt === UITypes.DateTime ? constructDateTimeFormat(columnObj) : constructTimeFormat(columnObj),
)
if (columnObj.uidt === UITypes.DateTime && !dayjs(textToCopy).isValid()) {
throw new Error('Invalid DateTime')
}
}
await copy(textToCopy)
message.success(t('msg.info.copiedToClipboard'))
}

27
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -1,7 +1,16 @@
import useVuelidate from '@vuelidate/core'
import { helpers, minLength, required } from '@vuelidate/validators'
import type { Ref } from 'vue'
import type { BoolType, ColumnType, FormType, LinkToAnotherRecordType, StringOrNullType, TableType, ViewType, FormColumnType } from 'nocodb-sdk'
import type {
BoolType,
ColumnType,
FormColumnType,
FormType,
LinkToAnotherRecordType,
StringOrNullType,
TableType,
ViewType,
} from 'nocodb-sdk'
import { ErrorMessages, RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vueuse/core'
import {
@ -92,18 +101,10 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
{} as Record<string, FormColumnType>,
)
let order = 1
columns.value = meta?.value?.columns
?.map((c: Record<string, any>) => ({
...c,
fk_column_id: c.id,
fk_view_id: viewMeta.id,
...(fieldById[c.id] ? fieldById[c.id] : {}),
order: (fieldById[c.id] && fieldById[c.id].order) || order++,
id: fieldById[c.id] && fieldById[c.id].id,
}))
.sort((a: Record<string, any>, b: Record<string, any>) => a.order - b.order) as Record<string, any>[]
columns.value = viewMeta.model?.columns?.map((c) => ({
...c,
description: fieldById[c.id].description,
}))
const _sharedViewMeta = (viewMeta as any).meta
sharedViewMeta.value = isString(_sharedViewMeta) ? JSON.parse(_sharedViewMeta) : _sharedViewMeta

1
packages/nc-gui/context/index.ts

@ -38,3 +38,4 @@ export const ToggleDialogInj: InjectionKey<Function> = Symbol('toggle-dialog-inj
export const CellClickHookInj: InjectionKey<EventHook<MouseEvent> | undefined> = Symbol('cell-click-injection')
export const SaveRowInj: InjectionKey<(() => void) | undefined> = Symbol('save-row-injection')
export const CurrentCellInj: InjectionKey<Ref<Element | undefined>> = Symbol('current-cell-injection')
export const IsUnderLookupInj: InjectionKey<Ref<boolean>> = Symbol('is-under-lookup-injection')

12
packages/nc-gui/lang/fr.json

@ -260,7 +260,7 @@
"barcodeFormat": "Format du code-barres",
"qrCodeValueTooLong": "Trop de caractères pour un code QR",
"barcodeValueTooLong": "Trop de caractères pour un code-barres",
"currentLocation": "Current Location",
"currentLocation": "Emplacement actuel",
"lng": "Lng",
"lat": "Lat",
"aggregateFunction": "Fonction agrégée",
@ -385,12 +385,12 @@
"nextRecord": "Ligne suivante",
"previousRecord": "Ligne précédente",
"copyApiURL": "Copier l'URL de l'API",
"createTable": "Create New Table",
"createTable": "Créer une nouvelle table",
"refreshTable": "Actualiser le tableau",
"renameTable": "Rename Table",
"deleteTable": "Delete Table",
"renameTable": "Renommer la table",
"deleteTable": "Supprimer la table",
"addField": "Ajouter un nouveau champ à ce tableau",
"setDisplay": "Set as Display value",
"setDisplay": "Définir comme valeur d'affichage",
"addRow": "Ajouter une nouvelle ligne",
"saveRow": "Enregistrer la ligne",
"saveAndExit": "Enregistrer et quitter",
@ -580,7 +580,7 @@
"afterEnablePwd": "L’accès est restreint par un mot de passe",
"privateLink": "Cette vue est partagée avec un lien privé",
"privateLinkAdditionalInfo": "Les personnes ayant le lien privé peuvent voir uniquement les cellules visibles de cette vue",
"afterFormSubmitted": "Après que le formulaire ait été soumis",
"afterFormSubmitted": "Après que le formulaire a été soumis",
"apiOptions": "Accéder au projet via",
"submitAnotherForm": "Afficher le bouton \"Soumettre un autre formulaire\"",
"showBlankForm": "Montrer un formulaire vierge après 5 secondes",

8
packages/nc-gui/lang/ru.json

@ -354,7 +354,7 @@
"account": {
"authToken": "Скопировать токен авторизации",
"swagger": "Swagger: REST APIs",
"projInfo": "Скопировать информацию о проекте",
"projInfo": "Информация о проекте",
"themes": "Темы"
},
"sort": "Сортировать",
@ -385,10 +385,10 @@
"nextRecord": "Следующая запись",
"previousRecord": "Предыдущая запись",
"copyApiURL": "Скопируйте URL API",
"createTable": "Create New Table",
"createTable": "Создать новую таблицу",
"refreshTable": "Обновление таблицы",
"renameTable": "Rename Table",
"deleteTable": "Delete Table",
"renameTable": "Переименовать таблицу",
"deleteTable": "Удалить таблицу",
"addField": "Добавить новое поле в эту таблицу",
"setDisplay": "Установить как значение отображения",
"addRow": "Добавить новую строку",

8
packages/nc-gui/lang/zh-Hans.json

@ -68,7 +68,7 @@
"questions": "问题",
"reachOut": "联系我们",
"betaNote": "此功能仍在测试中。",
"moreInfo": "这里可以找到更多信息",
"moreInfo": "点击此处了解更多信息。",
"logs": "日志",
"groupingField": "分组字段",
"insertAfter": "在右侧插入列",
@ -385,10 +385,10 @@
"nextRecord": "下一条记录",
"previousRecord": "上一条纪录",
"copyApiURL": "复制 API 链接",
"createTable": "Create New Table",
"createTable": "创建新的表格",
"refreshTable": "刷新表格",
"renameTable": "Rename Table",
"deleteTable": "Delete Table",
"renameTable": "重命名表格",
"deleteTable": "删除表格",
"addField": "添加新字段",
"setDisplay": "设置为显示值",
"addRow": "添加新行",

2
packages/nc-gui/lib/types.ts

@ -60,6 +60,8 @@ export interface Row {
commentCount?: number
changed?: boolean
saving?: boolean
// use in datetime picker component
isUpdatedFromCopyNPaste?: Record<string, boolean>
}
}

1
packages/nc-gui/nuxt-shim.d.ts vendored

@ -18,7 +18,6 @@ declare module '#app/nuxt' {
job:
| {
id: string
name: string
}
| any,
subscribedCb?: () => void,

14
packages/nc-gui/package-lock.json generated

@ -110,7 +110,7 @@
}
},
"../nocodb-sdk": {
"version": "0.107.0-beta.1",
"version": "0.108.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
@ -15245,9 +15245,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz",
"integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz",
"integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@ -29548,9 +29548,9 @@
}
},
"socket.io-parser": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz",
"integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz",
"integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"

13
packages/nc-gui/pages/index/index/index.vue

@ -90,10 +90,10 @@ const duplicateProject = (project: ProjectType) => {
const { close } = useDialog(resolveComponent('DlgProjectDuplicate'), {
'modelValue': isOpen,
'project': project,
'onOk': async (jobData: { name: string; id: string }) => {
'onOk': async (jobData: { id: string }) => {
await loadProjects()
$jobs.subscribe({ name: jobData.name, id: jobData.id }, undefined, async (status: string) => {
$jobs.subscribe({ id: jobData.id }, undefined, async (status: string) => {
if (status === JobStatus.COMPLETED) {
await loadProjects()
} else if (status === JobStatus.FAILED) {
@ -308,6 +308,7 @@ const copyProjectMeta = async () => {
<div v-if="record.status !== ProjectStatus.JOB" class="flex items-center gap-2">
<component
:is="iconMap.edit"
v-if="isUIAllowed('projectUpdate', true)"
v-e="['c:project:edit:rename']"
class="nc-action-btn"
@click.stop="navigateTo(`/${text}`)"
@ -315,12 +316,18 @@ const copyProjectMeta = async () => {
<component
:is="iconMap.delete"
v-if="isUIAllowed('projectDelete', true)"
class="nc-action-btn"
:data-testid="`delete-project-${record.title}`"
@click.stop="deleteProject(record)"
/>
<a-dropdown :trigger="['click']" overlay-class-name="nc-dropdown-import-menu" @click.stop>
<a-dropdown
v-if="isUIAllowed('duplicateProject', true)"
:trigger="['click']"
overlay-class-name="nc-dropdown-import-menu"
@click.stop
>
<GeneralIcon
icon="threeDotVertical"
class="nc-import-menu outline-0"

2
packages/nc-gui/plugins/a.dayjs.ts

@ -4,6 +4,7 @@ import customParseFormat from 'dayjs/plugin/customParseFormat.js'
import duration from 'dayjs/plugin/duration.js'
import utc from 'dayjs/plugin/utc.js'
import weekday from 'dayjs/plugin/weekday.js'
import timezone from 'dayjs/plugin/timezone.js'
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(() => {
@ -12,4 +13,5 @@ export default defineNuxtPlugin(() => {
extend(customParseFormat)
extend(duration)
extend(weekday)
extend(timezone)
})

17
packages/nc-gui/plugins/jobs.ts

@ -29,22 +29,22 @@ export default defineNuxtPlugin(async (nuxtApp) => {
await init(nuxtApp.$state.token.value)
}
const send = (name: string, data: any) => {
const send = (evt: string, data: any) => {
if (socket) {
const _id = messageIndex++
socket.emit(name, { _id, data })
socket.emit(evt, { _id, data })
return _id
}
}
const jobs = {
subscribe(
job: { id: string; name: string } | any,
job: { id: string } | any,
subscribedCb?: () => void,
statusCb?: (status: JobStatus, data?: any) => void,
logCb?: (data: { message: string }) => void,
) {
const logFn = (data: { id: string; name: string; data: { message: string } }) => {
const logFn = (data: { id: string; data: { message: string } }) => {
if (data.id === job.id) {
if (logCb) logCb(data.data)
}
@ -61,11 +61,10 @@ export default defineNuxtPlugin(async (nuxtApp) => {
const _id = send('subscribe', job)
const subscribeFn = (data: { _id: number; name: string; id: string }) => {
const subscribeFn = (data: { _id: number; id: string }) => {
if (data._id === _id) {
if (data.id !== job.id || data.name !== job.name) {
if (data.id !== job.id) {
job.id = data.id
job.name = data.name
}
if (subscribedCb) subscribedCb()
socket?.on('log', logFn)
@ -75,10 +74,10 @@ export default defineNuxtPlugin(async (nuxtApp) => {
}
socket?.on('subscribed', subscribeFn)
},
getStatus(name: string, id: string): Promise<string> {
getStatus(id: string): Promise<string> {
return new Promise((resolve) => {
if (socket) {
const _id = send('status', { name, id })
const _id = send('status', { id })
const tempFn = (data: any) => {
if (data._id === _id) {
resolve(data.status)

7
packages/nc-gui/store/project.ts

@ -82,6 +82,10 @@ export const useProject = defineStore('projectStore', () => {
return ['mysql', ClientType.MYSQL].includes(getBaseType(baseId))
}
function isSqlite(baseId?: string) {
return getBaseType(baseId) === ClientType.SQLITE
}
function isMssql(baseId?: string) {
return getBaseType(baseId) === 'mssql'
}
@ -91,7 +95,7 @@ export const useProject = defineStore('projectStore', () => {
}
function isXcdbBase(baseId?: string) {
return bases.value.find((base) => base.id === baseId)?.is_meta
return (bases.value.find((base) => base.id === baseId)?.is_meta as boolean) || false
}
const isSharedBase = computed(() => projectType === 'base')
@ -209,6 +213,7 @@ export const useProject = defineStore('projectStore', () => {
isMysql,
isMssql,
isPg,
isSqlite,
sqlUis,
isSharedBase,
loadProjectMetaInfo,

23
packages/nc-gui/utils/cell.ts

@ -1,5 +1,6 @@
import type { ColumnType } from 'nocodb-sdk'
import { UITypes } from 'nocodb-sdk'
import dayjs from 'dayjs'
export const dataTypeLow = (column: ColumnType) => column.dt?.toLowerCase()
export const isBoolean = (column: ColumnType, abstractType?: any) =>
@ -53,3 +54,25 @@ export const isManualSaved = (column: ColumnType) => [UITypes.Currency].includes
export const isPrimary = (column: ColumnType) => !!column.pv
export const isPrimaryKey = (column: ColumnType) => !!column.pk
// used for LTAR and Formula
export const renderValue = (result?: any) => {
if (!result || typeof result !== 'string') {
return result
}
// convert ISO string (e.g. in MSSQL) to YYYY-MM-DD hh:mm:ssZ
// e.g. 2023-05-18T05:30:00.000Z -> 2023-05-18 11:00:00+05:30
result = result.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/g, (d: string) => {
return dayjs(d).isValid() ? dayjs(d).format('YYYY-MM-DD HH:mm:ssZ') : d
})
// convert all date time values to local time
// the datetime is either YYYY-MM-DD hh:mm:ss (xcdb)
// or YYYY-MM-DD hh:mm:ss+/-xx:yy (ext)
return result.replace(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:[+-]\d{2}:\d{2})?/g, (d: string) => {
// TODO(timezone): retrieve the format from the corresponding column meta
// assume HH:mm at this moment
return dayjs(d).isValid() ? dayjs(d).format('YYYY-MM-DD HH:mm') : d
})
}

8
packages/nc-gui/utils/dateTimeUtils.ts

@ -1,7 +1,13 @@
import dayjs from 'dayjs'
export const timeAgo = (date: any) => {
return dayjs.utc(date).fromNow()
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(date)) {
// if there is no timezone info, consider as UTC
// e.g. 2023-01-01 08:00:00 (MySQL)
date += '+00:00'
}
// show in local time
return dayjs(date).fromNow()
}
export const dateFormats = [

1
packages/nc-gui/utils/index.ts

@ -23,3 +23,4 @@ export * from './browserUtils'
export * from './geoDataUtils'
export * from './mimeTypeUtils'
export * from './parseUtils'
export * from './cell'

2
packages/nc-lib-gui/package.json

@ -1,6 +1,6 @@
{
"name": "nc-lib-gui",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"description": "NocoDB GUI",
"author": {
"name": "NocoDB",

10
packages/noco-docs/content/en/engineering/development-setup.md

@ -51,4 +51,14 @@ For Playwright tests, screenshots are captured on the tests. These will provide
![Screenshot 2022-09-29 at 12 43 37 PM](https://user-images.githubusercontent.com/86527202/192965070-dc04b952-70fb-4197-b4bd-ca7eda066e60.png)
## Accessing 'Easter egg' menu
Double click twice on empty space between `View list` & `Share` button to the left top of Grid view; following options become accessible
1. Export Cache
2. Delete Cache
3. Debug Meta
4. Toggle Beta Features
![Screenshot 2023-05-23 at 8 35 14 PM](https://github.com/nocodb/nocodb/assets/86527202/fe2765fa-5796-4d26-8c12-e71b8226872e)

4
packages/nocodb-sdk/package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "nocodb-sdk",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nocodb-sdk",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",

2
packages/nocodb-sdk/package.json

@ -1,6 +1,6 @@
{
"name": "nocodb-sdk",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"description": "NocoDB SDK",
"main": "build/main/index.js",
"typings": "build/main/index.d.ts",

103
packages/nocodb-sdk/src/lib/Api.ts

@ -546,7 +546,39 @@ export interface FilterType {
| 'notchecked'
| 'notempty'
| 'notnull'
| 'null';
| 'null'
| null
| (
| 'allof'
| 'anyof'
| 'blank'
| 'btw'
| 'checked'
| 'empty'
| 'eq'
| 'ge'
| 'gt'
| 'gte'
| 'in'
| 'is'
| 'isWithin'
| 'isnot'
| 'le'
| 'like'
| 'lt'
| 'lte'
| 'nallof'
| 'nanyof'
| 'nbtw'
| 'neq'
| 'nlike'
| 'not'
| 'notblank'
| 'notchecked'
| 'notempty'
| 'notnull'
| ('null' & null)
);
/** Comparison Sub-Operator */
comparison_sub_op?:
| 'daysAgo'
@ -589,7 +621,7 @@ export interface FilterType {
| ('yesterday' & null)
);
/** Foreign Key to Column */
fk_column_id?: IdType;
fk_column_id?: StringOrNullType;
/** Foreign Key to Hook */
fk_hook_id?: StringOrNullType;
/** Foreign Key to Model */
@ -664,7 +696,39 @@ export interface FilterReqType {
| 'notchecked'
| 'notempty'
| 'notnull'
| 'null';
| 'null'
| null
| (
| 'allof'
| 'anyof'
| 'blank'
| 'btw'
| 'checked'
| 'empty'
| 'eq'
| 'ge'
| 'gt'
| 'gte'
| 'in'
| 'is'
| 'isWithin'
| 'isnot'
| 'le'
| 'like'
| 'lt'
| 'lte'
| 'nallof'
| 'nanyof'
| 'nbtw'
| 'neq'
| 'nlike'
| 'not'
| 'notblank'
| 'notchecked'
| 'notempty'
| 'notnull'
| ('null' & null)
);
/** Comparison Sub-Operator */
comparison_sub_op?:
| 'daysAgo'
@ -707,7 +771,7 @@ export interface FilterReqType {
| ('yesterday' & null)
);
/** Foreign Key to Column */
fk_column_id?: IdType;
fk_column_id?: StringOrNullType;
/** Belong to which filter ID */
fk_parent_id?: StringOrNullType;
/** Is this filter grouped? */
@ -1603,7 +1667,7 @@ export interface NormalColumnRequestType {
/** Data Type Extra */
dtx?: StringOrNullType;
/** Data Type Extra Precision */
dtxp?: StringOrNullType | number;
dtxp?: string | number | null;
/** Data Type Extra Scale */
dtxs?: StringOrNullType | number;
/** Numeric Precision */
@ -1804,7 +1868,7 @@ export interface PluginReqType {
/** Is Plugin Active? */
active?: BoolType;
/** Plugin Input */
input?: StringOrNullType;
input?: string | null;
}
/**
@ -2255,6 +2319,8 @@ export interface UserType {
* @example org-level-viewer
*/
roles?: string;
/** Access token version */
token_version?: string;
}
/**
@ -4032,9 +4098,12 @@ export class Api<
baseDuplicate: (
projectId: IdType,
data: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
options?: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
};
project?: object;
},
baseId?: IdType,
params: RequestParams = {}
@ -4078,9 +4147,12 @@ export class Api<
duplicate: (
projectId: IdType,
data: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
options?: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
};
project?: object;
},
params: RequestParams = {}
) =>
@ -5118,8 +5190,11 @@ export class Api<
projectId: IdType,
tableId: IdType,
data: {
excludeData?: boolean;
excludeViews?: boolean;
options?: {
excludeData?: boolean;
excludeViews?: boolean;
excludeHooks?: boolean;
};
},
params: RequestParams = {}
) =>

2
packages/nocodb-sdk/src/lib/formulaHelpers.ts

@ -173,7 +173,7 @@ export function jsepTreeToFormula(node) {
'SWITCH',
'URL',
];
if (!formulas.includes(node.name)) return '{' + node.name + '}';
if (!formulas.includes(node.name.toUpperCase())) return '{' + node.name + '}';
return node.name;
}

10
packages/nocodb/.eslintrc.js

@ -63,12 +63,20 @@ module.exports = {
],
},
],
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-this-alias': 'off',
// todo: enable
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-var-requires': 'off',
'no-useless-catch': 'off',
'no-empty': 'off',

4
packages/nocodb/Dockerfile

@ -31,9 +31,7 @@ COPY ./package*.json ./
COPY ./docker/main.js ./docker/main.js
#COPY ./docker/start.sh /usr/src/appEntry/start.sh
COPY ./docker/start-litestream.sh /usr/src/appEntry/start.sh
COPY ./public/css/*.css ./docker/public/css/
COPY ./public/js/*.js ./docker/public/js/
COPY ./public/favicon.ico ./docker/public/
COPY src/public/ ./docker/public/
# install production dependencies,
# reduce node_module size with modclean & removing sqlite deps,

5
packages/nocodb/Dockerfile.local

@ -14,13 +14,12 @@ COPY ./package*.json ./
COPY ./docker/nc-gui/ ./docker/nc-gui/
COPY ./docker/main.js ./docker/index.js
COPY ./docker/start-local.sh /usr/src/appEntry/start.sh
COPY ./public/css/*.css ./docker/public/css/
COPY ./public/js/*.js ./docker/public/js/
COPY ./public/favicon.ico ./docker/public/
COPY src/public/ ./docker/public/
# install production dependencies,
# reduce node_module size with modclean & removing sqlite deps,
# package built code into app.tar.gz & add execute permission to start.sh
RUN npm uninstall --save nocodb-sdk
RUN npm ci --omit=dev --quiet \
&& npx modclean --patterns="default:*" --ignore="nc-lib-gui/**,dayjs/**,express-status-monitor/**,@azure/msal-node/dist/**" --run \
&& rm -rf ./node_modules/sqlite3/deps \

32
packages/nocodb/package-lock.json generated

@ -1,12 +1,12 @@
{
"name": "nocodb",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nocodb",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@google-cloud/storage": "^5.7.2",
@ -80,7 +80,7 @@
"mysql2": "^3.2.0",
"nanoid": "^3.1.20",
"nc-help": "^0.2.87",
"nc-lib-gui": "0.107.0-beta.1",
"nc-lib-gui": "0.108.0",
"nc-plugin": "^0.1.3",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",
@ -190,7 +190,7 @@
}
},
"../nocodb-sdk": {
"version": "0.107.0-beta.1",
"version": "0.108.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"axios": "^0.21.1",
@ -13157,9 +13157,9 @@
}
},
"node_modules/nc-lib-gui": {
"version": "0.107.0-beta.1",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.107.0-beta.1.tgz",
"integrity": "sha512-xP069IpkCMrOCTKAGOWf5/GhnMVm+wqZflgt7G5CSWF3A46v5pL5SYj1yKK8HUN0v2ZVP2Agjzp44RZBv4QqqA==",
"version": "0.108.0",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.108.0.tgz",
"integrity": "sha512-ZV4g5ivc5T5rGDZD89UqJtazNOv4Bo/mc/8sf3OXLOPB4ksIp8v63mSIeb+jBE9TRdptRKA+Ml67Rczwa+0SKA==",
"dependencies": {
"express": "^4.17.1"
}
@ -15797,9 +15797,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz",
"integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz",
"integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
@ -28442,9 +28442,9 @@
}
},
"nc-lib-gui": {
"version": "0.107.0-beta.1",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.107.0-beta.1.tgz",
"integrity": "sha512-xP069IpkCMrOCTKAGOWf5/GhnMVm+wqZflgt7G5CSWF3A46v5pL5SYj1yKK8HUN0v2ZVP2Agjzp44RZBv4QqqA==",
"version": "0.108.0",
"resolved": "https://registry.npmjs.org/nc-lib-gui/-/nc-lib-gui-0.108.0.tgz",
"integrity": "sha512-ZV4g5ivc5T5rGDZD89UqJtazNOv4Bo/mc/8sf3OXLOPB4ksIp8v63mSIeb+jBE9TRdptRKA+Ml67Rczwa+0SKA==",
"requires": {
"express": "^4.17.1"
}
@ -30444,9 +30444,9 @@
}
},
"socket.io-parser": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz",
"integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.3.tgz",
"integrity": "sha512-JMafRntWVO2DCJimKsRTh/wnqVvO4hrfwOqtO7f+uzwsQMuxO6VwImtYxaQ+ieoyshWOTJyV0fA21lccEXRPpQ==",
"requires": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"

6
packages/nocodb/package.json

@ -1,6 +1,6 @@
{
"name": "nocodb",
"version": "0.107.0-beta.1",
"version": "0.108.0",
"description": "NocoDB Backend",
"main": "dist/bundle.js",
"author": {
@ -32,6 +32,8 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"watch:run": "cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:mysql": "cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunMysql --log-error --project tsconfig.json\"",
"watch:run:pg": "cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG --log-error --project tsconfig.json\"",
"watch:run:playwright": "rm -f ./test_noco.db; cross-env DATABASE_URL=sqlite:./test_noco.db PLAYWRIGHT_TEST=true NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/testDocker --log-error --project tsconfig.json\"",
"watch:run:playwright:quick": "rm -f ./test_noco.db; cp ../../tests/playwright/fixtures/noco_0_91_7.db ./test_noco.db; cross-env DATABASE_URL=sqlite:./test_noco.db NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/docker --log-error --project tsconfig.json\"",
"watch:run:playwright:pg:cyquick": "rm -f ./test_noco.db; cp ../../tests/playwright/fixtures/noco_0_91_7.db ./test_noco.db; cross-env NC_DISABLE_TELE=true EE=true nodemon -e ts,js -w ./src -x \"ts-node src/run/dockerRunPG_CyQuick.ts --log-error --project tsconfig.json\"",
@ -111,7 +113,7 @@
"mysql2": "^3.2.0",
"nanoid": "^3.1.20",
"nc-help": "^0.2.87",
"nc-lib-gui": "0.107.0-beta.1",
"nc-lib-gui": "0.108.0",
"nc-plugin": "^0.1.3",
"ncp": "^2.0.0",
"nocodb-sdk": "file:../nocodb-sdk",

84
packages/nocodb/src/Noco.ts

@ -1,32 +1,36 @@
// import * as Sentry from '@sentry/node';
import path from 'path';
import Sentry, { Handlers } from '@sentry/node';
import { Logger } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import clear from 'clear';
import * as express from 'express';
import NcToolGui from 'nc-lib-gui';
import { IoAdapter } from '@nestjs/platform-socket.io';
import requestIp from 'request-ip';
import cookieParser from 'cookie-parser';
import { T } from 'nc-help';
import { v4 as uuidv4 } from 'uuid';
import dotenv from 'dotenv';
import { AppModule } from './app.module';
import { NC_LICENSE_KEY } from './constants';
import Store from './models/Store';
import { MetaTable } from './utils/globals';
import type { IEventEmitter } from './modules/event-emitter/event-emitter.interface';
import type { Express } from 'express';
import type * as http from 'http';
dotenv.config();
export default class Noco {
private static _this: Noco;
private static ee: boolean;
public static readonly env: string = '_noco';
private static _httpServer: http.Server;
private static _server: Express;
private static logger = new Logger(Noco.name);
public static get dashboardUrl(): string {
let siteUrl = `http://localhost:${process.env.PORT || 8080}`;
// if (Noco._this?.config?.envs?.[Noco._this?.env]?.publicUrl) {
// siteUrl = Noco._this?.config?.envs?.[Noco._this?.env]?.publicUrl;
// }
if (Noco._this?.config?.envs?.['_noco']?.publicUrl) {
siteUrl = Noco._this?.config?.envs?.['_noco']?.publicUrl;
}
const siteUrl = `http://localhost:${process.env.PORT || 8080}`;
return `${siteUrl}${Noco._this?.config?.dashboardPath}`;
}
@ -40,7 +44,6 @@ export default class Noco {
public readonly metaMgrv2: any;
public env: string;
private ncToolApi;
private config: any;
private requestContext: any;
@ -100,8 +103,13 @@ export default class Noco {
const nestApp = await NestFactory.create(AppModule);
this.initSentry(nestApp);
nestApp.useWebSocketAdapter(new IoAdapter(httpServer));
nestApp.use(requestIp.mw());
nestApp.use(cookieParser());
nestApp.use(
express.json({ limit: process.env.NC_REQUEST_BODY_SIZE || '50mb' }),
);
@ -109,7 +117,11 @@ export default class Noco {
const dashboardPath = process.env.NC_DASHBOARD_URL || '/dashboard';
server.use(NcToolGui.expressMiddleware(dashboardPath));
server.use(express.static(path.join(__dirname, 'public')));
server.get('/', (_req, res) => res.redirect(dashboardPath));
this.initSentryErrorHandler(server);
return nestApp.getHttpAdapter().getInstance();
}
@ -120,4 +132,56 @@ export default class Noco {
public static get server(): Express {
return Noco._server;
}
public static async initJwt(): Promise<any> {
if (this.config?.auth?.jwt) {
if (!this.config.auth.jwt.secret) {
let secret = (
await Noco._ncMeta.metaGet('', '', MetaTable.STORE, {
key: 'nc_auth_jwt_secret',
})
)?.value;
if (!secret) {
await Noco._ncMeta.metaInsert('', '', MetaTable.STORE, {
key: 'nc_auth_jwt_secret',
value: (secret = uuidv4()),
});
}
this.config.auth.jwt.secret = secret;
}
this.config.auth.jwt.options = this.config.auth.jwt.options || {};
if (!this.config.auth.jwt.options?.expiresIn) {
this.config.auth.jwt.options.expiresIn =
process.env.NC_JWT_EXPIRES_IN ?? '10h';
}
}
let serverId = (
await Noco._ncMeta.metaGet('', '', MetaTable.STORE, {
key: 'nc_server_id',
})
)?.value;
if (!serverId) {
await Noco._ncMeta.metaInsert('', '', MetaTable.STORE, {
key: 'nc_server_id',
value: (serverId = T.id),
});
}
process.env.NC_SERVER_UUID = serverId;
}
private static initSentryErrorHandler(router) {
if (process.env.NC_SENTRY_DSN) {
router.use(Handlers.errorHandler());
}
}
private static initSentry(router) {
if (process.env.NC_SENTRY_DSN) {
Sentry.init({ dsn: process.env.NC_SENTRY_DSN });
// The request handler must be the first middleware on the app
router.use(Handlers.requestHandler());
}
}
}

60
packages/nocodb/src/app.module.ts

@ -1,35 +1,22 @@
import { Inject, Module, RequestMethod } from '@nestjs/common';
import { Module, RequestMethod } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { BullModule } from '@nestjs/bull';
import { EventEmitterModule as NestJsEventEmitter } from '@nestjs/event-emitter';
import { Connection } from './connection/connection';
import { GlobalExceptionFilter } from './filters/global-exception/global-exception.filter';
import NcPluginMgrv2 from './helpers/NcPluginMgrv2';
import { GlobalMiddleware } from './middlewares/global/global.middleware';
import { GuiMiddleware } from './middlewares/gui/gui.middleware';
import { PublicMiddleware } from './middlewares/public/public.middleware';
import { DatasModule } from './modules/datas/datas.module';
import { IEventEmitter } from './modules/event-emitter/event-emitter.interface';
import { EventEmitterModule } from './modules/event-emitter/event-emitter.module';
import { AuthService } from './services/auth.service';
import { UsersModule } from './modules/users/users.module';
import { MetaService } from './meta/meta.service';
import Noco from './Noco';
import { TestModule } from './modules/test/test.module';
import { GlobalModule } from './modules/global/global.module';
import { HookHandlerService } from './services/hook-handler.service';
import { LocalStrategy } from './strategies/local.strategy';
import { AuthTokenStrategy } from './strategies/authtoken.strategy/authtoken.strategy';
import { BaseViewStrategy } from './strategies/base-view.strategy/base-view.strategy';
import NcConfigFactory from './utils/NcConfigFactory';
import NcUpgrader from './version-upgrader/NcUpgrader';
import { MetasModule } from './modules/metas/metas.module';
import NocoCache from './cache/NocoCache';
import { JobsModule } from './modules/jobs/jobs.module';
import type {
MiddlewareConsumer,
OnApplicationBootstrap,
} from '@nestjs/common';
import type { MiddlewareConsumer } from '@nestjs/common';
@Module({
imports: [
@ -41,13 +28,6 @@ import type {
EventEmitterModule,
JobsModule,
NestJsEventEmitter.forRoot(),
...(process.env['NC_REDIS_URL']
? [
BullModule.forRoot({
redis: process.env.NC_REDIS_URL,
}),
]
: []),
],
controllers: [],
providers: [
@ -62,47 +42,13 @@ import type {
HookHandlerService,
],
})
export class AppModule implements OnApplicationBootstrap {
constructor(
private readonly connection: Connection,
private readonly metaService: MetaService,
@Inject('IEventEmitter') private readonly eventEmitter: IEventEmitter,
) {}
export class AppModule {
// Global Middleware
configure(consumer: MiddlewareConsumer) {
consumer
.apply(GuiMiddleware)
.forRoutes({ path: '*', method: RequestMethod.GET })
.apply(PublicMiddleware)
.forRoutes({ path: '*', method: RequestMethod.GET })
.apply(GlobalMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
// app init
async onApplicationBootstrap(): Promise<void> {
process.env.NC_VERSION = '0105004';
await NocoCache.init();
await this.connection.init();
await NcConfigFactory.metaDbCreateIfNotExist(this.connection.config);
await this.metaService.init();
// todo: remove
// temporary hack
Noco._ncMeta = this.metaService;
Noco.config = this.connection.config;
Noco.eventEmitter = this.eventEmitter;
// init plugin manager
await NcPluginMgrv2.init(Noco.ncMeta);
await Noco.loadEEState();
// run upgrader
await NcUpgrader.upgrade({ ncMeta: Noco._ncMeta });
}
}

14
packages/nocodb/src/cache/RedisCacheMgr.ts vendored

@ -12,8 +12,12 @@ export default class RedisCacheMgr extends CacheMgr {
constructor(config: any) {
super();
this.client = new Redis(config);
// flush the existing db with selected key (Default: 0)
this.client.flushdb();
// avoid flushing db in worker container
if (process.env.NC_WORKER_CONTAINER !== 'true') {
// flush the existing db with selected key (Default: 0)
this.client.flushdb();
}
// TODO(cache): fetch orgs once it's implemented
const orgs = 'noco';
@ -41,7 +45,7 @@ export default class RedisCacheMgr extends CacheMgr {
}
// @ts-ignore
async get(key: string, type: string, config?: any): Promise<any> {
async get(key: string, type: string): Promise<any> {
log(`RedisCacheMgr::get: getting key ${key} with type ${type}`);
if (type === CacheGetType.TYPE_ARRAY) {
return this.client.smembers(key);
@ -131,7 +135,7 @@ export default class RedisCacheMgr extends CacheMgr {
// e.g. arr = ["nc:<orgs>:<scope>:<model_id_1>", "nc:<orgs>:<scope>:<model_id_2>"]
const arr = (await this.get(key, CacheGetType.TYPE_ARRAY)) || [];
log(`RedisCacheMgr::getList: getting list with key ${key}`);
const isNoneList = arr.length && arr[0] === 'NONE';
const isNoneList = arr.length && arr.includes('NONE');
if (isNoneList) {
return Promise.resolve({
@ -244,7 +248,7 @@ export default class RedisCacheMgr extends CacheMgr {
: `${this.prefix}:${scope}:${subListKeys.join(':')}:list`;
log(`RedisCacheMgr::appendToList: append key ${key} to ${listKey}`);
let list = (await this.get(listKey, CacheGetType.TYPE_ARRAY)) || [];
if (list.length && list[0] === 'NONE') {
if (list.length && list.includes('NONE')) {
list = [];
await this.del(listKey);
}

2
packages/nocodb/src/cache/RedisMockCacheMgr.ts vendored

@ -40,7 +40,7 @@ export default class RedisMockCacheMgr extends CacheMgr {
}
// @ts-ignore
async get(key: string, type: string, config?: any): Promise<any> {
async get(key: string, type: string): Promise<any> {
log(`RedisMockCacheMgr::get: getting key ${key} with type ${type}`);
if (type === CacheGetType.TYPE_ARRAY) {
return this.client.smembers(key);

19
packages/nocodb/src/connection/connection.spec.ts

@ -1,19 +0,0 @@
import { Test } from '@nestjs/testing';
import { Connection } from './knex';
import type { TestingModule } from '@nestjs/testing';
describe('Knex', () => {
let provider: Connection;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [Connection],
}).compile();
provider = module.get<Connection>(Connection);
});
it('should be defined', () => {
expect(provider).toBeDefined();
});
});

37
packages/nocodb/src/connection/connection.ts

@ -1,37 +0,0 @@
import { Global, Injectable, Scope } from '@nestjs/common';
import { XKnex } from '../db/CustomKnex';
import NcConfigFactory from '../utils/NcConfigFactory';
import type * as knex from 'knex';
@Injectable({
scope: Scope.DEFAULT,
})
export class Connection {
public static knex: knex.Knex;
public static _config: any;
get knexInstance(): knex.Knex {
return Connection.knex;
}
get config(): knex.Knex {
return Connection._config;
}
// init metadb connection
static async init(): Promise<void> {
Connection._config = await NcConfigFactory.make();
if (!Connection.knex) {
Connection.knex = XKnex({
...this._config.meta.db,
useNullAsDefault: true,
});
}
}
// init metadb connection
async init(): Promise<void> {
return await Connection.init();
}
}

1
packages/nocodb/src/controllers/api-tokens.controller.ts

@ -9,7 +9,6 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import {

1
packages/nocodb/src/controllers/attachments.controller.ts

@ -30,7 +30,6 @@ export class AttachmentsController {
@UploadedFiles() files: Array<any>,
@Body() body: any,
@Request() req: any,
@Query('path') path: string,
) {
const attachments = await this.attachmentsService.upload({
files: files,

1
packages/nocodb/src/controllers/audits.controller.ts

@ -10,7 +10,6 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import {

1
packages/nocodb/src/controllers/bases.controller.ts

@ -10,7 +10,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { BaseReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import {

1
packages/nocodb/src/controllers/bulk-data-alias.controller.ts

@ -10,7 +10,6 @@ import {
Response,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/columns.controller.ts

@ -11,7 +11,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { ColumnReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/data-alias-export.controller.ts

@ -1,6 +1,5 @@
import { Controller, Get, Request, Response, UseGuards } from '@nestjs/common';
import * as XLSX from 'xlsx';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

3
packages/nocodb/src/controllers/data-alias-nested.controller.ts

@ -8,7 +8,6 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,
@ -131,7 +130,6 @@ export class DataAliasNestedController {
@Param('projectName') projectName: string,
@Param('tableName') tableName: string,
@Param('refRowId') refRowId: string,
@Param('relationType') relationType: string,
) {
await this.dataAliasNestedService.relationDataRemove({
columnName: columnName,
@ -158,7 +156,6 @@ export class DataAliasNestedController {
@Param('projectName') projectName: string,
@Param('tableName') tableName: string,
@Param('refRowId') refRowId: string,
@Param('relationType') relationType: string,
) {
await this.dataAliasNestedService.relationDataAdd({
columnName: columnName,

1
packages/nocodb/src/controllers/data-alias.controller.ts

@ -11,7 +11,6 @@ import {
Response,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import { parseHrtimeToSeconds } from '../helpers';
import {

4
packages/nocodb/src/controllers/filters.controller.ts

@ -9,14 +9,12 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FilterReqType } from 'nocodb-sdk';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import {
Acl,
ExtractProjectIdMiddleware,
UseAclMiddleware,
} from '../middlewares/extract-project-id/extract-project-id.middleware';
import { FiltersService } from '../services/filters.service';
@ -71,7 +69,7 @@ export class FiltersController {
@Get('/api/v1/db/meta/filters/:filterParentId/children')
@Acl('filterChildrenList')
async filterChildrenRead(filterParentId: string) {
async filterChildrenRead(@Param('filterParentId') filterParentId: string) {
return new PagedResponseImpl(
await this.filtersService.filterChildrenList({
filterId: filterParentId,

1
packages/nocodb/src/controllers/form-columns.controller.ts

@ -1,5 +1,4 @@
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/galleries.controller.ts

@ -9,7 +9,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { GalleryUpdateReqType, ViewCreateReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/grid-columns.controller.ts

@ -1,6 +1,5 @@
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
import { GridColumnReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/grids.controller.ts

@ -8,7 +8,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { ViewCreateReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/maps.controller.ts

@ -9,7 +9,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { MapUpdateReqType, ViewCreateReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/meta-diffs.controller.ts

@ -6,7 +6,6 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/model-visibilities.controller.ts

@ -8,7 +8,6 @@ import {
Query,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/old-datas/old-datas.controller.ts

@ -11,7 +11,6 @@ import {
Response,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/org-users.controller.ts

@ -10,7 +10,6 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { OrgUserRoles } from 'nocodb-sdk';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';

10
packages/nocodb/src/controllers/plugins.controller.ts

@ -14,11 +14,11 @@ import { Acl } from '../middlewares/extract-project-id/extract-project-id.middle
import { PluginsService } from '../services/plugins.service';
// todo: move to a interceptor
const blockInCloudMw = (_req, res, next) => {
if (process.env.NC_CLOUD === 'true') {
res.status(403).send('Not allowed');
} else next();
};
// const blockInCloudMw = (_req, res, next) => {
// if (process.env.NC_CLOUD === 'true') {
// res.status(403).send('Not allowed');
// } else next();
// };
@Controller()
@UseGuards(GlobalGuard)

1
packages/nocodb/src/controllers/project-users.controller.ts

@ -11,7 +11,6 @@ import {
UseGuards,
} from '@nestjs/common';
import { ProjectUserReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

77
packages/nocodb/src/controllers/projects.controller.ts

@ -11,15 +11,13 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import isDocker from 'is-docker';
import { ProjectReqType } from 'nocodb-sdk';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import {
Acl,
ExtractProjectIdMiddleware,
UseAclMiddleware,
UseProjectIdMiddleware,
} from '../middlewares/extract-project-id/extract-project-id.middleware';
import Noco from '../Noco';
import { packageVersion } from '../utils/packageVersion';
@ -31,9 +29,7 @@ import type { ProjectType } from 'nocodb-sdk';
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@UseAclMiddleware({
permissionName: 'projectList',
})
@Acl('projectList')
@Get('/api/v1/db/meta/projects/')
async list(@Query() queryParams: Record<string, any>, @Request() req) {
const projects = await this.projectsService.projectList({
@ -57,7 +53,7 @@ export class ProjectsController {
PackageVersion: packageVersion,
};
}
@Acl('projectGet')
@Get('/api/v1/db/meta/projects/:projectId')
async projectGet(@Param('projectId') projectId: string) {
const project = await this.projectsService.getProjectWithInfo({
@ -68,7 +64,7 @@ export class ProjectsController {
return project;
}
@Acl('projectUpdate')
@Patch('/api/v1/db/meta/projects/:projectId')
async projectUpdate(
@Param('projectId') projectId: string,
@ -82,6 +78,7 @@ export class ProjectsController {
return project;
}
@Acl('projectDelete')
@Delete('/api/v1/db/meta/projects/:projectId')
async projectDelete(@Param('projectId') projectId: string) {
const deleted = await this.projectsService.projectSoftDelete({
@ -91,6 +88,7 @@ export class ProjectsController {
return deleted;
}
@Acl('projectCreate')
@Post('/api/v1/db/meta/projects')
@HttpCode(200)
async projectCreate(@Body() projectBody: ProjectReqType, @Request() req) {
@ -102,66 +100,3 @@ export class ProjectsController {
return project;
}
}
/*
// // Project CRUD
export async function projectCost(req, res) {
let cost = 0;
const project = await Project.getWithInfo(req.params.projectId);
for (const base of project.bases) {
const sqlClient = await NcConnectionMgrv2.getSqlClient(base);
const userCount = await ProjectUser.getUsersCount(req.query);
const recordCount = (await sqlClient.totalRecords())?.data.TotalRecords;
if (recordCount > 100000) {
// 36,000 or $79/user/month
cost = Math.max(36000, 948 * userCount);
} else if (recordCount > 50000) {
// $36,000 or $50/user/month
cost = Math.max(36000, 600 * userCount);
} else if (recordCount > 10000) {
// $240/user/yr
cost = Math.min(240 * userCount, 36000);
} else if (recordCount > 1000) {
// $120/user/yr
cost = Math.min(120 * userCount, 36000);
}
}
T.event({
event: 'a:project:cost',
data: {
cost,
},
});
res.json({ cost });
}
export async function hasEmptyOrNullFilters(req, res) {
res.json(await Filter.hasEmptyOrNullFilters(req.params.projectId));
}
export default (router) => {
router.get(
'/api/v1/db/meta/projects/:projectId/cost',
metaApiMetrics,
ncMetaAclMw(projectCost, 'projectCost')
);
router.get(
'/api/v1/db/meta/projects/:projectId/has-empty-or-null-filters',
metaApiMetrics,
ncMetaAclMw(hasEmptyOrNullFilters, 'hasEmptyOrNullFilters')
);
};
* */

1
packages/nocodb/src/controllers/shared-bases.controller.ts

@ -10,7 +10,6 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GlobalGuard } from '../guards/global/global.guard';
import {
Acl,

1
packages/nocodb/src/controllers/sorts.controller.ts

@ -9,7 +9,6 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { SortReqType } from 'nocodb-sdk';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';

1
packages/nocodb/src/controllers/tables.controller.ts

@ -11,7 +11,6 @@ import {
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TableReqType } from 'nocodb-sdk';
import { GlobalGuard } from '../guards/global/global.guard';
import extractRolesObj from '../utils/extractRolesObj';

117
packages/nocodb/src/controllers/users/users.controller.ts

@ -1,5 +1,3 @@
import { promisify } from 'util';
import { AuditOperationSubTypes, AuditOperationTypes } from 'nocodb-sdk';
import {
Body,
Controller,
@ -19,23 +17,17 @@ import {
Acl,
ExtractProjectIdMiddleware,
} from '../../middlewares/extract-project-id/extract-project-id.middleware';
import Noco from '../../Noco';
import { GoogleStrategy } from '../../strategies/google.strategy/google.strategy';
import extractRolesObj from '../../utils/extractRolesObj';
import { Audit, User } from '../../models';
import { User } from '../../models';
import {
genJwt,
randomTokenString,
setTokenCookie,
} from '../../services/users/helpers';
import { UsersService } from '../../services/users/users.service';
import extractRolesObj from '../../utils/extractRolesObj';
@Controller()
export class UsersController {
constructor(
private readonly usersService: UsersService,
private googleStrategy: GoogleStrategy,
) {}
constructor(private readonly usersService: UsersService) {}
@Post([
'/auth/user/signup',
@ -59,56 +51,14 @@ export class UsersController {
'/api/v1/auth/token/refresh',
])
@HttpCode(200)
async refreshToken(@Request() req: any, @Request() res: any): Promise<any> {
return await this.usersService.refreshToken({
body: req.body,
req,
res,
});
}
async successfulSignIn({ user, err, info, req, res, auditDescription }) {
try {
if (!user || !user.email) {
if (err) {
return res.status(400).send(err);
}
if (info) {
return res.status(400).send(info);
}
return res.status(400).send({ msg: 'Your signin has failed' });
}
await promisify((req as any).login.bind(req))(user);
const refreshToken = randomTokenString();
if (!user.token_version) {
user.token_version = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user.token_version,
});
setTokenCookie(res, refreshToken);
await Audit.insert({
op_type: AuditOperationTypes.AUTHENTICATION,
op_sub_type: AuditOperationSubTypes.SIGNIN,
user: user.email,
ip: req.clientIp,
description: auditDescription,
});
res.json({
token: genJwt(user, Noco.getConfig()),
} as any);
} catch (e) {
console.log(e);
throw e;
}
async refreshToken(@Request() req: any, @Response() res: any): Promise<any> {
res.json(
await this.usersService.refreshToken({
body: req.body,
req,
res,
}),
);
}
@Post([
@ -118,8 +68,9 @@ export class UsersController {
])
@UseGuards(AuthGuard('local'))
@HttpCode(200)
async signin(@Request() req) {
return this.usersService.login(req.user);
async signin(@Request() req, @Response() res) {
await this.setRefreshToken({ req, res });
res.json(this.usersService.login(req.user));
}
@Post('/api/v1/auth/user/signout')
@ -136,18 +87,15 @@ export class UsersController {
@Post(`/auth/google/genTokenByCode`)
@HttpCode(200)
@UseGuards(AuthGuard('google'))
async googleSignin(@Request() req) {
return this.usersService.login(req.user);
async googleSignin(@Request() req, @Response() res) {
await this.setRefreshToken({ req, res });
res.json(this.usersService.login(req.user));
}
@Get('/auth/google')
@UseGuards(AuthGuard('google'))
googleAuthenticate(@Request() req) {
// this.googleStrategy.authenticate(req, {
// scope: ['profile', 'email'],
// state: req.query.state,
// callbackURL: req.ncSiteUrl + Noco.getConfig().dashboardPath,
// });
googleAuthenticate() {
// google strategy will take care the request
}
@Get(['/auth/user/me', '/api/v1/db/auth/user/me', '/api/v1/auth/user/me'])
@ -168,7 +116,7 @@ export class UsersController {
@UseGuards(GlobalGuard)
@Acl('passwordChange')
@HttpCode(200)
async passwordChange(@Request() req: any, @Body() body: any): Promise<any> {
async passwordChange(@Request() req: any): Promise<any> {
if (!(req as any).isAuthenticated()) {
NcError.forbidden('Not allowed');
}
@ -188,7 +136,7 @@ export class UsersController {
'/api/v1/auth/password/forgot',
])
@HttpCode(200)
async passwordForgot(@Request() req: any, @Body() body: any): Promise<any> {
async passwordForgot(@Request() req: any): Promise<any> {
await this.usersService.passwordForgot({
siteUrl: (req as any).ncSiteUrl,
body: req.body,
@ -269,4 +217,27 @@ export class UsersController {
return res.status(400).json({ msg: e.message });
}
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user.token_version) {
user.token_version = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user.token_version,
});
setTokenCookie(res, refreshToken);
}
}

3
packages/nocodb/src/controllers/view-columns.controller.ts

@ -8,8 +8,7 @@ import {
Post,
UseGuards,
} from '@nestjs/common';
import { ColumnReqType, ViewColumnReqType } from 'nocodb-sdk';
import { AuthGuard } from '@nestjs/passport';
import { ViewColumnReqType } from 'nocodb-sdk';
import { GlobalGuard } from '../guards/global/global.guard';
import { PagedResponseImpl } from '../helpers/PagedResponse';
import {

6
packages/nocodb/src/db/BaseModelSql.ts

@ -1613,7 +1613,7 @@ class BaseModelSql extends BaseModel {
}
}
async nestedRead(id, { where, fields: fields1, f, ...rest }, trx = null) {
async nestedRead(id, { fields: fields1, f, ...rest }, trx = null) {
rest = Object.assign({}, this.defaultNestedQueryParams, rest);
const { hm: childs = '', bt: parents = '', mm: many = '' } = rest;
@ -1852,7 +1852,7 @@ class BaseModelSql extends BaseModel {
return null;
}
// @ts-ignore
const { tn, cn, vtn, vcn, vrcn, rtn, rcn } =
const { vtn, vcn, vrcn, rtn, rcn } =
this.manyToManyRelations.find(({ vtn }) => assoc === vtn) || {};
const childModel = this.dbModels[rtn];
@ -1894,7 +1894,7 @@ class BaseModelSql extends BaseModel {
return null;
}
// @ts-ignore
const { tn, cn, vtn, vcn, vrcn, rtn, rcn } =
const { vtn, vcn, vrcn, rtn, rcn } =
this.manyToManyRelations.find(({ vtn }) => assoc === vtn) || {};
const childModel = this.dbModels[rtn];

320
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -1,6 +1,9 @@
import autoBind from 'auto-bind';
import groupBy from 'lodash/groupBy';
import DataLoader from 'dataloader';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import timezone from 'dayjs/plugin/timezone';
import { nocoExecute } from 'nc-help';
import {
AuditOperationSubTypes,
@ -16,7 +19,6 @@ import DOMPurify from 'isomorphic-dompurify';
import { v4 as uuidv4 } from 'uuid';
import { NcError } from '../helpers/catchError';
import getAst from '../helpers/getAst';
import { Audit, Column, Filter, Model, Project, Sort, View } from '../models';
import { sanitize, unsanitize } from '../helpers/sqlSanitize';
import {
@ -48,6 +50,9 @@ import type {
import type { Knex } from 'knex';
import type { SortType } from 'nocodb-sdk';
dayjs.extend(utc);
dayjs.extend(timezone);
const GROUP_COL = '__nc_group_id';
const nanoidv2 = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 14);
@ -130,10 +135,24 @@ class BaseModelSqlv2 {
autoBind(this);
}
public async readByPk(id?: any, validateFormula = false): Promise<any> {
public async readByPk(
id?: any,
validateFormula = false,
query: any = {},
): Promise<any> {
const qb = this.dbDriver(this.tnPath);
await this.selectObject({ qb, validateFormula });
const { ast, dependencyFields } = await getAst({
query,
model: this.model,
view: this.viewId && (await View.get(this.viewId)),
});
await this.selectObject({
...(dependencyFields ?? {}),
qb,
validateFormula,
});
qb.where(_wherePk(this.model.primaryKeys, id));
@ -153,14 +172,7 @@ class BaseModelSqlv2 {
data.__proto__ = proto;
}
// retrieve virtual column data as well
const project = await Project.get(this.model.project_id);
const { model, view } = await getViewAndModelByAliasOrId({
projectName: project.title,
tableName: this.model.title,
});
const { ast } = await getAst({ model, view });
return data ? await nocoExecute(ast, data, {}) : {};
return data ? await nocoExecute(ast, data, {}, query) : {};
}
public async exist(id?: any): Promise<any> {
@ -190,7 +202,7 @@ class BaseModelSqlv2 {
): Promise<any> {
const { where, ...rest } = this._getListArgs(args as any);
const qb = this.dbDriver(this.tnPath);
await this.selectObject({ qb, validateFormula });
await this.selectObject({ ...args, qb, validateFormula });
const aliasColObjMap = await this.model.getAliasColObjMap();
const sorts = extractSortsObject(rest?.sort, aliasColObjMap);
@ -1340,12 +1352,6 @@ class BaseModelSqlv2 {
const columns = await this.model.getColumns();
for (const column of columns) {
switch (column.uidt) {
case UITypes.Rollup:
{
// @ts-ignore
const colOptions: RollupColumn = await column.getColOptions();
}
break;
case UITypes.Lookup:
{
// @ts-ignore
@ -1600,6 +1606,67 @@ class BaseModelSqlv2 {
if (!checkColumnRequired(column, fields, extractPkAndPv)) continue;
switch (column.uidt) {
case UITypes.DateTime:
if (this.isMySQL) {
// MySQL stores timestamp in UTC but display in timezone
// To verify the timezone, run `SELECT @@global.time_zone, @@session.time_zone;`
// If it's SYSTEM, then the timezone is read from the configuration file
// if a timezone is set in a DB, the retrieved value would be converted to the corresponding timezone
// for example, let's say the global timezone is +08:00 in DB
// the value 2023-01-01 10:00:00 (UTC) would display as 2023-01-01 18:00:00 (UTC+8)
// our existing logic is based on UTC, during the query, we need to take the UTC value
// hence, we use CONVERT_TZ to convert back to UTC value
res[sanitize(column.title || column.column_name)] =
this.dbDriver.raw(
`CONVERT_TZ(??, @@GLOBAL.time_zone, '+00:00')`,
[
`${sanitize(alias || this.model.table_name)}.${
column.column_name
}`,
],
);
break;
} else if (this.isPg) {
// if there is no timezone info,
// convert to database timezone,
// then convert to UTC
if (
column.dt !== 'timestamp with time zone' &&
column.dt !== 'timestamptz'
) {
res[sanitize(column.title || column.column_name)] = this.dbDriver
.raw(
`?? AT TIME ZONE CURRENT_SETTING('timezone') AT TIME ZONE 'UTC'`,
[
`${sanitize(alias || this.model.table_name)}.${
column.column_name
}`,
],
)
.wrap('(', ')');
break;
}
} else if (this.isMssql) {
// if there is no timezone info,
// convert to database timezone,
// then convert to UTC
if (column.dt !== 'datetimeoffset') {
res[sanitize(column.title || column.column_name)] =
this.dbDriver.raw(
`CONVERT(DATETIMEOFFSET, ?? AT TIME ZONE 'UTC')`,
[
`${sanitize(alias || this.model.table_name)}.${
column.column_name
}`,
],
);
break;
}
}
res[sanitize(column.title || column.column_name)] = sanitize(
`${alias || this.model.table_name}.${column.column_name}`,
);
break;
case 'LinkToAnotherRecord':
case 'Lookup':
break;
@ -1727,7 +1794,11 @@ class BaseModelSqlv2 {
await populatePk(this.model, data);
// todo: filter based on view
const insertObj = await this.model.mapAliasToColumn(data);
const insertObj = await this.model.mapAliasToColumn(
data,
this.clientMeta,
this.dbDriver,
);
await this.validate(insertObj);
@ -1865,7 +1936,11 @@ class BaseModelSqlv2 {
async updateByPk(id, data, trx?, cookie?) {
try {
const updateObj = await this.model.mapAliasToColumn(data);
const updateObj = await this.model.mapAliasToColumn(
data,
this.clientMeta,
this.dbDriver,
);
await this.validate(data);
@ -1913,6 +1988,16 @@ class BaseModelSqlv2 {
return this.getTnPath(this.model);
}
public get clientMeta() {
return {
isSqlite: this.isSqlite,
isMssql: this.isMssql,
isPg: this.isPg,
isMySQL: this.isMySQL,
// isSnowflake: this.isSnowflake,
};
}
get isSqlite() {
return this.clientType === 'sqlite3';
}
@ -1941,7 +2026,11 @@ class BaseModelSqlv2 {
// const driver = trx ? trx : await this.dbDriver.transaction();
try {
await populatePk(this.model, data);
const insertObj = await this.model.mapAliasToColumn(data);
const insertObj = await this.model.mapAliasToColumn(
data,
this.clientMeta,
this.dbDriver,
);
let rowId = null;
const postInsertOps = [];
@ -2091,6 +2180,7 @@ class BaseModelSqlv2 {
raw?: boolean;
} = {},
) {
let trx;
try {
// TODO: ag column handling for raw bulk insert
const insertDatas = raw
@ -2098,7 +2188,11 @@ class BaseModelSqlv2 {
: await Promise.all(
datas.map(async (d) => {
await populatePk(this.model, d);
return this.model.mapAliasToColumn(d);
return this.model.mapAliasToColumn(
d,
this.clientMeta,
this.dbDriver,
);
}),
);
@ -2115,7 +2209,7 @@ class BaseModelSqlv2 {
// refer : https://www.sqlite.org/limits.html
const chunkSize = this.isSqlite ? 10 : _chunkSize;
const trx = await this.dbDriver.transaction();
trx = await this.dbDriver.transaction();
if (!foreign_key_checks) {
if (this.isPg) {
@ -2146,6 +2240,7 @@ class BaseModelSqlv2 {
return response;
} catch (e) {
await trx?.rollback();
// await this.errorInsertb(e, data, null);
throw e;
}
@ -2161,7 +2256,11 @@ class BaseModelSqlv2 {
const updateDatas = raw
? datas
: await Promise.all(datas.map((d) => this.model.mapAliasToColumn(d)));
: await Promise.all(
datas.map((d) =>
this.model.mapAliasToColumn(d, this.clientMeta, this.dbDriver),
),
);
const prevData = [];
const newData = [];
@ -2213,7 +2312,11 @@ class BaseModelSqlv2 {
) {
try {
let count = 0;
const updateData = await this.model.mapAliasToColumn(data);
const updateData = await this.model.mapAliasToColumn(
data,
this.clientMeta,
this.dbDriver,
);
await this.validate(updateData);
const pkValues = await this._extractPksValues(updateData);
if (pkValues) {
@ -2259,7 +2362,9 @@ class BaseModelSqlv2 {
let transaction;
try {
const deleteIds = await Promise.all(
ids.map((d) => this.model.mapAliasToColumn(d)),
ids.map((d) =>
this.model.mapAliasToColumn(d, this.clientMeta, this.dbDriver),
),
);
const deleted = [];
@ -3145,16 +3250,23 @@ class BaseModelSqlv2 {
} else {
query = sanitize(query);
}
return this.convertAttachmentType(
let data =
this.isPg || this.isSnowflake
? (await this.dbDriver.raw(query))?.rows
: query.slice(0, 6) === 'select' && !this.isMssql
? await this.dbDriver.from(
this.dbDriver.raw(query).wrap('(', ') __nc_alias'),
)
: await this.dbDriver.raw(query),
childTable,
);
: await this.dbDriver.raw(query);
// update attachment fields
data = this.convertAttachmentType(data, childTable);
// update date time fields
data = this.convertDateFormat(data, childTable);
return data;
}
private _convertAttachmentType(
@ -3186,7 +3298,153 @@ class BaseModelSqlv2 {
this._convertAttachmentType(attachmentColumns, d),
);
} else {
this._convertAttachmentType(attachmentColumns, data);
data = this._convertAttachmentType(attachmentColumns, data);
}
}
}
return data;
}
// TODO(timezone): retrieve the format from the corresponding column meta
private _convertDateFormat(
dateTimeColumns: Record<string, any>[],
d: Record<string, any>,
) {
if (!d) return d;
for (const col of dateTimeColumns) {
if (!d[col.title]) continue;
if (col.uidt === UITypes.Formula) {
if (!d[col.title] || typeof d[col.title] !== 'string') {
continue;
}
// remove milliseconds
if (this.isMySQL) {
d[col.title] = d[col.title].replace(/\.000000/g, '');
} else if (this.isMssql) {
d[col.title] = d[col.title].replace(/\.0000000 \+00:00/g, '');
}
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/g.test(d[col.title])) {
// convert ISO string (e.g. in MSSQL) to YYYY-MM-DD hh:mm:ssZ
// e.g. 2023-05-18T05:30:00.000Z -> 2023-05-18 11:00:00+05:30
d[col.title] = d[col.title].replace(
/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/g,
(d: string) => {
if (!dayjs(d).isValid()) return d;
if (this.isSqlite) {
// e.g. DATEADD formula
return dayjs(d).utc().format('YYYY-MM-DD HH:mm:ssZ');
}
return dayjs(d).utc(true).format('YYYY-MM-DD HH:mm:ssZ');
},
);
continue;
}
// convert all date time values to utc
// the datetime is either YYYY-MM-DD hh:mm:ss (xcdb)
// or YYYY-MM-DD hh:mm:ss+/-xx:yy (ext)
d[col.title] = d[col.title].replace(
/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:[+-]\d{2}:\d{2})?/g,
(d: string) => {
if (!dayjs(d).isValid()) {
return d;
}
if (this.isSqlite) {
// if there is no timezone info,
// we assume the input is on NocoDB server timezone
// then we convert to UTC from server timezone
// example: datetime without timezone
// we need to display 2023-04-27 10:00:00 (in HKT)
// we convert d (e.g. 2023-04-27 18:00:00) to utc, i.e. 2023-04-27 02:00:00+00:00
// if there is timezone info,
// we simply convert it to UTC
// example: datetime with timezone
// e.g. 2023-04-27 10:00:00+05:30 -> 2023-04-27 04:30:00+00:00
return dayjs(d)
.tz(Intl.DateTimeFormat().resolvedOptions().timeZone)
.utc()
.format('YYYY-MM-DD HH:mm:ssZ');
}
// set keepLocalTime to true if timezone info is not found
const keepLocalTime = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/g.test(
d,
);
return dayjs(d).utc(keepLocalTime).format('YYYY-MM-DD HH:mm:ssZ');
},
);
continue;
}
let keepLocalTime = true;
if (this.isSqlite) {
if (!col.cdf) {
if (
d[col.title].indexOf('-') === -1 &&
d[col.title].indexOf('+') === -1 &&
d[col.title].slice(-1) !== 'Z'
) {
// if there is no timezone info,
// we assume the input is on NocoDB server timezone
// then we convert to UTC from server timezone
// e.g. 2023-04-27 10:00:00 (IST) -> 2023-04-27 04:30:00+00:00
d[col.title] = dayjs(d[col.title])
.tz(Intl.DateTimeFormat().resolvedOptions().timeZone)
.utc()
.format('YYYY-MM-DD HH:mm:ssZ');
continue;
} else {
// otherwise, we convert from the given timezone to UTC
keepLocalTime = false;
}
}
}
if (
this.isPg &&
(col.dt === 'timestamp with time zone' || col.dt === 'timestamptz')
) {
// postgres - timezone already attached to input
// e.g. 2023-05-11 16:16:51+08:00
keepLocalTime = false;
}
if (d[col.title] instanceof Date) {
// e.g. MSSQL
// Wed May 10 2023 17:47:46 GMT+0800 (Hong Kong Standard Time)
keepLocalTime = false;
}
// e.g. 01.01.2022 10:00:00+05:30 -> 2022-01-01 04:30:00+00:00
// e.g. 2023-05-09 11:41:49 -> 2023-05-09 11:41:49+00:00
d[col.title] = dayjs(d[col.title])
// keep the local time
.utc(keepLocalTime)
// show the timezone even for Mysql
.format('YYYY-MM-DD HH:mm:ssZ');
}
return d;
}
private convertDateFormat(data: Record<string, any>, childTable?: Model) {
// Show the date time in UTC format in API response
// e.g. 2022-01-01 04:30:00+00:00
if (data) {
const dateTimeColumns = (
childTable ? childTable.columns : this.model.columns
).filter(
(c) => c.uidt === UITypes.DateTime || c.uidt === UITypes.Formula,
);
if (dateTimeColumns.length) {
if (Array.isArray(data)) {
data = data.map((d) => this._convertDateFormat(dateTimeColumns, d));
} else {
data = this._convertDateFormat(dateTimeColumns, data);
}
}
}

12
packages/nocodb/src/db/CustomKnex.ts

@ -1,12 +1,24 @@
import { Knex, knex } from 'knex';
import { SnowflakeClient } from 'nc-help';
import { types } from 'pg';
import dayjs from 'dayjs';
import Filter from '../models/Filter';
import type { FilterType } from 'nocodb-sdk';
import type { BaseModelSql } from './BaseModelSql';
// For the code, check out
// https://raw.githubusercontent.com/brianc/node-pg-types/master/lib/builtins.js
// override parsing date column to Date()
types.setTypeParser(1082, (val) => val);
// override timestamp
types.setTypeParser(1114, (val) => {
return dayjs(val).format('YYYY-MM-DD HH:mm:ss');
});
// override timestampz
types.setTypeParser(1184, (val) => {
return dayjs(val).format('YYYY-MM-DD HH:mm:ssZ');
});
const opMappingGen = {
eq: '=',

2
packages/nocodb/src/db/conditionV2.ts

@ -1,5 +1,5 @@
import { isNumericCol, RelationTypes, UITypes } from 'nocodb-sdk';
import dayjs, { extend } from 'dayjs';
import dayjs from 'dayjs';
// import customParseFormat from 'dayjs/plugin/customParseFormat.js';
import Filter from '../models/Filter';
import { sanitize } from '../helpers/sqlSanitize';

54
packages/nocodb/src/db/formulav2/formulaQueryBuilderv2.ts

@ -537,6 +537,51 @@ async function _formulaQueryBuilder(
};
};
break;
case UITypes.DateTime:
if (knex.clientType().startsWith('mysql')) {
aliasToColumn[col.id] = async (): Promise<any> => {
return {
// convert from DB timezone to UTC
builder: knex.raw(
`CONVERT_TZ(??, @@GLOBAL.time_zone, '+00:00')`,
[col.column_name],
),
};
};
} else if (
knex.clientType() === 'pg' &&
col.dt !== 'timestamp with time zone' &&
col.dt !== 'timestamptz'
) {
aliasToColumn[col.id] = async (): Promise<any> => {
return {
// convert from DB timezone to UTC
builder: knex
.raw(
`?? AT TIME ZONE CURRENT_SETTING('timezone') AT TIME ZONE 'UTC'`,
[col.column_name],
)
.wrap('(', ')'),
};
};
} else if (
knex.clientType() === 'mssql' &&
col.dt !== 'datetimeoffset'
) {
// convert from DB timezone to UTC
aliasToColumn[col.id] = async (): Promise<any> => {
return {
builder: knex.raw(
`CONVERT(DATETIMEOFFSET, ?? AT TIME ZONE 'UTC')`,
[col.column_name],
),
};
};
} else {
aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });
}
break;
default:
aliasToColumn[col.id] = () =>
Promise.resolve({ builder: col.column_name });
@ -548,11 +593,11 @@ async function _formulaQueryBuilder(
const colAlias = a ? ` as ${a}` : '';
pt.arguments?.forEach?.((arg) => {
if (arg.fnName) return;
arg.fnName = pt.callee.name;
arg.fnName = pt.callee.name.toUpperCase();
arg.argsCount = pt.arguments?.length;
});
if (pt.type === 'CallExpression') {
switch (pt.callee.name) {
switch (pt.callee.name.toUpperCase()) {
case 'ADD':
case 'SUM':
if (pt.arguments.length > 1) {
@ -631,13 +676,14 @@ async function _formulaQueryBuilder(
break;
}
const calleeName = pt.callee.name.toUpperCase();
return {
builder: knex.raw(
`${pt.callee.name}(${(
`${calleeName}(${(
await Promise.all(
pt.arguments.map(async (arg) => {
let query = (await fn(arg)).builder.toQuery();
if (pt.callee.name === 'CONCAT') {
if (calleeName === 'CONCAT') {
if (knex.clientType() !== 'sqlite3') {
query = await convertDateFormatForConcat(
arg,

8
packages/nocodb/src/db/functionMappings/mssql.ts

@ -130,15 +130,15 @@ const mssql = {
)},
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${
(await fn(pt.arguments[0])).builder
}), 'yyyy-MM-dd HH:mm')
}), 'yyyy-MM-dd HH:mm:ss')
ELSE
FORMAT(DATEADD(${String((await fn(pt.arguments[2])).builder).replace(
/["']/g,
'',
)},
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${fn(
pt.arguments[0],
)}), 'yyyy-MM-dd')
${dateIN > 0 ? '+' : ''}${(await fn(pt.arguments[1])).builder}, ${
(await fn(pt.arguments[0])).builder
}), 'yyyy-MM-dd')
END${colAlias}`,
),
};

2
packages/nocodb/src/db/functionMappings/mysql.ts

@ -62,7 +62,7 @@ const mysql2 = {
DATE_FORMAT(DATE_ADD(${(await fn(pt.arguments[0])).builder}, INTERVAL
${(await fn(pt.arguments[1])).builder} ${String(
(await fn(pt.arguments[2])).builder,
).replace(/["']/g, '')}), '%Y-%m-%d %H:%i')
).replace(/["']/g, '')}), '%Y-%m-%d %H:%i:%s')
ELSE
DATE(DATE_ADD(${(await fn(pt.arguments[0])).builder}, INTERVAL
${(await fn(pt.arguments[1])).builder} ${String(

6
packages/nocodb/src/db/functionMappings/sqlite.ts

@ -95,9 +95,9 @@ const sqlite3 = {
builder: knex.raw(
`CASE
WHEN ${(await fn(pt.arguments[0])).builder} LIKE '%:%' THEN
STRFTIME('%Y-%m-%d %H:%M', DATETIME(DATETIME(${
STRFTIME('%Y-%m-%dT%H:%M:%fZ', DATETIME(DATETIME(${
(await fn(pt.arguments[0])).builder
}, 'localtime'),
}, 'utc'),
${dateIN > 0 ? '+' : ''}${
(await fn(pt.arguments[1])).builder
} || ' ${String((await fn(pt.arguments[2])).builder).replace(
@ -105,7 +105,7 @@ const sqlite3 = {
'',
)}'))
ELSE
DATE(DATETIME(${(await fn(pt.arguments[0])).builder}, 'localtime'),
DATE(DATETIME(${(await fn(pt.arguments[0])).builder}),
${dateIN > 0 ? '+' : ''}${
(await fn(pt.arguments[1])).builder
} || ' ${String((await fn(pt.arguments[2])).builder).replace(

2
packages/nocodb/src/db/mapFunctionName.ts

@ -20,7 +20,7 @@ export interface MapFnArgs {
}
const mapFunctionName = async (args: MapFnArgs): Promise<any> => {
const name = args.pt.callee.name;
const name = args.pt.callee.name.toUpperCase();
let val;
switch (args.knex.clientType()) {

2
packages/nocodb/src/db/sql-client/lib/SqlClientFactory.ts

@ -1,7 +1,5 @@
import fs from 'fs';
import { promisify } from 'util';
import Noco from '../../../Noco';
import SqlClientFactoryEE from './ee/SqlClientFactoryEE';
import MySqlClient from './mysql/MysqlClient';
import MssqlClient from './mssql/MssqlClient';
import OracleClient from './oracle/OracleClient';

12
packages/nocodb/src/db/sql-migrator/lib/KnexMigrator.ts

@ -10,7 +10,7 @@ import Debug from '../../util/Debug';
import Emit from '../../util/emit';
import Result from '../../util/Result';
import * as fileHelp from '../../util/file.help';
import NcConfigFactory from '../../../utils/NcConfigFactory';
import { getToolDir, NcConfig } from '../../../utils/nc-config';
import SqlMigrator from './SqlMigrator';
const evt = new Emit();
@ -39,7 +39,7 @@ export default class KnexMigrator extends SqlMigrator {
this.project_id = projectObj?.project_id;
this.project = projectObj?.config;
this.metaDb = projectObj?.metaDb;
this.toolDir = NcConfigFactory.getToolDir();
this.toolDir = getToolDir();
}
emit(data, _args?) {
@ -312,8 +312,12 @@ export default class KnexMigrator extends SqlMigrator {
if (exists) {
await this._readProjectJson(projJsonFilePath);
this.emit('Migrator for project initalised successfully');
} else if (NcConfigFactory.hasDbUrl()) {
this.project = await NcConfigFactory.make();
} else if (
Object.keys(process.env).some((envKey) =>
envKey.startsWith('NC_DB_URL'),
)
) {
this.project = await NcConfig.createByEnv();
} else {
args.type = args.type || 'sqlite';

281
packages/nocodb/src/helpers/initAdminFromEnv.ts

@ -0,0 +1,281 @@
import { promisify } from 'util';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcryptjs';
import { validatePassword } from 'nocodb-sdk';
import boxen from 'boxen';
import { T } from 'nc-help';
import { isEmail } from 'validator';
import NocoCache from '../cache/NocoCache';
import { ProjectUser, User } from '../models';
import Noco from '../Noco';
import { CacheScope, MetaTable } from '../utils/globals';
const rolesLevel = { owner: 0, creator: 1, editor: 2, commenter: 3, viewer: 4 };
export default async function initAdminFromEnv(_ncMeta = Noco.ncMeta) {
if (process.env.NC_ADMIN_EMAIL && process.env.NC_ADMIN_PASSWORD) {
if (!isEmail(process.env.NC_ADMIN_EMAIL?.trim())) {
console.log(
'\n',
boxen(
`Provided admin email '${process.env.NC_ADMIN_EMAIL}' is not valid`,
{
title: 'Invalid admin email',
padding: 1,
borderStyle: 'double',
titleAlignment: 'center',
borderColor: 'red',
},
),
'\n',
);
process.exit(1);
}
const { valid, error, hint } = validatePassword(
process.env.NC_ADMIN_PASSWORD,
);
if (!valid) {
console.log(
'\n',
boxen(`${error}${hint ? `\n\n${hint}` : ''}`, {
title: 'Invalid admin password',
padding: 1,
borderStyle: 'double',
titleAlignment: 'center',
borderColor: 'red',
}),
'\n',
);
process.exit(1);
}
let ncMeta;
try {
ncMeta = await _ncMeta.startTransaction();
const email = process.env.NC_ADMIN_EMAIL.toLowerCase().trim();
const salt = await promisify(bcrypt.genSalt)(10);
const password = await promisify(bcrypt.hash)(
process.env.NC_ADMIN_PASSWORD,
salt,
);
const email_verification_token = uuidv4();
const roles = 'user,super';
// if super admin not present
if (await User.isFirst(ncMeta)) {
// roles = 'owner,creator,editor'
T.emit('evt', {
evt_type: 'project:invite',
count: 1,
});
await User.insert(
{
firstname: '',
lastname: '',
email,
salt,
password,
email_verification_token,
roles,
},
ncMeta,
);
} else {
const salt = await promisify(bcrypt.genSalt)(10);
const password = await promisify(bcrypt.hash)(
process.env.NC_ADMIN_PASSWORD,
salt,
);
const email_verification_token = uuidv4();
const superUser = await ncMeta.metaGet2(null, null, MetaTable.USERS, {
roles: 'user,super',
});
if (!superUser?.id) {
const existingUserWithNewEmail = await User.getByEmail(email, ncMeta);
if (existingUserWithNewEmail?.id) {
// clear cache
await NocoCache.delAll(
CacheScope.USER,
`${existingUserWithNewEmail.email}___*`,
);
await NocoCache.del(
`${CacheScope.USER}:${existingUserWithNewEmail.id}`,
);
await NocoCache.del(
`${CacheScope.USER}:${existingUserWithNewEmail.email}`,
);
// Update email and password of super admin account
await User.update(
existingUserWithNewEmail.id,
{
salt,
email,
password,
email_verification_token,
token_version: null,
refresh_token: null,
roles,
},
ncMeta,
);
} else {
T.emit('evt', {
evt_type: 'project:invite',
count: 1,
});
await User.insert(
{
firstname: '',
lastname: '',
email,
salt,
password,
email_verification_token,
roles,
},
ncMeta,
);
}
} else if (email !== superUser.email) {
// update admin email and password and migrate projects
// if user already present and associated with some project
// check user account already present with the new admin email
const existingUserWithNewEmail = await User.getByEmail(email, ncMeta);
if (existingUserWithNewEmail?.id) {
// get all project access belongs to the existing account
// and migrate to the admin account
const existingUserProjects = await ncMeta.metaList2(
null,
null,
MetaTable.PROJECT_USERS,
{
condition: { fk_user_id: existingUserWithNewEmail.id },
},
);
for (const existingUserProject of existingUserProjects) {
const userProject = await ProjectUser.get(
existingUserProject.project_id,
superUser.id,
ncMeta,
);
// if admin user already have access to the project
// then update role based on the highest access level
if (userProject) {
if (
rolesLevel[userProject.roles] >
rolesLevel[existingUserProject.roles]
) {
await ProjectUser.update(
userProject.project_id,
superUser.id,
existingUserProject.roles,
ncMeta,
);
}
} else {
// if super doesn't have access then add the access
await ProjectUser.insert(
{
...existingUserProject,
fk_user_id: superUser.id,
},
ncMeta,
);
}
// delete the old project access entry from DB
await ProjectUser.delete(
existingUserProject.project_id,
existingUserProject.fk_user_id,
ncMeta,
);
}
// delete existing user
await ncMeta.metaDelete(
null,
null,
MetaTable.USERS,
existingUserWithNewEmail.id,
);
// clear cache
await NocoCache.delAll(
CacheScope.USER,
`${existingUserWithNewEmail.email}___*`,
);
await NocoCache.del(
`${CacheScope.USER}:${existingUserWithNewEmail.id}`,
);
await NocoCache.del(
`${CacheScope.USER}:${existingUserWithNewEmail.email}`,
);
// Update email and password of super admin account
await User.update(
superUser.id,
{
salt,
email,
password,
email_verification_token,
token_version: null,
refresh_token: null,
},
ncMeta,
);
} else {
// if email's are not different update the password and hash
await User.update(
superUser.id,
{
salt,
email,
password,
email_verification_token,
token_version: null,
refresh_token: null,
},
ncMeta,
);
}
} else {
const newPasswordHash = await promisify(bcrypt.hash)(
process.env.NC_ADMIN_PASSWORD,
superUser.salt,
);
if (newPasswordHash !== superUser.password) {
// if email's are same and passwords are different
// then update the password and token version
await User.update(
superUser.id,
{
salt,
password,
email_verification_token,
token_version: null,
refresh_token: null,
},
ncMeta,
);
}
}
}
await ncMeta.commit();
} catch (e) {
console.log('Error occurred while updating/creating admin user');
console.log(e);
await ncMeta.rollback(e);
}
}
}

3
packages/nocodb/src/index.ts

@ -1,6 +1,5 @@
import Noco from './Noco';
import NcConfigFactory from './utils/NcConfigFactory';
export default Noco;
export { Noco, NcConfigFactory };
export { Noco };

6
packages/nocodb/src/init.ts

@ -1,12 +1,12 @@
import { Connection } from './connection/connection';
import { MetaService } from './meta/meta.service';
import { NcConfig } from './utils/nc-config';
import Noco from './Noco';
// run upgrader
import NcUpgrader from './version-upgrader/NcUpgrader';
export default async () => {
await Connection.init();
Noco._ncMeta = new MetaService(new Connection());
const config = await NcConfig.createByEnv();
Noco._ncMeta = new MetaService(config);
await NcUpgrader.upgrade({ ncMeta: Noco._ncMeta });
};

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save