Browse Source

Merge pull request #9722 from nocodb/nc-feat/workspace-icon

feat(nc-gui): workspace icon
pull/9867/head
Ramesh Mane 1 week ago committed by GitHub
parent
commit
07b0146049
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 17
      packages/nc-gui/components/cmd-k/index.vue
  2. 6
      packages/nc-gui/components/dlg/InviteDlg.vue
  3. 9
      packages/nc-gui/components/general/EmojiPicker.vue
  4. 71
      packages/nc-gui/components/general/ImageCropper.vue
  5. 2
      packages/nc-gui/components/general/UserIcon.vue
  6. 112
      packages/nc-gui/components/general/WorkspaceIcon.vue
  7. 523
      packages/nc-gui/components/general/WorkspaceIconSelector.vue
  8. 2
      packages/nc-gui/components/smartsheet/Form.vue
  9. 7
      packages/nc-gui/components/workspace/View.vue
  10. 37
      packages/nc-gui/composables/useCommandPalette/index.ts
  11. 6
      packages/nc-gui/lib/enums.ts
  12. 5
      packages/nc-gui/lib/types.ts
  13. 16
      packages/nc-gui/utils/commonUtils.ts
  14. 6
      packages/nocodb-sdk/src/lib/enums.ts
  15. 1
      packages/nocodb-sdk/src/lib/globals.ts
  16. 11
      packages/nocodb/src/controllers/attachments-secure.controller.ts
  17. 20
      packages/nocodb/src/controllers/attachments.controller.ts
  18. 8
      packages/nocodb/src/helpers/attachmentHelpers.ts
  19. 8
      packages/nocodb/src/helpers/catchError.ts
  20. 3
      packages/nocodb/src/interface/Jobs.ts
  21. 24
      packages/nocodb/src/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor.ts
  22. 2
      packages/nocodb/src/plugins/GenericS3/GenericS3.ts
  23. 12
      packages/nocodb/src/plugins/storage/Local.ts
  24. 28
      packages/nocodb/src/schema/swagger-v2.json
  25. 28
      packages/nocodb/src/schema/swagger.json
  26. 151
      packages/nocodb/src/services/attachments.service.ts
  27. 5
      packages/nocodb/src/types/nc-plugin/lib/IStorageAdapterV2.ts
  28. 2
      tests/playwright/pages/Dashboard/WorkspaceSettings/index.ts

17
packages/nc-gui/components/cmd-k/index.vue

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { useMagicKeys, whenever } from '@vueuse/core'
import { commandScore } from './command-score'
import type { CommandPaletteType } from '~/lib/types'
import type { CommandPaletteType } from '#imports'
interface CmdAction {
id: string
@ -10,7 +10,8 @@ interface CmdAction {
parent?: string
handler?: Function
scopePayload?: any
icon?: VNode | string
icon?: VNode | string | Record<string, any>
iconType?: string
keywords?: string[]
section?: string
is_default?: number | null
@ -85,6 +86,7 @@ const nestedScope = computed(() => {
id: parent,
label: parentEl?.title,
icon: parentEl?.icon,
iconType: parentEl?.iconType,
iconColor: parent.startsWith('ws-') ? parentEl?.iconColor : null,
})
parent = parentEl?.parent || 'root'
@ -414,13 +416,15 @@ defineExpose({
<GeneralWorkspaceIcon
v-if="el.icon && el.id.startsWith('ws')"
:workspace="{
title: el.label,
id: el.id.split('-')[1],
meta: {
color: el.iconColor,
icon: el.icon,
iconType: el.iconType,
},
}"
hide-label
size="small"
size="medium"
/>
<component
@ -501,13 +505,16 @@ defineExpose({
<GeneralWorkspaceIcon
v-if="item.data.icon && item.data.id.startsWith('ws')"
:workspace="{
title: item.data.title,
id: item.data.id.split('-')[2],
meta: {
color: item.data?.iconColor,
icon: item.data?.icon,
iconType: item.data?.iconType,
},
}"
class="mr-2"
size="small"
size="medium"
/>
<template v-else-if="item.data.section === 'Bases' || item.data.icon === 'project'">
<GeneralBaseIconColorPicker

6
packages/nc-gui/components/dlg/InviteDlg.vue

@ -417,15 +417,15 @@ const onRoleChange = (role: keyof typeof RoleLabels) => (inviteData.roles = role
</a-input>
</div>
<div class="flex flex-col max-h-64 overflow-y-auto nc-scrollbar-md mt-2">
<div class="flex flex-col max-h-64 overflow-y-auto nc-scrollbar-md mt-2 px-2">
<div
v-for="ws in workSpaceSelectList"
:key="ws.id"
class="px-4 cursor-pointer hover:bg-gray-100 rounded-lg h-9.5 py-2 w-full flex gap-2"
class="px-2 cursor-pointer hover:bg-gray-100 rounded-lg h-9.5 py-2 w-full flex gap-2"
@click="checked[ws.id!] = !checked[ws.id!]"
>
<div class="flex gap-2 capitalize items-center">
<GeneralWorkspaceIcon :hide-label="true" :workspace="ws" size="small" />
<GeneralWorkspaceIcon :workspace="ws" size="medium" />
{{ ws.title }}
</div>
<div class="flex-1" />

9
packages/nc-gui/components/general/EmojiPicker.vue

@ -39,10 +39,6 @@ function selectEmoji(_emoji: any) {
isOpen.value = false
}
const isUnicodeEmoji = computed(() => {
return emojiRef.value?.match(/\p{Extended_Pictographic}/gu)
})
const onClick = (e: Event) => {
if (readonly) return
@ -95,7 +91,7 @@ const showClearButton = computed(() => {
<template v-if="!emojiRef">
<slot name="default" />
</template>
<template v-else-if="isUnicodeEmoji">
<template v-else-if="isUnicodeEmoji(emojiRef)">
{{ emojiRef }}
</template>
<template v-else>
@ -117,6 +113,7 @@ const showClearButton = computed(() => {
:show-preview="false"
color="#40444D"
:auto-focus="true"
class="nc-emoji-picker"
@select="selectEmoji"
@click.stop="() => {}"
>
@ -141,7 +138,7 @@ const showClearButton = computed(() => {
@apply pr-22;
}
}
.emoji-mart {
.nc-emoji-picker.emoji-mart {
@apply !w-90;
span.emoji-type-native {

71
packages/nc-gui/components/general/ImageCropper.vue

@ -2,8 +2,8 @@
import { Cropper } from 'vue-advanced-cropper'
import 'vue-advanced-cropper/dist/style.css'
import 'vue-advanced-cropper/dist/theme.classic.css'
import type { AttachmentReqType } from 'nocodb-sdk'
import type { ImageCropperConfig } from '~/lib/types'
import type { AttachmentReqType, PublicAttachmentScope } from 'nocodb-sdk'
import type { ImageCropperConfig } from '#imports'
interface Props {
imageConfig: {
@ -14,14 +14,23 @@ interface Props {
cropperConfig: ImageCropperConfig
uploadConfig?: {
path?: string
scope?: PublicAttachmentScope
// filesize in bytes
maxFileSize?: number
}
showCropper: boolean
}
const { imageConfig, cropperConfig, uploadConfig, ...props } = defineProps<Props>()
const { imageConfig, uploadConfig, ...props } = defineProps<Props>()
const emit = defineEmits(['update:showCropper', 'submit'])
const showCropper = useVModel(props, 'showCropper', emit)
const { cropperConfig } = toRefs(props)
const imageRestriction = computed(() => {
return cropperConfig.value.imageRestriction || 'fit-area'
})
const { api, isLoading } = useApi()
const cropperRef = ref()
@ -31,12 +40,21 @@ const previewImage = ref({
src: '',
})
const fileSize = ref<number>(0)
const isValidFileSize = computed(() => {
return uploadConfig?.maxFileSize ? !!fileSize.value && fileSize.value <= uploadConfig?.maxFileSize : true
})
const handleCropImage = () => {
const { canvas } = cropperRef.value.getResult()
previewImage.value = {
canvas,
src: canvas.toDataURL(),
src: canvas.toDataURL(imageConfig.type),
}
;(canvas as any).toBlob((blob: Blob) => {
fileSize.value = blob.size
}, imageConfig.type)
}
const handleUploadImage = async (fileToUpload: AttachmentReqType[]) => {
@ -45,6 +63,7 @@ const handleUploadImage = async (fileToUpload: AttachmentReqType[]) => {
const uploadResult = await api.storage.uploadByUrl(
{
path: uploadConfig?.path as string,
scope: uploadConfig?.scope,
},
fileToUpload,
)
@ -69,18 +88,23 @@ const handleUploadImage = async (fileToUpload: AttachmentReqType[]) => {
const handleSaveImage = async () => {
if (previewImage.value.canvas) {
;(previewImage.value.canvas as any).toBlob(async (blob: Blob) => {
await handleUploadImage([
{
title: imageConfig.name,
fileName: imageConfig.name,
mimetype: imageConfig.type,
size: blob.size,
url: previewImage.value.src,
data: previewImage.value.src,
},
])
}, imageConfig.type)
await handleUploadImage([
{
title: imageConfig.name,
fileName: imageConfig.name,
mimetype: imageConfig.type,
size: fileSize.value,
url: previewImage.value.src,
data: previewImage.value.src,
},
])
}
}
const defaultSize = ({ imageSize, visibleArea }: { imageSize: Record<string, any>; visibleArea: Record<string, any> }) => {
return {
width: (visibleArea || imageSize).width,
height: (visibleArea || imageSize).height,
}
}
@ -95,7 +119,7 @@ watch(showCropper, () => {
</script>
<template>
<NcModal v-model:visible="showCropper" :mask-closable="false">
<NcModal v-model:visible="showCropper" :mask-closable="false" wrap-class-name="!z-1050">
<div class="nc-image-cropper-wrapper relative">
<Cropper
ref="cropperRef"
@ -105,7 +129,10 @@ watch(showCropper, () => {
:stencil-props="cropperConfig?.stencilProps || {}"
:min-height="cropperConfig?.minHeight"
:min-width="cropperConfig?.minWidth"
:image-restriction="cropperConfig?.imageRestriction"
:image-restriction="imageRestriction"
v-bind="
cropperConfig.stencilProps?.fillDefault || cropperConfig.stencilProps?.fillDefault === undefined ? { defaultSize } : {}
"
/>
<div v-if="previewImage.src" class="result_preview">
<img :src="previewImage.src" alt="Preview Image" />
@ -121,7 +148,13 @@ watch(showCropper, () => {
<span class="ml-2">Crop</span>
</NcButton>
<NcButton size="small" :loading="isLoading" :disabled="!previewImage.src" @click="handleSaveImage"> Save </NcButton>
<NcTooltip :disabled="isValidFileSize">
<template #title> Cropped file size is greater than max file size </template>
<NcButton size="small" :loading="isLoading" :disabled="!previewImage.src || !isValidFileSize" @click="handleSaveImage">
Save
</NcButton>
</NcTooltip>
</div>
</div>
</NcModal>

2
packages/nc-gui/components/general/UserIcon.vue

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { isColorDark, stringToColor } from '~/utils/colorsUtils'
import { isColorDark, stringToColor } from '#imports'
const props = withDefaults(
defineProps<{

112
packages/nc-gui/components/general/WorkspaceIcon.vue

@ -1,16 +1,66 @@
<script lang="ts" setup>
import type { WorkspaceType } from 'nocodb-sdk'
import { isColorDark, stringToColor } from '~/utils/colorsUtils'
import 'emoji-mart-vue-fast/css/emoji-mart.css'
import { Icon } from '@iconify/vue'
import { WorkspaceIconType, isColorDark, stringToColor } from '#imports'
const props = defineProps<{
workspace: WorkspaceType | undefined
workspaceIcon?: {
icon: string | Record<string, any>
iconType: WorkspaceIconType | string
}
hideLabel?: boolean
size?: 'small' | 'medium' | 'large'
size?: 'small' | 'medium' | 'large' | 'xlarge'
isRounded?: boolean
}>()
const { workspace } = toRefs(props)
const { getPossibleAttachmentSrc } = useAttachment()
const workspaceIcon = computed(() => {
if (!workspace.value) {
return {
icon: '',
iconType: '',
}
}
let icon = workspace.value.meta?.icon || ''
let iconType = workspace.value.meta?.iconType || ''
if (props.workspaceIcon) {
icon = props.workspaceIcon?.icon || ''
iconType = props.workspaceIcon?.iconType || ''
}
return {
icon: iconType === WorkspaceIconType.IMAGE && ncIsObject(icon) ? getPossibleAttachmentSrc(icon) || '' : icon,
iconType,
}
})
const workspaceColor = computed(() => {
const color = props.workspace ? props.workspace.meta?.color || stringToColor(props.workspace.id!) : undefined
const color = workspace.value ? workspace.value.meta?.color || stringToColor(workspace.value.id!) : undefined
if (!props.hideLabel && workspaceIcon.value.icon) {
switch (workspaceIcon.value.iconType) {
case WorkspaceIconType.IMAGE: {
return ''
}
case WorkspaceIconType.EMOJI: {
return '#F4F4F5'
}
case WorkspaceIconType.ICON: {
return '#F4F4F5'
}
default: {
return color || '#0A1433'
}
}
}
return color || '#0A1433'
})
@ -20,17 +70,69 @@ const size = computed(() => props.size || 'medium')
<template>
<div
class="flex nc-workspace-avatar"
class="flex nc-workspace-avatar overflow-hidden"
:class="{
'min-w-4 w-4 h-4 rounded': size === 'small',
'min-w-6 w-6 h-6 rounded-md': size === 'medium',
'min-w-10 w-10 h-10 rounded-lg !text-base': size === 'large',
'min-w-16 w-16 h-16 rounded-lg !text-4xl': size === 'xlarge',
'!rounded-[50%]': props.isRounded,
}"
:style="{ backgroundColor: workspaceColor }"
:style="{
backgroundColor:
!props.hideLabel && workspaceIcon.icon && workspaceIcon.iconType === WorkspaceIconType.IMAGE ? undefined : workspaceColor,
}"
>
<template v-if="!props.hideLabel">
<CellAttachmentPreviewImage
v-if="workspaceIcon.icon && workspaceIcon.iconType === WorkspaceIconType.IMAGE"
:srcs="workspaceIcon.icon"
class="flex-none !object-contain max-h-full max-w-full !m-0"
/>
<div
v-else-if="workspaceIcon.icon && workspaceIcon.iconType === WorkspaceIconType.EMOJI"
class="flex items-center justify-center"
:class="{
'text-white': isColorDark(workspaceColor),
'text-black opacity-80': !isColorDark(workspaceColor),
'text-sm': size === 'small',
'text-base': size === 'medium',
'text-2xl': size === 'large',
'text-4xl': size === 'xlarge',
}"
>
<template v-if="isUnicodeEmoji(workspaceIcon.icon)">
{{ workspaceIcon.icon }}
</template>
<template v-else>
<Icon
:data-testid="`nc-icon-${workspaceIcon.icon}`"
class="!text-inherit flex-none"
:class="{
'w-3 h-3': size === 'small',
'w-4 h-4': size === 'medium',
'w-6 h-6': size === 'large',
'w-10 h-10': size === 'xlarge',
}"
:icon="workspaceIcon.icon"
></Icon>
</template>
</div>
<GeneralIcon
v-else-if="workspaceIcon.icon && workspaceIcon.iconType === WorkspaceIconType.ICON"
:icon="workspaceIcon.icon"
class="flex-none"
:class="{
'text-white': isColorDark(workspaceColor),
'text-black opacity-80': !isColorDark(workspaceColor),
'w-3 h-3': size === 'small',
'w-4 h-4': size === 'medium',
'w-6 h-6': size === 'large',
'w-10 h-10': size === 'xlarge',
}"
/>
<div
v-else
class="font-semibold"
:class="{
'text-white': isColorDark(workspaceColor),

523
packages/nc-gui/components/general/WorkspaceIconSelector.vue

@ -0,0 +1,523 @@
<script lang="ts" setup>
import type { UploadChangeParam, UploadFile } from 'ant-design-vue'
import { Upload } from 'ant-design-vue'
import data from 'emoji-mart-vue-fast/data/apple.json'
import { EmojiIndex, Picker } from 'emoji-mart-vue-fast/src'
import { WorkspaceIconType } from '#imports'
import 'emoji-mart-vue-fast/css/emoji-mart.css'
import { PublicAttachmentScope } from 'nocodb-sdk'
interface Props {
icon: string | Record<string, any>
iconType: WorkspaceIconType | string
currentWorkspace: any
}
const props = withDefaults(defineProps<Props>(), {})
const emits = defineEmits(['update:icon', 'update:iconType', 'submit'])
const { currentWorkspace } = toRefs(props)
const vIcon = useVModel(props, 'icon', emits)
const vIconType = useVModel(props, 'iconType', emits)
const { t } = useI18n()
const { getPossibleAttachmentSrc } = useAttachment()
const isOpen = ref<boolean>(false)
const isLoading = ref<boolean>(false)
const inputRef = ref<HTMLInputElement>()
const searchQuery = ref<string>('')
const activeTabLocal = ref<WorkspaceIconType>(WorkspaceIconType.ICON)
const activeTab = computed({
get: () => activeTabLocal.value,
set: (value: WorkspaceIconType) => {
searchQuery.value = ''
activeTabLocal.value = value
nextTick(() => {
focusInput()
})
},
})
const icons = computed(() => {
return searchIcons(searchQuery.value)
})
const selectIcon = (icon: string) => {
vIcon.value = icon
vIconType.value = WorkspaceIconType.ICON
emits('submit')
isOpen.value = false
}
const handleRemoveIcon = (closeDropdown = true) => {
vIcon.value = ''
vIconType.value = ''
if (closeDropdown) {
isOpen.value = false
}
}
const emojiIndex = new EmojiIndex(data, {
emojisToShowFilter: (emoji: any) => {
if (Number(emoji.added_in) >= 14) {
return false
}
return true
},
})
function selectEmoji(_emoji: any) {
vIcon.value = _emoji.native
vIconType.value = WorkspaceIconType.EMOJI
emits('submit')
isOpen.value = false
}
const fileList = ref<UploadFile[]>([])
const imageCropperData = ref({
cropperConfig: {
stencilProps: {
aspectRatio: 1,
fillDefault: true,
},
minHeight: 150,
minWidth: 150,
},
imageConfig: {
src: '',
type: 'image',
name: 'icon',
},
uploadConfig: {
path: [NOCO, 'workspace', currentWorkspace.value?.id, 'icon'].join('/'),
scope: PublicAttachmentScope.WORKSPACEPICS,
maxFileSize: 2 * 1024 * 1024,
},
})
const handleOnUploadImage = async (data: any) => {
vIcon.value = data
vIconType.value = WorkspaceIconType.IMAGE
emits('submit')
isOpen.value = false
}
const showImageCropperLocal = ref(false)
const showImageCropper = ref(false)
const getWorkspaceLogoSrc = computed(() => {
if (vIcon.value && vIconType.value === WorkspaceIconType.IMAGE) {
return getPossibleAttachmentSrc(vIcon.value)
}
return []
})
const isUploadingImage = ref(false)
function rejectDrop(fileList: UploadFile[]) {
fileList.map((file) => {
return message.error(`${t('msg.error.fileUploadFailed')} ${file.name}`)
})
}
const handleChange = (info: UploadChangeParam) => {
const status = info.file.status
if (status === 'uploading') {
isUploadingImage.value = true
return
}
if (status === 'done' && info.file.originFileObj instanceof File) {
// 1. Revoke the object URL, to allow the garbage collector to destroy the uploaded before file
if (imageCropperData.value.imageConfig.src) {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
}
// 2. Create the blob link to the file to optimize performance:
const blob = URL.createObjectURL(info.file.originFileObj)
// 3. Update the image. The type will be derived from the extension
imageCropperData.value.imageConfig = {
src: blob,
type: info.file.originFileObj.type,
name: info.file.originFileObj.name,
}
isUploadingImage.value = false
showImageCropper.value = true
return
}
if (status === 'error') {
isUploadingImage.value = false
message.error(`${t('msg.error.fileUploadFailed')} ${info.file.name}`)
}
}
/** a workaround to override default antd upload api call */
const customReqCbk = (customReqArgs: { file: any; onSuccess: () => void }) => {
fileList.value.forEach((f) => {
if (f.uid === customReqArgs.file.uid) {
f.status = 'done'
handleChange({ file: f, fileList: fileList.value })
}
})
customReqArgs.onSuccess()
}
/** check if the file size exceeds the limit */
const beforeUpload = (file: UploadFile) => {
const exceedLimit = file.size! / 1024 / 1024 > 2
if (exceedLimit) {
message.error(`File ${file.name} is too big. The accepted file size is less than 2MB.`)
}
return !exceedLimit || Upload.LIST_IGNORE
}
const onVisibilityChange = (value: boolean) => {
if (!value && showImageCropperLocal.value) {
isOpen.value = true
}
}
function focusInput() {
if (activeTab.value === WorkspaceIconType.EMOJI) {
setTimeout(() => {
const emojiInput = document.querySelector('.emoji-mart-search input') as HTMLInputElement
if (!emojiInput) return
emojiInput.focus()
emojiInput.select()
})
} else if (activeTab.value === WorkspaceIconType.ICON) {
setTimeout(() => {
inputRef.value?.focus()
inputRef.value?.select()
}, 250)
}
}
watch(showImageCropper, (newValue) => {
if (newValue) {
showImageCropperLocal.value = true
} else {
setTimeout(() => {
showImageCropperLocal.value = false
}, 500)
}
})
watch(isOpen, (newValue) => {
if (newValue) {
nextTick(() => {
focusInput()
})
}
})
</script>
<template>
<div>
<NcDropdown v-model:visible="isOpen" overlay-class-name="w-[432px]" @visible-change="onVisibilityChange">
<div
class="rounded-lg border-1 flex-none w-17 h-17 overflow-hidden transition-all duration-300 cursor-pointer"
:class="{
'border-transparent': !isOpen && vIconType === WorkspaceIconType.IMAGE,
'border-nc-gray-medium': !isOpen && vIconType !== WorkspaceIconType.IMAGE,
'border-primary shadow-selected': isOpen,
}"
>
<GeneralWorkspaceIcon
:workspace="currentWorkspace"
:workspace-icon="{
icon: vIcon,
iconType: vIconType,
}"
size="xlarge"
class="!w-full !h-full !min-w-full rounded-none select-none cursor-pointer"
/>
</div>
<template #overlay>
<div class="pt-2 h-[320px]">
<NcTabs v-model:activeKey="activeTab" class="nc-workspace-icon-dropdown-tabs h-full">
<template #leftExtra>
<div class="w-0"></div>
</template>
<template #rightExtra>
<div>
<NcButton size="xs" type="text" :disabled="!vIcon" @click.stop="handleRemoveIcon"> Remove </NcButton>
</div>
</template>
<a-tab-pane :key="WorkspaceIconType.ICON" class="w-full" :disabled="isLoading">
<template #tab>
<div class="tab-title">
<GeneralIcon icon="ncPlaceholderIcon" class="flex-none" />
Icon
</div>
</template>
<div class="h-full overflow-auto nc-scrollbar-thin flex flex-col">
<div class="!sticky top-0 flex gap-2 bg-white px-2 py-2">
<a-input
ref="inputRef"
v-model:value="searchQuery"
:placeholder="$t('placeholder.searchIcons')"
class="nc-dropdown-search-unified-input z-10"
>
</a-input>
</div>
<div v-if="icons.length" class="grid px-3 auto-rows-max pb-2 gap-3 grid-cols-10">
<component
:is="i"
v-for="({ icon: i, name }, idx) in icons"
:key="idx"
:icon="i"
class="w-6 hover:bg-gray-100 cursor-pointer rounded p-1 text-gray-700 h-6"
@click="selectIcon(name)"
/>
</div>
<div v-else class="flex-1 grid place-items-center">
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" class="!my-0" />
</div>
</div>
</a-tab-pane>
<a-tab-pane :key="WorkspaceIconType.IMAGE" class="w-full" :disabled="isLoading">
<template #tab>
<div
class="tab-title"
:class="{
'!cursor-wait': isLoading,
}"
>
<GeneralIcon icon="ncUpload" class="flex-none" />
Upload
</div>
</template>
<div class="p-2 flex flex-col gap-2.5 h-full">
<div v-if="getWorkspaceLogoSrc.length" class="flex items-center gap-4">
<div class="h-12 w-12 p-2">
<CellAttachmentPreviewImage
:srcs="getWorkspaceLogoSrc"
class="flex-none !object-contain max-h-full max-w-full !m-0 rounded-lg"
/>
</div>
<div class="flex-1 w-[calc(100%_-_108px)]">
<NcTooltip class="truncate flex-1" show-on-truncate-only>
<template #title> {{ vIcon?.title || 'Workspace logo' }}</template>
{{ vIcon?.title || 'Workspace logo' }}
</NcTooltip>
<div class="text-nc-content-gray-muted text-sm">
{{ vIcon?.size ? `${(vIcon?.size / 1048576).toFixed(2)} MB` : '0 MB' }}
</div>
</div>
<div>
<NcButton icon-only type="text" size="xs" class="!px-1" @click="handleRemoveIcon(false)">
<template #icon>
<GeneralIcon icon="deleteListItem" />
</template>
</NcButton>
</div>
</div>
<div class="flex-1">
<a-upload-dragger
v-model:fileList="fileList"
name="file"
accept="image/*"
:disabled="isUploadingImage"
:multiple="false"
:show-upload-list="false"
class="nc-workspace-image-uploader"
:custom-request="customReqCbk"
:before-upload="beforeUpload"
@change="handleChange"
@reject="rejectDrop"
>
<div class="ant-upload-drag-icon !text-nc-content-gray-muted !mb-2 text-center">
<div v-if="isUploadingImage" class="h-6 grid place-items-center">
<GeneralLoader size="regular" />
</div>
<GeneralIcon v-else icon="upload" class="h-6 w-6" />
</div>
<div class="ant-upload-text !text-nc-content-gray-muted !text-sm">
Drop your icon here or <span class="text-nc-content-brand hover:underline">browse file</span>
<div class="mt-1">Supported: image/*</div>
</div>
</a-upload-dragger>
</div>
</div>
</a-tab-pane>
<a-tab-pane :key="WorkspaceIconType.EMOJI" class="w-full" :disabled="isLoading">
<template #tab>
<div class="tab-title">
<GeneralIcon icon="ncSmile" class="flex-none" />
Emoji
</div>
</template>
<div class="h-full">
<Picker
:data="emojiIndex"
:native="true"
:show-preview="false"
color="#40444D"
:auto-focus="true"
:show-categories="false"
:i18n="{
search: 'Search emoji',
}"
class="nc-workspace-emoji-picker"
@select="selectEmoji"
@click.stop="() => {}"
></Picker>
</div>
</a-tab-pane>
</NcTabs>
</div>
</template>
</NcDropdown>
<GeneralImageCropper
v-model:show-cropper="showImageCropper"
:cropper-config="imageCropperData.cropperConfig"
:image-config="imageCropperData.imageConfig"
:upload-config="imageCropperData.uploadConfig"
@submit="handleOnUploadImage"
></GeneralImageCropper>
</div>
</template>
<style lang="scss" scoped>
.nc-workspace-icon-dropdown-tabs {
:deep(.ant-tabs-nav) {
@apply px-3;
.ant-tabs-extra-content {
@apply self-start;
}
.ant-tabs-tab {
@apply px-0 pt-1 pb-2;
&.ant-tabs-tab-active {
@apply font-medium;
}
& + .ant-tabs-tab {
@apply ml-4;
}
.tab-title {
@apply text-xs leading-[24px] px-2 rounded hover:bg-gray-100 transition-colors flex items-center gap-2;
}
}
}
:deep(.ant-tabs-content) {
@apply h-full;
}
&.nc-ai-loading {
:deep(.ant-tabs-tab) {
@apply !cursor-wait;
}
}
:deep(.ant-tabs-tab-disabled) {
.tab-title {
@apply text-nc-content-gray-muted hover:bg-transparent;
}
}
}
:deep(.ant-input::placeholder) {
@apply text-gray-500;
}
:deep(.nc-workspace-avatar img) {
@apply !cursor-pointer;
}
</style>
<style>
.nc-workspace-image-uploader {
&.ant-upload.ant-upload-drag {
@apply !rounded-lg !bg-white !hover:bg-nc-bg-gray-light !transition-colors duration-300;
}
.ant-upload-btn {
@apply !flex flex-col items-center justify-center !min-h-[176px];
}
}
.nc-workspace-emoji-picker.emoji-mart {
@apply !w-108 !h-full !border-none bg-transparent rounded-t-none rounded-b-lg;
span.emoji-type-native {
@apply cursor-pointer;
}
.emoji-mart-anchor {
@apply h-8 py-1.5;
svg {
@apply h-3.5 !important;
}
}
.emoji-mart-search {
@apply px-2 mt-2;
input {
@apply text-sm pl-[11px] rounded-lg !py-5px transition-all duration-300 !outline-none ring-0;
&:focus {
@apply !outline-none ring-0 shadow-selected border-primary;
}
}
}
.emoji-mart-scroll {
@apply mt-1 px-1 overflow-x-hidden;
h3.emoji-mart-category-label {
@apply text-xs text-gray-500 mb-0;
}
}
.emoji-mart-scroll {
@apply nc-scrollbar-thin;
overflow-y: overlay;
}
.emoji-mart-emoji {
@apply !px-1 !py-0.75 !m-0.5;
}
.emoji-mart-emoji:hover:before {
@apply !rounded-md;
}
}
</style>

2
packages/nc-gui/components/smartsheet/Form.vue

@ -17,7 +17,7 @@ import {
isVirtualCol,
} from 'nocodb-sdk'
import type { ValidateInfo } from 'ant-design-vue/es/form/useForm'
import type { ImageCropperConfig } from '~/lib/types'
import type { ImageCropperConfig } from '#imports'
provide(IsFormInj, ref(true))
provide(IsGalleryInj, ref(false))

7
packages/nc-gui/components/workspace/View.vue

@ -109,7 +109,7 @@ onMounted(() => {
<NcPageHeader>
<template #icon>
<div class="flex justify-center items-center h-6 w-6">
<GeneralWorkspaceIcon :workspace="currentWorkspace" hide-label size="small" />
<GeneralWorkspaceIcon :workspace="currentWorkspace" size="medium" />
</div>
</template>
<template #title>
@ -166,11 +166,6 @@ onMounted(() => {
</template>
<style lang="scss" scoped>
.nc-workspace-avatar {
@apply min-w-5 h-5 w-5 rounded-[6px] flex items-center justify-center text-white font-weight-bold uppercase;
font-size: 0.7rem;
}
.tab {
@apply flex flex-row items-center gap-x-2;
}

37
packages/nc-gui/composables/useCommandPalette/index.ts

@ -37,23 +37,30 @@ export const useCommandPalette = createSharedComposable(() => {
const { workspacesList } = storeToRefs(useWorkspace())
const workspacesCmd = computed(() =>
(workspacesList.value || []).map((workspace: { id: string; title: string; meta?: { color: string } }) => ({
id: `ws-nav-${workspace.id}`,
title: workspace.title,
icon: 'workspace',
iconColor: workspace.meta?.color,
section: 'Workspaces',
scopePayload: {
scope: `ws-${workspace.id}`,
data: {
workspace_id: workspace.id,
(workspacesList?.value || []).map(
(workspace: {
id: string
title: string
meta?: { color: string; icon: string | Record<string, any>; iconType: string }
}) => ({
id: `ws-nav-${workspace.id}`,
title: workspace.title,
icon: workspace.meta?.icon || 'workspace',
iconType: workspace.meta?.iconType,
iconColor: workspace.meta?.color,
section: 'Workspaces',
scopePayload: {
scope: `ws-${workspace.id}`,
data: {
workspace_id: workspace.id,
},
},
},
handler: processHandler({
type: 'navigate',
payload: `/${workspace.id}/settings`,
handler: processHandler({
type: 'navigate',
payload: `/${workspace.id}/settings`,
}),
}),
})),
),
)
const commands = ref({

6
packages/nc-gui/lib/enums.ts

@ -180,3 +180,9 @@ export enum ExtensionsEvents {
export enum IntegrationStoreEvents {
INTEGRATION_ADD = 'integration-add',
}
export enum WorkspaceIconType {
IMAGE = 'IMAGE',
EMOJI = 'EMOJI',
ICON = 'ICON',
}

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

@ -245,6 +245,11 @@ interface FormFieldsLimitOptionsType {
interface ImageCropperConfig {
stencilProps?: {
aspectRatio?: number
/**
* It can be used to force the cropper fills all visible area by default:
* @default true
*/
fillDefault?: boolean
}
minHeight?: number
minWidth?: number

16
packages/nc-gui/utils/commonUtils.ts

@ -55,3 +55,19 @@ export const ncArrayFrom = <T>(
): T[] => {
return Array.from({ length }, (_, i) => contentCallback(i))
}
/**
* Checks if a string contains Unicode emojis.
*
* @param emoji - The string to check.
* @returns A boolean indicating if the string contains Unicode emojis.
*
* @example
* ```ts
* const hasEmoji = isUnicodeEmoji('Hello World 😊');
* console.log(hasEmoji); // Output: true
* ```
*/
export const isUnicodeEmoji = (emoji: string) => {
return !!emoji?.match(/(\p{Emoji}|\p{Extended_Pictographic})/gu)
}

6
packages/nocodb-sdk/src/lib/enums.ts

@ -440,3 +440,9 @@ export enum ViewLockType {
Locked = 'locked',
Collaborative = 'collaborative',
}
export enum PublicAttachmentScope {
WORKSPACEPICS = 'workspacePics',
PROFILEPICS = 'profilePics',
ORGANIZATIONPICS = 'organizationPics',
}

1
packages/nocodb-sdk/src/lib/globals.ts

@ -218,6 +218,7 @@ export enum NcErrorType {
INTEGRATION_LINKED_WITH_BASES = 'INTEGRATION_LINKED_WITH_BASES',
FORMULA_ERROR = 'FORMULA_ERROR',
PERMISSION_DENIED = 'PERMISSION_DENIED',
INVALID_ATTACHMENT_UPLOAD_SCOPE = 'INVALID_ATTACHMENT_UPLOAD_SCOPE',
}
type Roles = OrgUserRoles | ProjectRoles | WorkspaceUserRoles;

11
packages/nocodb/src/controllers/attachments-secure.controller.ts

@ -15,6 +15,7 @@ import {
} from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import { PublicAttachmentScope } from 'nocodb-sdk';
import type { AttachmentReqType, FileType } from 'nocodb-sdk';
import type { NcRequest } from '~/interface/config';
import { NcContext } from '~/interface/config';
@ -28,7 +29,7 @@ import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
import { TenantContext } from '~/decorators/tenant-context.decorator';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { NcError } from '~/helpers/catchError';
import { localFileExists } from '~/helpers/attachmentHelpers';
import { ATTACHMENT_ROOTS, localFileExists } from '~/helpers/attachmentHelpers';
@Controller()
export class AttachmentsSecureController {
@ -44,10 +45,12 @@ export class AttachmentsSecureController {
async upload(
@UploadedFiles() files: Array<FileType>,
@Req() req: NcRequest & { user: { id: string } },
@Query('scope') scope?: PublicAttachmentScope,
) {
const attachments = await this.attachmentsService.upload({
files: files,
req,
scope,
});
return attachments;
@ -60,10 +63,12 @@ export class AttachmentsSecureController {
async uploadViaURL(
@Body() body: Array<AttachmentReqType>,
@Req() req: NcRequest & { user: { id: string } },
@Query('scope') scope?: PublicAttachmentScope,
) {
const attachments = await this.attachmentsService.uploadViaURL({
urls: body,
req,
scope,
});
return attachments;
@ -89,7 +94,9 @@ export class AttachmentsSecureController {
);
}
const filePath = param.split('/')[2] === 'thumbnails' ? '' : 'uploads';
const targetParam = param.split('/')[2];
const filePath = ATTACHMENT_ROOTS.includes(targetParam) ? '' : 'uploads';
const file = await this.attachmentsService.getFile({
path: path.join('nc', filePath, fpath),

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

@ -16,6 +16,7 @@ import {
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { Response } from 'express';
import contentDisposition from 'content-disposition';
import { PublicAttachmentScope } from 'nocodb-sdk';
import type { AttachmentReqType, FileType } from 'nocodb-sdk';
import { UploadAllowedInterceptor } from '~/interceptors/is-upload-allowed/is-upload-allowed.interceptor';
import { GlobalGuard } from '~/guards/global/global.guard';
@ -23,7 +24,11 @@ import { AttachmentsService } from '~/services/attachments.service';
import { PresignedUrl } from '~/models';
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard';
import { NcContext, NcRequest } from '~/interface/config';
import { isPreviewAllowed, localFileExists } from '~/helpers/attachmentHelpers';
import {
ATTACHMENT_ROOTS,
isPreviewAllowed,
localFileExists,
} from '~/helpers/attachmentHelpers';
import { DataTableService } from '~/services/data-table.service';
import { TenantContext } from '~/decorators/tenant-context.decorator';
import { DataApiLimiterGuard } from '~/guards/data-api-limiter.guard';
@ -42,11 +47,16 @@ export class AttachmentsController {
@Post(['/api/v1/db/storage/upload', '/api/v2/storage/upload'])
@HttpCode(200)
@UseInterceptors(UploadAllowedInterceptor, AnyFilesInterceptor())
async upload(@UploadedFiles() files: Array<FileType>, @Req() req: NcRequest) {
async upload(
@UploadedFiles() files: Array<FileType>,
@Req() req: NcRequest,
@Query('scope') scope?: PublicAttachmentScope,
) {
const attachments = await this.attachmentsService.upload({
files: files,
path: req.query?.path?.toString(),
req,
scope,
});
return attachments;
@ -60,11 +70,13 @@ export class AttachmentsController {
@Body() body: Array<AttachmentReqType>,
@Query('path') path: string,
@Req() req: NcRequest,
@Query('scope') scope?: PublicAttachmentScope,
) {
const attachments = await this.attachmentsService.uploadViaURL({
urls: body,
path,
req,
scope,
});
return attachments;
@ -165,7 +177,9 @@ export class AttachmentsController {
);
}
const filePath = param.split('/')[2] === 'thumbnails' ? '' : 'uploads';
const targetParam = param.split('/')[2];
const filePath = ATTACHMENT_ROOTS.includes(targetParam) ? '' : 'uploads';
const file = await this.attachmentsService.getFile({
path: path.join('nc', filePath, fpath),

8
packages/nocodb/src/helpers/attachmentHelpers.ts

@ -2,6 +2,7 @@ import path from 'path';
import fs from 'fs';
import mime from 'mime/lite';
import slash from 'slash';
import { PublicAttachmentScope } from 'nocodb-sdk';
import { getToolDir } from '~/utils/nc-config';
import { NcError } from '~/helpers/catchError';
@ -72,3 +73,10 @@ export const localFileExists = (path: string) => {
.then(() => true)
.catch(() => false);
};
export const ATTACHMENT_ROOTS = [
'thumbnails',
PublicAttachmentScope.WORKSPACEPICS,
PublicAttachmentScope.PROFILEPICS,
PublicAttachmentScope.ORGANIZATIONPICS,
];

8
packages/nocodb/src/helpers/catchError.ts

@ -661,6 +661,10 @@ const errorHelpers: {
message: 'Permission denied',
code: 403,
},
[NcErrorType.INVALID_ATTACHMENT_UPLOAD_SCOPE]: {
message: 'Invalid attachment upload scope',
code: 400,
},
};
function generateError(
@ -1019,4 +1023,8 @@ export class NcError {
...(args || {}),
});
}
static invalidAttachmentUploadScope(args?: NcErrorArgs) {
throw new NcBaseErrorv2(NcErrorType.INVALID_ATTACHMENT_UPLOAD_SCOPE, args);
}
}

3
packages/nocodb/src/interface/Jobs.ts

@ -1,4 +1,4 @@
import type { AttachmentResType, UserType } from 'nocodb-sdk';
import type { AttachmentResType, PublicAttachmentScope, UserType } from 'nocodb-sdk';
import type { NcContext, NcRequest } from '~/interface/config';
export const JOBS_QUEUE = 'jobs';
@ -161,4 +161,5 @@ export interface DataExportJobData extends JobData {
export interface ThumbnailGeneratorJobData extends JobData {
attachments: AttachmentResType[];
scope?: PublicAttachmentScope;
}

24
packages/nocodb/src/modules/jobs/jobs/thumbnail-generator/thumbnail-generator.processor.ts

@ -4,7 +4,7 @@ import { Logger } from '@nestjs/common';
import slash from 'slash';
import type { IStorageAdapterV2 } from '~/types/nc-plugin';
import type { Job } from 'bull';
import type { AttachmentResType } from 'nocodb-sdk';
import type { AttachmentResType, PublicAttachmentScope } from 'nocodb-sdk';
import type { ThumbnailGeneratorJobData } from '~/interface/Jobs';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { getPathFromUrl } from '~/helpers/attachmentHelpers';
@ -14,12 +14,12 @@ export class ThumbnailGeneratorProcessor {
private logger = new Logger(ThumbnailGeneratorProcessor.name);
async job(job: Job<ThumbnailGeneratorJobData>) {
const { attachments } = job.data;
const { attachments, scope } = job.data;
const results = [];
for (const attachment of attachments) {
const thumbnail = await this.generateThumbnail(attachment);
const thumbnail = await this.generateThumbnail(attachment, scope);
if (!thumbnail) {
continue;
@ -38,6 +38,7 @@ export class ThumbnailGeneratorProcessor {
private async generateThumbnail(
attachment: AttachmentResType,
scope?: PublicAttachmentScope,
): Promise<{ [key: string]: string }> {
const sharp = Noco.sharp;
@ -53,6 +54,7 @@ export class ThumbnailGeneratorProcessor {
const { file, relativePath } = await this.getFileData(
attachment,
storageAdapter,
scope,
);
const thumbnailPaths = {
@ -119,13 +121,14 @@ export class ThumbnailGeneratorProcessor {
private async getFileData(
attachment: AttachmentResType,
storageAdapter: IStorageAdapterV2,
scope?: PublicAttachmentScope,
): Promise<{ file: Buffer; relativePath: string }> {
let relativePath;
if (attachment.path) {
relativePath = path.join(
'nc',
'uploads',
scope ? '' : 'uploads',
attachment.path.replace(/^download[/\\]/i, ''),
);
} else if (attachment.url) {
@ -134,8 +137,17 @@ export class ThumbnailGeneratorProcessor {
const file = await storageAdapter.fileRead(relativePath);
// remove everything before 'nc/uploads/' (including nc/uploads/) in relativePath
relativePath = relativePath.replace(/^.*?nc[/\\]uploads[/\\]/, '');
const scopePath = scope ? scope : 'uploads';
// remove everything before 'nc/${scopePath}/' (including nc/${scopePath}/) in relativePath
relativePath = relativePath.replace(
new RegExp(`^.*?nc[/\\\\]${scopePath}[/\\\\]`),
'',
);
if (scope) {
relativePath = `${scopePath}/${relativePath}`;
}
return { file, relativePath };
}

2
packages/nocodb/src/plugins/GenericS3/GenericS3.ts

@ -104,7 +104,7 @@ export default class GenericS3 implements IStorageAdapterV2 {
options?: {
mimetype?: string;
},
): Promise<void> {
): Promise<string | null> {
try {
const streamError = new Promise<void>((_, reject) => {
stream.on('error', (err) => {

12
packages/nocodb/src/plugins/storage/Local.ts

@ -70,13 +70,21 @@ export default class Local implements IStorageAdapterV2 {
public async fileCreateByStream(
key: string,
stream: Readable,
): Promise<void> {
): Promise<string | null> {
return new Promise((resolve, reject) => {
const destPath = validateAndNormaliseLocalPath(key);
try {
mkdirp(path.dirname(destPath)).then(() => {
const writableStream = fs.createWriteStream(destPath);
writableStream.on('finish', () => resolve());
writableStream.on('finish', () => {
this.fileRead(destPath)
.then(() => {
resolve(null);
})
.catch((e) => {
reject(e);
});
});
writableStream.on('error', (err) => reject(err));
stream.pipe(writableStream);
});

28
packages/nocodb/src/schema/swagger-v2.json

@ -11649,6 +11649,20 @@
},
{
"$ref": "#/components/parameters/xc-token"
},
{
"schema": {
"enum": [
"workspacePics",
"profilePics",
"organizationPics"
],
"type": "string",
"example": "workspacePics"
},
"name": "scope",
"in": "query",
"description": "The scope of the attachment"
}
],
"description": "Upload attachment"
@ -11688,6 +11702,20 @@
},
{
"$ref": "#/components/parameters/xc-token"
},
{
"schema": {
"enum": [
"workspacePics",
"profilePics",
"organizationPics"
],
"type": "string",
"example": "workspacePics"
},
"name": "scope",
"in": "query",
"description": "The scope of the attachment"
}
],
"description": "Upload attachment by URL. Used in Airtable Migration."

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

@ -16417,6 +16417,20 @@
},
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"enum": [
"workspacePics",
"profilePics",
"organizationPics"
],
"type": "string",
"example": "workspacePics"
},
"name": "scope",
"in": "query",
"description": "The scope of the attachment"
}
],
"description": "Upload attachment"
@ -16454,6 +16468,20 @@
},
{
"$ref": "#/components/parameters/xc-auth"
},
{
"schema": {
"enum": [
"workspacePics",
"profilePics",
"organizationPics"
],
"type": "string",
"example": "workspacePics"
},
"name": "scope",
"in": "query",
"description": "The scope of the attachment"
}
],
"description": "Upload attachment by URL. Used in Airtable Migration."

151
packages/nocodb/src/services/attachments.service.ts

@ -1,6 +1,7 @@
import path from 'path';
import Url from 'url';
import { AppEvents } from 'nocodb-sdk';
import { Readable } from 'stream';
import { AppEvents, PublicAttachmentScope } from 'nocodb-sdk';
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { nanoid } from 'nanoid';
import mime from 'mime/lite';
@ -52,17 +53,35 @@ export class AttachmentsService {
private readonly jobsService: IJobsService,
) {}
async upload(param: { files: FileType[]; req?: NcRequest; path?: string }) {
async upload(param: {
files: FileType[];
req?: NcRequest;
path?: string;
scope?: PublicAttachmentScope;
}) {
// Validate scope if exist
if (
param.scope &&
!Object.values(PublicAttachmentScope).includes(param.scope)
) {
NcError.invalidAttachmentUploadScope();
}
const userId = param.req?.user.id || 'anonymous';
param.path =
param.path || `${moment().format('YYYY/MM/DD')}/${hash(userId)}`;
param.path = param.scope
? `${hash(userId)}`
: param.path || `${moment().format('YYYY/MM/DD')}/${hash(userId)}`;
// TODO: add getAjvValidatorMw
const filePath = this.sanitizeUrlPath(
const _filePath = this.sanitizeUrlPath(
param.path?.toString()?.split('/') || [''],
);
const destPath = path.join('nc', 'uploads', ...filePath);
const _destPath = path.join(
'nc',
param.scope ? param.scope : 'uploads',
..._filePath,
);
const storageAdapter = await NcPluginMgrv2.storageAdapter();
@ -79,10 +98,25 @@ export class AttachmentsService {
queue.addAll(
param.files?.map((file) => async () => {
try {
const nanoId = nanoid(5);
const filePath = this.sanitizeUrlPath([
...(param?.path?.toString()?.split('/') || ['']),
...(param.scope ? [nanoId] : []),
]);
const destPath = param.scope
? path.join(_destPath, `${nanoId}`)
: _destPath;
const originalName = utf8ify(file.originalname);
const fileName = `${normalizeFilename(
path.parse(originalName).name,
)}_${nanoid(5)}${path.extname(originalName)}`;
const fileName = param.scope
? `${normalizeFilename(
path.parse(originalName).name,
)}${path.extname(originalName)}`
: `${normalizeFilename(path.parse(originalName).name)}_${nanoid(
5,
)}${path.extname(originalName)}`;
const tempMetadata: {
width?: number;
@ -170,6 +204,7 @@ export class AttachmentsService {
workspace_id: RootScopes.ROOT,
},
attachments: generateThumbnail,
scope: param.scope,
});
}
@ -186,16 +221,31 @@ export class AttachmentsService {
urls: AttachmentReqType[];
req?: NcRequest;
path?: string;
scope?: PublicAttachmentScope;
}) {
// Validate scope if exist
if (
param.scope &&
!Object.values(PublicAttachmentScope).includes(param.scope)
) {
NcError.invalidAttachmentUploadScope();
}
const userId = param.req?.user.id || 'anonymous';
param.path =
param.path || `${moment().format('YYYY/MM/DD')}/${hash(userId)}`;
param.path = param.scope
? `${hash(userId)}`
: param.path || `${moment().format('YYYY/MM/DD')}/${hash(userId)}`;
const filePath = this.sanitizeUrlPath(
param?.path?.toString()?.split('/') || [''],
);
const destPath = path.join('nc', 'uploads', ...filePath);
const destPath = path.join(
'nc',
param.scope ? param.scope : 'uploads',
...filePath,
);
const storageAdapter = await NcPluginMgrv2.storageAdapter();
@ -214,42 +264,94 @@ export class AttachmentsService {
try {
const { url, fileName: _fileName } = urlMeta;
const nanoId = nanoid(5);
const filePath = this.sanitizeUrlPath([
...(param.scope ? [param.scope] : []),
...(param?.path?.toString()?.split('/') || ['']),
...(param.scope ? [nanoId] : []),
]);
const fileDestPath = param.scope
? path.join(destPath, `${nanoId}`)
: destPath;
let mimeType,
response,
size,
finalUrl = url;
let base64TempStream: Readable;
let base64Buffer: Buffer;
if (!url.startsWith('data:')) {
response = await axios.head(url, { maxRedirects: 5 });
mimeType = response.headers['content-type']?.split(';')[0];
size = response.headers['content-length'];
finalUrl = response.request.res.responseUrl;
} else {
if (!url.startsWith('data')) {
NcError.badRequest('Invalid data URL format');
}
const [metadata, base64Data] = url.split(',');
const metadataHelper = metadata.split(':');
if (metadataHelper.length < 2) {
NcError.badRequest('Invalid data URL format');
}
const mimetypeHelper = metadataHelper[1].split(';');
mimeType = mimetypeHelper[0];
size = Buffer.byteLength(base64Data, 'base64');
base64Buffer = Buffer.from(base64Data, 'base64');
base64TempStream = Readable.from(base64Buffer);
}
const parsedUrl = Url.parse(finalUrl, true);
const decodedPath = decodeURIComponent(parsedUrl.pathname);
const fileNameWithExt = _fileName || path.basename(decodedPath);
const fileName = `${normalizeFilename(
path.parse(fileNameWithExt).name,
)}_${nanoid(5)}${path.extname(fileNameWithExt)}`;
const fileName = param.scope
? `${normalizeFilename(
path.parse(fileNameWithExt).name,
)}${path.extname(fileNameWithExt)}`
: `${normalizeFilename(path.parse(fileNameWithExt).name)}_${nanoid(
5,
)}${path.extname(fileNameWithExt)}`;
if (!mimeType) {
mimeType = mime.getType(path.extname(fileNameWithExt).slice(1));
}
const { url: attachmentUrl, data: file } =
await storageAdapter.fileCreateByUrl(
slash(path.join(destPath, fileName)),
finalUrl,
{
fetchOptions: {
// The sharp requires image to be passed as buffer.);
buffer: mimeType.includes('image'),
let attachmentUrl, file;
if (!base64TempStream) {
const { url: _attachmentUrl, data: _file } =
await storageAdapter.fileCreateByUrl(
slash(path.join(fileDestPath, fileName)),
finalUrl,
{
fetchOptions: {
// The sharp requires image to be passed as buffer.);
buffer: mimeType.includes('image'),
},
},
},
);
attachmentUrl = _attachmentUrl;
file = _file;
} else {
attachmentUrl = await storageAdapter.fileCreateByStream(
slash(path.join(fileDestPath, fileName)),
base64TempStream,
);
file = base64Buffer;
}
const tempMetadata: {
width?: number;
height?: number;
@ -331,6 +433,7 @@ export class AttachmentsService {
workspace_id: RootScopes.ROOT,
},
attachments: generateThumbnail,
scope: param.scope,
});
}

5
packages/nocodb/src/types/nc-plugin/lib/IStorageAdapterV2.ts

@ -30,7 +30,10 @@ export default interface IStorageAdapterV2<
url: string,
options?: FileCreateByUrlOptions,
): Promise<any>;
fileCreateByStream(destPath: string, readStream: Readable): Promise<void>;
fileCreateByStream(
destPath: string,
readStream: Readable,
): Promise<string | null>;
fileReadByStream(
key: string,
options?: { encoding?: string },

2
tests/playwright/pages/Dashboard/WorkspaceSettings/index.ts

@ -25,7 +25,7 @@ export class WorkspaceSettingsObject extends BasePage {
async renameWorkspace({ newTitle }: { newTitle: string }) {
await this.clickSettingsTab();
await this.get().getByTestId('nc-workspace-settings-settings-rename-input').fill(newTitle);
const submitAction = () => this.get().getByTestId('nc-workspace-settings-settings-rename-submit').click();
const submitAction = () => this.rootPage.keyboard.press('Enter');
await this.waitForResponse({
uiAction: submitAction,

Loading…
Cancel
Save