From 9792595dbd172cafd70e33f4c2887997b87996b7 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Tue, 7 Oct 2025 13:10:04 +0700 Subject: [PATCH 1/7] add generic uploader interface Signed-off-by: Alexander Onnikov --- desktop/src/ui/platform.ts | 7 +- desktop/src/ui/types.ts | 6 +- dev/docker-compose.yaml | 5 +- dev/prod/public/config.json | 5 +- dev/prod/src/platform.ts | 12 +- packages/presentation/src/file.ts | 260 ++---------------- packages/presentation/src/index.ts | 1 + packages/presentation/src/plugin.ts | 8 +- packages/presentation/src/preview.ts | 167 ++++-------- packages/presentation/src/storage.ts | 390 +++++++++++++++++++++++++++ packages/presentation/src/types.ts | 20 ++ server/front/src/index.ts | 2 + server/front/src/starter.ts | 5 +- 13 files changed, 509 insertions(+), 379 deletions(-) create mode 100644 packages/presentation/src/storage.ts diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 1f2e747293f..181341e7fba 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -131,7 +131,7 @@ import '@hcengineering/ai-assistant-assets' import analyticsCollector, { analyticsCollectorId } from '@hcengineering/analytics-collector' import { coreId } from '@hcengineering/core' import love, { loveId } from '@hcengineering/love' -import presentation, { parsePreviewConfig, parseUploadConfig, presentationId } from '@hcengineering/presentation' +import presentation, { createFileStorage, presentationId } from '@hcengineering/presentation' import print, { printId } from '@hcengineering/print' import sign from '@hcengineering/sign' import textEditor, { textEditorId } from '@hcengineering/text-editor' @@ -288,11 +288,10 @@ export async function configurePlatform (onWorkbenchConnect?: () => Promise -} - -interface FileUploadError { - key: string - error: string -} - -interface FileUploadSuccess { - key: string - id: string -} - -type FileUploadResult = FileUploadSuccess | FileUploadError - -const defaultUploadUrl = '/files' -const defaultFilesUrl = '/files/:workspace/:filename?file=:blobId&workspace=:workspace' - -function parseInt (value: string, fallback: number): number { - const number = Number.parseInt(value) - return Number.isInteger(number) ? number : fallback -} - -export function parseUploadConfig (config: string, uploadUrl: string): UploadConfig { - const uploadConfig: UploadConfig = { - 'form-data': { url: uploadUrl }, - 'signed-url': undefined - } - - if (config !== undefined) { - const configs = config.split(';') - for (const c of configs) { - if (c === '') { - continue - } - - const [key, size, url] = c.split('|') - - if (url === undefined || url === '') { - throw new Error(`Bad upload config: ${c}`) - } - - if (key === 'form-data') { - uploadConfig['form-data'] = { url } - } else if (key === 'signed-url') { - uploadConfig['signed-url'] = { - url, - size: parseInt(size, 0) * 1024 * 1024 - } - } else { - throw new Error(`Unknown upload config key: ${key}`) - } - } - } - - return uploadConfig -} - -export function getFilesUrl (): string { - const filesUrl = getMetadata(plugin.metadata.FilesURL) ?? defaultFilesUrl - const frontUrl = getMetadata(plugin.metadata.FrontUrl) ?? window.location.origin - - return filesUrl.includes('://') ? filesUrl : concatLink(frontUrl, filesUrl) -} +import { type FileStorage } from './types' export function getCurrentWorkspaceUuid (): WorkspaceUuid { const workspaceUuid = getMetadata(plugin.metadata.WorkspaceUuid) ?? '' return workspaceUuid as WorkspaceUuid } -/** - * @public - */ +/** @public */ export function generateFileId (): string { return uuid() } -/** - * @public - */ -export function getUploadUrl (): string { - const template = getMetadata(plugin.metadata.UploadURL) ?? defaultUploadUrl - - return template.replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) -} - -function getUploadConfig (): UploadConfig { - return getMetadata(plugin.metadata.UploadConfig) ?? { 'form-data': { url: getUploadUrl() } } -} - -function getFileUploadMethod (blob: Blob): { method: FileUploadMethod, url: string } { - const config = getUploadConfig() - - const signedUrl = config['signed-url'] - if (signedUrl !== undefined && signedUrl.size < blob.size) { - return { method: 'signed-url', url: signedUrl.url } +/** @public */ +export function getFileStorage (): FileStorage { + const storage = getMetadata(plugin.metadata.FileStorage) + if (storage === undefined) { + throw new Error('Missing file storage metadata') } - return { method: 'form-data', url: config['form-data'].url } -} - -/** - * @public - */ -export function getFileUploadParams (blobId: string, blob: Blob): FileUploadParams { - const workspaceId = encodeURIComponent(getCurrentWorkspaceUuid()) - const fileId = encodeURIComponent(blobId) - - const { method, url: urlTemplate } = getFileUploadMethod(blob) - - const url = urlTemplate.replaceAll(':workspace', workspaceId).replaceAll(':blobId', fileId) - - const headers: Record = - method !== 'signed-url' - ? { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - } - : {} - - return { method, url, headers } + return storage } -/** - * @public - */ +/** @public */ export function getFileUrl (file: string, filename?: string): string { if (file.includes('://')) { return file } - const template = getFilesUrl() - return template - .replaceAll(':filename', encodeURIComponent(filename ?? file)) - .replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) - .replaceAll(':blobId', encodeURIComponent(file)) + const storage = getFileStorage() + return storage.getFileUrl(file, filename) } -/** - * @public - */ +/** @public */ export async function uploadFile (file: File, uuid?: Ref): Promise> { uuid ??= generateFileId() as Ref - const params = getFileUploadParams(uuid, file) - - if (params.method === 'signed-url') { - await uploadFileWithSignedUrl(file, uuid, params.url) - } else { - await uploadFileWithFormData(file, uuid, params.url) - } + const storage = getFileStorage() + await storage.uploadFile(uuid, file) return uuid } -/** - * @public - */ -export async function deleteFile (id: string): Promise { - const fileUrl = getFileUrl(id) - - const resp = await fetch(fileUrl, { - method: 'DELETE', - headers: { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - } - }) - - if (!resp.ok) { - throw new Error('Failed to delete file') - } -} - -async function uploadFileWithFormData (file: File, uuid: string, uploadUrl: string): Promise { - const data = new FormData() - data.append('file', file, uuid) - - const resp = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - }, - body: data - }) - - if (!resp.ok) { - if (resp.status === 413) { - throw new PlatformError(new Status(Severity.ERROR, plugin.status.FileTooLarge, {})) - } else { - throw Error(`Failed to upload file: ${resp.statusText}`) - } - } - - const result = (await resp.json()) as FileUploadResult[] - if (result.length !== 1) { - throw Error('Bad upload response') - } - - if ('error' in result[0]) { - throw Error(`Failed to upload file: ${result[0].error}`) - } -} - -async function uploadFileWithSignedUrl (file: File, uuid: string, uploadUrl: string): Promise { - const response = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - } - }) - - if (response.ok) { - throw Error(`Failed to genearte signed upload URL: ${response.statusText}`) - } - - const signedUrl = await response.text() - if (signedUrl === undefined || signedUrl === '') { - throw Error('Missing signed upload URL') - } - - try { - const response = await fetch(signedUrl, { - body: file, - method: 'PUT', - headers: { - 'Content-Type': file.type, - 'Content-Length': file.size.toString() - // 'x-amz-meta-last-modified': file.lastModified.toString() - } - }) - - if (!response.ok) { - throw Error(`Failed to upload file: ${response.statusText}`) - } - - // confirm we uploaded file - await fetch(uploadUrl, { - method: 'PUT', - headers: { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - } - }) - } catch (err) { - // abort the upload - await fetch(uploadUrl, { - method: 'DELETE', - headers: { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - } - }) - } +/** @public */ +export async function deleteFile (file: string): Promise { + const storage = getFileStorage() + await storage.deleteFile(file) } export async function getJsonOrEmpty (file: string, name: string): Promise { diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index ba646872468..1d7eb387859 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -79,3 +79,4 @@ export * from './drawingCommandsProcessor' export * from './link-preview' export * from './communication' export * from './pulse' +export * from './storage' diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index b52c3d78930..bf683d41d6d 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -39,14 +39,13 @@ import { import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' import { type ComponentExtensionId } from '@hcengineering/ui/src/types' -import { type UploadConfig } from './file' import { type PresentationMiddlewareFactory } from './pipeline' -import type { PreviewConfig } from './preview' import { type ComponentPointExtension, type DocCreateExtension, type DocRules, type FilePreviewExtension, + type FileStorage, type InstantTransactions, type ObjectSearchCategory } from './types' @@ -165,7 +164,7 @@ export default plugin(presentationId, { FrontVersion: '' as Metadata, Draft: '' as Metadata>, UploadURL: '' as Metadata, - FilesURL: '' as Metadata, + DatalakeUrl: '' as Metadata, CollaboratorUrl: '' as Metadata, Token: '' as Metadata, Endpoint: '' as Metadata, @@ -173,8 +172,7 @@ export default plugin(presentationId, { WorkspaceDataId: '' as Metadata, FrontUrl: '' as Asset, LinkPreviewUrl: '' as Metadata, - UploadConfig: '' as Metadata, - PreviewConfig: '' as Metadata, + FileStorage: '' as Metadata, ClientHook: '' as Metadata, SessionId: '' as Metadata, StatsUrl: '' as Metadata, diff --git a/packages/presentation/src/preview.ts b/packages/presentation/src/preview.ts index 2c0e9ca17a2..ca693a5e596 100644 --- a/packages/presentation/src/preview.ts +++ b/packages/presentation/src/preview.ts @@ -1,9 +1,10 @@ -import type { Blob, Ref } from '@hcengineering/core' -import { concatLink } from '@hcengineering/core' +import { type Blob, type Ref, concatLink } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' import { withRetry } from '@hcengineering/retry' -import { getFileUrl, getCurrentWorkspaceUuid } from './file' +import { getFileUrl, getFileStorage, getCurrentWorkspaceUuid } from './file' +import { type FileStorage } from './types' + import presentation from './plugin' export interface PreviewMetadata { @@ -18,7 +19,6 @@ export interface PreviewConfig { image: string video: string } - export interface VideoMeta { hls?: HLSMeta } @@ -28,50 +28,6 @@ export interface HLSMeta { source?: string } -const defaultImagePreview = (): string => `/files/${getCurrentWorkspaceUuid()}?file=:blobId&size=:size` - -/** - * - * PREVIEW_CONFIG env variable format. - * - image - an Url with :workspace, :blobId, :downloadFile, :size placeholders. - * - video - an Url with :workspace, :blobId placeholders. - */ -export function parsePreviewConfig (config?: string): PreviewConfig | undefined { - if (config === undefined) { - return - } - - const previewConfig = { image: defaultImagePreview(), video: '' } - - const configs = config.split(';') - for (const c of configs) { - if (c.includes('|')) { - const [key, value] = c.split('|') - if (key === 'image') { - previewConfig.image = value - } else if (key === 'video') { - previewConfig.video = value - } else { - throw new Error(`Unknown preview config key: ${key}`) - } - } else { - // fallback to image-only config for compatibility - previewConfig.image = c - } - } - - return Object.freeze(previewConfig) -} - -export function getPreviewConfig (): PreviewConfig { - return ( - (getMetadata(presentation.metadata.PreviewConfig) as PreviewConfig) ?? { - image: defaultImagePreview(), - video: '' - } - ) -} - export async function getBlobRef ( file: Ref, name?: string, @@ -92,11 +48,12 @@ export async function getBlobSrcSet (file: Ref, width?: number, height?: n } export function getSrcSet (_blob: Ref, width?: number, height?: number): string { - return blobToSrcSet(getPreviewConfig(), _blob, width, height) + const fileStorage = getFileStorage() + return blobToSrcSet(fileStorage, _blob, width, height) } function blobToSrcSet ( - cfg: PreviewConfig, + fileStorage: FileStorage, blob: Ref, width: number | undefined, height: number | undefined @@ -104,48 +61,30 @@ function blobToSrcSet ( if (blob.includes('://')) { return '' } + return getFileUrl(blob) - const workspace = encodeURIComponent(getCurrentWorkspaceUuid()) - const name = encodeURIComponent(blob) - - const previewUrl = getMetadata(presentation.metadata.PreviewUrl) ?? '' - if (previewUrl !== '') { - if (width !== undefined) { - return ( - getImagePreviewUrl(workspace, name, width, height ?? width, 1) + - ' 1x , ' + - getImagePreviewUrl(workspace, name, width, height ?? width, 2) + - ' 2x, ' + - getImagePreviewUrl(workspace, name, width, height ?? width, 3) + - ' 3x' - ) - } else { - return '' - } - } + // let url = cfg.image.replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) + // const downloadUrl = getFileUrl(blob) - let url = cfg.image.replaceAll(':workspace', workspace) - const downloadUrl = getFileUrl(blob) + // const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin + // if (!url.includes('://')) { + // url = concatLink(frontUrl ?? '', url) + // } + // url = url.replaceAll(':downloadFile', encodeURIComponent(downloadUrl)) + // url = url.replaceAll(':blobId', encodeURIComponent(blob)) - const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin - if (!url.includes('://')) { - url = concatLink(frontUrl ?? '', url) - } - url = url.replaceAll(':downloadFile', encodeURIComponent(downloadUrl)) - url = url.replaceAll(':blobId', name) - - let result = '' - if (width !== undefined) { - result += - formatImageSize(url, width, height ?? width, 1) + - ' 1x , ' + - formatImageSize(url, width, height ?? width, 2) + - ' 2x, ' + - formatImageSize(url, width, height ?? width, 3) + - ' 3x' - } + // let result = '' + // if (width !== undefined) { + // result += + // fileStorage.getImageUrl(blob, { width, height: height ?? width, dpr: 1 }) + + // ' 1x , ' + + // fileStorage.getImageUrl(blob, { width, height: height ?? width, dpr: 2 }) + + // ' 2x, ' + + // fileStorage.getImageUrl(blob, { width, height: height ?? width, dpr: 3 }) + + // ' 3x' + // } - return result + // return result } export function getPreviewThumbnail (file: string, width: number, height: number, dpr?: number): string { @@ -198,33 +137,39 @@ function formatImageSize (url: string, width: number, height: number, dpr: numbe * @deprecated, please use Blob direct operations. */ export function getFileSrcSet (_blob: Ref, width?: number, height?: number): string { - return blobToSrcSet(getPreviewConfig(), _blob, width, height) + return blobToSrcSet(getFileStorage(), _blob, width, height) } /** * @public */ export async function getVideoMeta (file: string, filename?: string): Promise { - const cfg = getPreviewConfig() - - let url = cfg.video - .replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) - .replaceAll(':blobId', encodeURIComponent(file)) - - if (url === '') { - return undefined - } - - const token = getMetadata(presentation.metadata.Token) ?? '' - const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin - if (!url.includes('://')) { - url = concatLink(frontUrl ?? '', url) - } - - try { - const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) - if (response.ok) { - return (await response.json()) as VideoMeta - } - } catch {} + // const cfg = getPreviewConfig() + + // let url = cfg.video + // .replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) + // .replaceAll(':blobId', encodeURIComponent(file)) + + // if (url === '') { + // return undefined + // } + + // const token = getMetadata(presentation.metadata.Token) ?? '' + // const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin + // if (!url.includes('://')) { + // url = concatLink(frontUrl ?? '', url) + // } + + // try { + // const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) + // if (response.ok) { + // const result = (await response.json()) as VideoMeta + // if (result.hls !== undefined) { + // result.hls.source = getBlobUrl(result.hls.source ?? '') + // result.hls.thumbnail = getBlobUrl(result.hls.thumbnail ?? '') + // } + // return result + // } + // } catch {} + return undefined } diff --git a/packages/presentation/src/storage.ts b/packages/presentation/src/storage.ts new file mode 100644 index 00000000000..ba59cb2e976 --- /dev/null +++ b/packages/presentation/src/storage.ts @@ -0,0 +1,390 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type WorkspaceUuid, concatLink } from '@hcengineering/core' +import { getMetadata } from '@hcengineering/platform' +import presentation from './plugin' +import { type FileStorage, type FileStorageUploadOptions } from './types' + +function getWorkspace (): WorkspaceUuid { + const workspaceUuid = getMetadata(presentation.metadata.WorkspaceUuid) ?? '' + return workspaceUuid as WorkspaceUuid +} + +function getToken (): string { + return getMetadata(presentation.metadata.Token) ?? '' +} + +export function createFileStorage (uploadUrl: string, datalakeUrl?: string, hulylakeUrl?: string): FileStorage { + if (datalakeUrl !== undefined && datalakeUrl !== '') { + console.debug('Using Datalake storage') + return new DatalakeStorage(datalakeUrl) + } + + if (hulylakeUrl !== undefined && hulylakeUrl !== '') { + console.debug('Using Hulylake storage') + return new HulylakeStorage(hulylakeUrl) + } + + console.debug('Using Front storage') + return new FrontStorage(uploadUrl) +} + +class FrontStorage implements FileStorage { + constructor (private readonly baseUrl: string) {} + + getFileUrl (file: string, filename?: string): string { + const workspace = getWorkspace() + const path = `/${workspace}/${filename}?file=${file}&workspace=${workspace}` + return concatLink(this.baseUrl, path) + } + + async getFileMeta (file: string): Promise> { + return {} + } + + async deleteFile (file: string): Promise { + const token = getToken() + const url = this.getFileUrl(file) + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + async uploadFile (uuid: string, file: File, options?: FileStorageUploadOptions): Promise { + const token = getToken() + const headers = { Authorization: `Bearer ${token}` } + await uploadXhr(this.baseUrl, headers, uuid, file, options) + } +} + +class DatalakeStorage implements FileStorage { + constructor (private readonly baseUrl: string) {} + + getFileUrl (file: string, filename?: string): string { + const workspace = getWorkspace() + const path = `/blob/${workspace}/${file}/${filename}` + return concatLink(this.baseUrl, path) + } + + async getFileMeta (file: string): Promise> { + const workspace = getWorkspace() + const token = getToken() + + const url = concatLink(this.baseUrl, `/meta/${encodeURIComponent(workspace)}/${encodeURIComponent(file)}`) + try { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${token}` + } + }) + if (response.ok) { + return await response.json() + } + } catch (err: any) {} + return {} + } + + async deleteFile (file: string): Promise { + const token = getToken() + const url = this.getFileUrl(file) + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + async uploadFile (uuid: string, file: File, options?: FileStorageUploadOptions): Promise { + const workspace = getWorkspace() + const token = getToken() + + if (file.size <= 10 * 1024 * 1024) { + const url = concatLink(this.baseUrl, `/upload/form-data/${encodeURIComponent(workspace)}`) + const headers = { Authorization: `Bearer ${token}` } + await uploadXhr(url, headers, uuid, file, options) + } else { + const url = concatLink( + this.baseUrl, + `/upload/multipart/${encodeURIComponent(workspace)}/${encodeURIComponent(uuid)}` + ) + const headers = { Authorization: `Bearer ${token}` } + await uploadMultipart(url, headers, uuid, file, options) + } + } +} + +class HulylakeStorage implements FileStorage { + constructor (private readonly baseUrl: string) {} + + getFileUrl (file: string, filename?: string): string { + const workspace = getWorkspace() + const path = `/api/${workspace}/${file}` + return concatLink(this.baseUrl, path) + } + + async getFileMeta (file: string): Promise> { + return {} + } + + async deleteFile (file: string): Promise { + const token = getToken() + const url = this.getFileUrl(file) + + const response = await fetch(url, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}` + } + }) + + if (!response.ok) { + throw new Error('Failed to delete file') + } + } + + async uploadFile (uuid: string, file: File, options?: FileStorageUploadOptions): Promise { + const token = getToken() + const url = this.getFileUrl(uuid) + + const response = await fetch(url, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}` + }, + signal: options?.signal + }) + + if (!response.ok) { + throw new Error('Failed to upload file') + } + } +} + +async function uploadXhr ( + url: string, + headers: Record, + uuid: string, + file: File | Blob, + options?: FileStorageUploadOptions +): Promise { + const xhr = new XMLHttpRequest() + const signal = options?.signal + const onProgress = options?.onProgress + + await new Promise((resolve, reject) => { + if (signal !== undefined) { + signal.onabort = () => { + xhr.abort() + reject(new Error('Upload aborted')) + } + } + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && onProgress !== undefined) { + onProgress({ + loaded: event.loaded, + total: event.total, + percentage: Math.round((event.loaded * 100) / event.total) + }) + } + } + + xhr.onload = async () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve() + } else { + const error = new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`) + reject(error) + } + } + + xhr.onerror = () => { + const error = new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`) + reject(error) + } + + xhr.ontimeout = () => { + const error = new Error('Upload timeout') + reject(error) + } + + xhr.open('POST', url, true) + for (const key in headers) { + xhr.setRequestHeader(key, headers[key]) + } + + const formData = new FormData() + formData.append('file', file, uuid) + xhr.send(formData) + }) +} + +async function uploadMultipart ( + url: string, + headers: Record, + uuid: string, + file: File | Blob, + options?: FileStorageUploadOptions +): Promise { + const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB chunks + const signal = options?.signal + const onProgress = options?.onProgress + + let uploadId: string | undefined + + try { + const { uploadId } = await multipartUploadCreate(url, headers, signal) + + // 2. Upload parts + const parts: Array<{ partNumber: number, etag: string }> = [] + const totalParts = Math.ceil(file.size / CHUNK_SIZE) + let uploadedSize = 0 + + for (let partNumber = 1; partNumber <= totalParts; partNumber++) { + const start = (partNumber - 1) * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) + + throwIfAborted(signal) + + const { etag } = await multipartUploadPart(url, headers, uploadId, partNumber, chunk, signal) + parts.push({ partNumber, etag }) + + uploadedSize += chunk.size + onProgress?.({ + loaded: uploadedSize, + total: file.size, + percentage: Math.round((uploadedSize * 100) / file.size) + }) + } + + throwIfAborted(signal) + + await multipartUploadComplete(url, headers, uploadId, parts, signal) + } catch (err) { + if (uploadId !== undefined) { + await multipartUploadAbort(url, headers, uploadId) + } + + const error = err instanceof Error ? err : new Error(String(err)) + throw error + } +} + +function throwIfAborted (signal?: AbortSignal): void { + if (signal?.aborted === true) { + throw new Error('Upload aborted') + } +} + +async function multipartUploadCreate ( + baseUrl: string, + headers: Record, + signal?: AbortSignal +): Promise<{ uuid: string, uploadId: string }> { + const response = await fetch(baseUrl, { + signal, + method: 'POST', + headers + }) + + if (!response.ok) { + throw new Error('Failed to initialize multipart upload') + } + + const { uuid, uploadId } = await response.json() + return { uuid, uploadId } +} + +async function multipartUploadComplete ( + baseUrl: string, + headers: Record, + uploadId: string, + parts: Array<{ partNumber: number, etag: string }>, + signal?: AbortSignal +): Promise { + const url = new URL(concatLink(baseUrl, '/complete')) + url.searchParams.set('uploadId', uploadId) + + const response = await fetch(url, { + signal, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers + }, + body: JSON.stringify({ parts }) + }) + + if (!response.ok) { + throw new Error('Failed to complete multipart upload') + } +} + +async function multipartUploadPart ( + baseUrl: string, + headers: Record, + uploadId: string, + partNumber: number, + blob: Blob, + signal?: AbortSignal +): Promise<{ etag: string }> { + const url = new URL(concatLink(baseUrl, '/part')) + url.searchParams.set('uploadId', uploadId) + url.searchParams.set('partNumber', `${partNumber}`) + + const response = await fetch(url, { + signal, + method: 'PUT', + headers, + body: blob + }) + + if (!response.ok) { + throw new Error(`Failed to upload part ${partNumber}`) + } + + const { etag } = await response.json() + return { etag } +} + +async function multipartUploadAbort (baseUrl: string, headers: Record, uploadId: string): Promise { + const url = new URL(concatLink(baseUrl, '/abort')) + url.searchParams.set('uploadId', uploadId) + + const response = await fetch(url, { + method: 'POST', + headers + }) + + if (!response.ok) { + throw new Error('Failed to reject multipart upload') + } +} diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index 3bf75bf4632..e4d02c176be 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -205,3 +205,23 @@ export interface FilePreviewExtension extends ComponentPointExtension { export interface InstantTransactions extends Class { txClasses: Array>> } + +export interface FileStorageUploadProgress { + loaded: number + total: number + percentage: number +} + +/** @public */ +export interface FileStorageUploadOptions { + onProgress?: (progress: FileStorageUploadProgress) => void + signal?: AbortSignal +} + +/** @public */ +export interface FileStorage { + getFileUrl: (file: string, filename?: string) => string + getFileMeta: (file: string) => Promise> + uploadFile: (uuid: string, file: File, options?: FileStorageUploadOptions) => Promise + deleteFile: (file: string) => Promise +} diff --git a/server/front/src/index.ts b/server/front/src/index.ts index ad9c0526642..f1dfcd38ce4 100644 --- a/server/front/src/index.ts +++ b/server/front/src/index.ts @@ -280,6 +280,7 @@ export function start ( billingUrl?: string pulseUrl?: string hulylakeUrl?: string + datalakeUrl?: string }, port: number, extraConfig?: Record @@ -359,6 +360,7 @@ export function start ( BILLING_URL: config.billingUrl, PULSE_URL: config.pulseUrl, HULYLAKE_URL: config.hulylakeUrl, + DATALAKE_URL: config.datalakeUrl, ...(extraConfig ?? {}) } res.status(200) diff --git a/server/front/src/starter.ts b/server/front/src/starter.ts index 74024a57c4d..dd24e0b28fe 100644 --- a/server/front/src/starter.ts +++ b/server/front/src/starter.ts @@ -132,6 +132,8 @@ export function startFront (ctx: MeasureContext, extraConfig?: Record Date: Wed, 8 Oct 2025 21:06:43 +0700 Subject: [PATCH 2/7] fix: refactor xhr upload Signed-off-by: Alexander Onnikov --- packages/presentation/src/storage.ts | 106 +++++++++++++++++---------- packages/presentation/src/types.ts | 3 +- packages/presentation/src/utils.ts | 4 +- 3 files changed, 72 insertions(+), 41 deletions(-) diff --git a/packages/presentation/src/storage.ts b/packages/presentation/src/storage.ts index ba59cb2e976..fdf1200cb0c 100644 --- a/packages/presentation/src/storage.ts +++ b/packages/presentation/src/storage.ts @@ -73,8 +73,21 @@ class FrontStorage implements FileStorage { async uploadFile (uuid: string, file: File, options?: FileStorageUploadOptions): Promise { const token = getToken() - const headers = { Authorization: `Bearer ${token}` } - await uploadXhr(this.baseUrl, headers, uuid, file, options) + const workspace = getWorkspace() + + const formData = new FormData() + formData.append('file', file) + formData.append('uuid', uuid) + + await uploadXhr( + { + url: concatLink(this.baseUrl, `/${workspace}`), + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData + }, + options + ) } } @@ -126,9 +139,19 @@ class DatalakeStorage implements FileStorage { const token = getToken() if (file.size <= 10 * 1024 * 1024) { - const url = concatLink(this.baseUrl, `/upload/form-data/${encodeURIComponent(workspace)}`) - const headers = { Authorization: `Bearer ${token}` } - await uploadXhr(url, headers, uuid, file, options) + const formData = new FormData() + formData.append('file', file) + formData.append('uuid', uuid) + + await uploadXhr( + { + url: concatLink(this.baseUrl, `/upload/form-data/${encodeURIComponent(workspace)}`), + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData + }, + options + ) } else { const url = concatLink( this.baseUrl, @@ -173,32 +196,36 @@ class HulylakeStorage implements FileStorage { const token = getToken() const url = this.getFileUrl(uuid) - const response = await fetch(url, { - method: 'PUT', - headers: { - Authorization: `Bearer ${token}` + await uploadXhr( + { + url, + method: 'PUT', + headers: { Authorization: `Bearer ${token}` }, + body: file }, - signal: options?.signal - }) - - if (!response.ok) { - throw new Error('Failed to upload file') - } + options + ) } } -async function uploadXhr ( - url: string, - headers: Record, - uuid: string, - file: File | Blob, - options?: FileStorageUploadOptions -): Promise { - const xhr = new XMLHttpRequest() +interface XHRUpload { + url: string + method: 'POST' | 'PUT' + headers: Record + body: XMLHttpRequestBodyInit +} + +interface XHRUploadResult { + status: number +} + +async function uploadXhr (upload: XHRUpload, options?: FileStorageUploadOptions): Promise { const signal = options?.signal const onProgress = options?.onProgress - await new Promise((resolve, reject) => { + return await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + if (signal !== undefined) { signal.onabort = () => { xhr.abort() @@ -207,18 +234,20 @@ async function uploadXhr ( } xhr.upload.onprogress = (event) => { - if (event.lengthComputable && onProgress !== undefined) { - onProgress({ + if (event.lengthComputable) { + onProgress?.({ loaded: event.loaded, total: event.total, - percentage: Math.round((event.loaded * 100) / event.total) + percentage: Math.round((100 * event.loaded) / event.total) }) } } xhr.onload = async () => { if (xhr.status >= 200 && xhr.status < 300) { - resolve() + resolve({ + status: xhr.status + }) } else { const error = new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`) reject(error) @@ -226,23 +255,24 @@ async function uploadXhr ( } xhr.onerror = () => { - const error = new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`) - reject(error) + reject(new Error('Network error')) + } + + xhr.onabort = () => { + reject(new Error('Upload aborted')) } xhr.ontimeout = () => { - const error = new Error('Upload timeout') - reject(error) + reject(new Error('Upload timeout')) } - xhr.open('POST', url, true) - for (const key in headers) { - xhr.setRequestHeader(key, headers[key]) + xhr.open(upload.method, upload.url, true) + + for (const key in upload.headers) { + xhr.setRequestHeader(key, upload.headers[key]) } - const formData = new FormData() - formData.append('file', file, uuid) - xhr.send(formData) + xhr.send(upload.body) }) } diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index e4d02c176be..65f4732d28b 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -12,7 +12,8 @@ import { type Space, type TxOperations, type BlobMetadata, - type AccountRole + type AccountRole, + WorkspaceUuid } from '@hcengineering/core' import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { type AnyComponent, type AnySvelteComponent, type ComponentExtensionId } from '@hcengineering/ui/src/types' diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index ff525b327dc..645fbf974a5 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -65,7 +65,7 @@ import { deepEqual } from 'fast-equals' import { onDestroy } from 'svelte' import { get, writable } from 'svelte/store' -import { type KeyedAttribute } from '..' +import { getFileStorage, type KeyedAttribute } from '..' import { OptimizeQueryMiddleware, type PresentationPipeline, PresentationPipelineImpl } from './pipeline' import plugin, { type ClientHook } from './plugin' @@ -891,7 +891,7 @@ export function setPresentationCookie (token: string, workspaceUuid: WorkspaceUu `; path=${path}` document.cookie = res } - setToken('/files/' + workspaceUuid) + setToken('/' + workspaceUuid) } export const upgradeDownloadProgress = writable(-1) From 920c2c28907c7f7d5b6ae443b1aee275f2deaaed Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Fri, 10 Oct 2025 22:30:46 +0700 Subject: [PATCH 3/7] enhance xhr upload Signed-off-by: Alexander Onnikov --- packages/presentation/src/storage.ts | 120 +++++++++++++++------------ 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/packages/presentation/src/storage.ts b/packages/presentation/src/storage.ts index fdf1200cb0c..a2b64bcc987 100644 --- a/packages/presentation/src/storage.ts +++ b/packages/presentation/src/storage.ts @@ -47,7 +47,7 @@ class FrontStorage implements FileStorage { getFileUrl (file: string, filename?: string): string { const workspace = getWorkspace() - const path = `/${workspace}/${filename}?file=${file}&workspace=${workspace}` + const path = `/${workspace}/${filename ?? file}?file=${file}&workspace=${workspace}` return concatLink(this.baseUrl, path) } @@ -158,7 +158,7 @@ class DatalakeStorage implements FileStorage { `/upload/multipart/${encodeURIComponent(workspace)}/${encodeURIComponent(uuid)}` ) const headers = { Authorization: `Bearer ${token}` } - await uploadMultipart(url, headers, uuid, file, options) + await uploadMultipart({ url, headers, body: file }, options) } } } @@ -223,67 +223,79 @@ async function uploadXhr (upload: XHRUpload, options?: FileStorageUploadOptions) const signal = options?.signal const onProgress = options?.onProgress - return await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() + // Check if already aborted before starting + if (signal?.aborted === true) { + throw new Error('Upload aborted') + } - if (signal !== undefined) { - signal.onabort = () => { - xhr.abort() - reject(new Error('Upload aborted')) + const xhr = new XMLHttpRequest() + + const abortHandler = (): void => { + xhr.abort() + } + + if (signal !== undefined) { + signal.addEventListener('abort', abortHandler) + } + + try { + return await new Promise((resolve, reject) => { + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.({ + loaded: event.loaded, + total: event.total, + percentage: Math.round((100 * event.loaded) / event.total) + }) + } } - } - xhr.upload.onprogress = (event) => { - if (event.lengthComputable) { - onProgress?.({ - loaded: event.loaded, - total: event.total, - percentage: Math.round((100 * event.loaded) / event.total) - }) + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve({ + status: xhr.status + }) + } else { + reject(new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`)) + } } - } - xhr.onload = async () => { - if (xhr.status >= 200 && xhr.status < 300) { - resolve({ - status: xhr.status - }) - } else { - const error = new Error(`Upload failed with status ${xhr.status}: ${xhr.statusText}`) - reject(error) + xhr.onerror = () => { + reject(new Error('Network error')) } - } - xhr.onerror = () => { - reject(new Error('Network error')) - } + xhr.onabort = () => { + reject(new Error('Upload aborted')) + } - xhr.onabort = () => { - reject(new Error('Upload aborted')) - } + xhr.ontimeout = () => { + reject(new Error('Upload timeout')) + } - xhr.ontimeout = () => { - reject(new Error('Upload timeout')) - } + xhr.open(upload.method, upload.url, true) - xhr.open(upload.method, upload.url, true) + for (const key in upload.headers) { + xhr.setRequestHeader(key, upload.headers[key]) + } - for (const key in upload.headers) { - xhr.setRequestHeader(key, upload.headers[key]) + xhr.send(upload.body) + }) + } finally { + if (signal !== undefined) { + signal.removeEventListener('abort', abortHandler) } + } +} - xhr.send(upload.body) - }) +interface MultipartUpload { + url: string + headers: Record + body: File | Blob } -async function uploadMultipart ( - url: string, - headers: Record, - uuid: string, - file: File | Blob, - options?: FileStorageUploadOptions -): Promise { +async function uploadMultipart (upload: MultipartUpload, options?: FileStorageUploadOptions): Promise { const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB chunks + const { url, headers, body } = upload const signal = options?.signal const onProgress = options?.onProgress @@ -292,15 +304,14 @@ async function uploadMultipart ( try { const { uploadId } = await multipartUploadCreate(url, headers, signal) - // 2. Upload parts const parts: Array<{ partNumber: number, etag: string }> = [] - const totalParts = Math.ceil(file.size / CHUNK_SIZE) + const totalParts = Math.ceil(body.size / CHUNK_SIZE) let uploadedSize = 0 for (let partNumber = 1; partNumber <= totalParts; partNumber++) { const start = (partNumber - 1) * CHUNK_SIZE - const end = Math.min(start + CHUNK_SIZE, file.size) - const chunk = file.slice(start, end) + const end = Math.min(start + CHUNK_SIZE, body.size) + const chunk = body.slice(start, end) throwIfAborted(signal) @@ -310,8 +321,8 @@ async function uploadMultipart ( uploadedSize += chunk.size onProgress?.({ loaded: uploadedSize, - total: file.size, - percentage: Math.round((uploadedSize * 100) / file.size) + total: body.size, + percentage: Math.round((uploadedSize * 100) / body.size) }) } @@ -323,8 +334,7 @@ async function uploadMultipart ( await multipartUploadAbort(url, headers, uploadId) } - const error = err instanceof Error ? err : new Error(String(err)) - throw error + throw err instanceof Error ? err : new Error(String(err)) } } From d1a1082bdd3f62fd6b4b9d0d3d35d8e8e4f95f61 Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Tue, 14 Oct 2025 10:43:47 +0700 Subject: [PATCH 4/7] refactor uploader Signed-off-by: Alexander Onnikov --- .vscode/launch.json | 3 +- dev/prod/public/config-dev.json | 4 +- dev/prod/public/config-huly.json | 2 - dev/prod/public/config-worker.json | 5 - packages/presentation/src/file.ts | 10 +- packages/presentation/src/preview.ts | 119 ++++---- packages/presentation/src/storage.ts | 3 +- packages/presentation/src/types.ts | 3 +- packages/presentation/src/utils.ts | 2 +- .../src/components/AttachmentPopup.svelte | 5 +- .../src/components/AttachmentRefInput.svelte | 6 +- .../AttachmentStyleBoxCollabEditor.svelte | 12 +- .../src/components/AttachmentStyledBox.svelte | 4 +- .../src/components/Attachments.svelte | 3 +- .../src/components/Photos.svelte | 5 +- plugins/attachment-resources/src/utils.ts | 15 +- plugins/attachment/src/index.ts | 2 +- .../src/components/Description.svelte | 4 +- .../src/components/FilePlaceholder.svelte | 5 +- .../components/message/MessageInput.svelte | 8 +- .../src/components/EditableAvatar.svelte | 3 +- .../components/document/EditDocContent.svelte | 5 +- .../src/components/EditDoc.svelte | 5 +- .../src/components/FilePanel.svelte | 9 +- plugins/drive-resources/src/utils.ts | 9 +- .../settings/CreateCustomEmojiPopup.svelte | 2 +- .../src/components/NewMessage.svelte | 5 +- .../src/components/NewMessages.svelte | 5 +- .../src/components/RecordingPopup.svelte | 2 + .../src/components/CreateCandidate.svelte | 3 +- .../src/components/FileUploadStatusBar.svelte | 2 +- .../components/FileUploadStatusPopup.svelte | 107 ++++--- plugins/uploader-resources/src/store.ts | 27 +- plugins/uploader-resources/src/utils.ts | 274 ++++++++++-------- plugins/uploader/src/types.ts | 38 ++- server/front/readme.md | 13 - server/front/src/index.ts | 6 - server/front/src/starter.ts | 19 -- tests/docker-compose.override.yaml | 5 +- 39 files changed, 357 insertions(+), 402 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index b9f801d1192..b67507b5763 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -107,7 +107,6 @@ "QUEUE_CONFIG": "huly.local:19092", "REGION": "cockroach", "QUEUE_REGION": "cockroach", - "FILES_URL": "http://huly.local:4030/blob/:workspace/:blobId/:filename", "OTEL_EXPORTER_OTLP_ENDPOINT": "http://huly.local:4318/v1/traces", "BRANDING_PATH": "${workspaceRoot}/dev/branding.json" }, @@ -833,7 +832,7 @@ "SECRET": "secret", "DB_URL": "postgresql://root@localhost:26257/defaultdb?sslmode=disable", "BUCKETS": "blobs,eu|http://localhost:9000?accessKey=minioadmin&secretKey=minioadmin", - "ACCOUNTS_URL": "http://localhost:3000", + "ACCOUNTS_URL": "http://huly.local:3000", "STATS_URL": "http://huly.local:4900", "QUEUE_CONFIG": "localhost:19092" }, diff --git a/dev/prod/public/config-dev.json b/dev/prod/public/config-dev.json index 38e66f03420..99b786f226d 100644 --- a/dev/prod/public/config-dev.json +++ b/dev/prod/public/config-dev.json @@ -12,8 +12,8 @@ "PUBLIC_SCHEDULE_URL": "https://schedule.hc.engineering", "CALDAV_SERVER_URL": "https://caldav.hc.engineering", "BACKUP_URL": "https://front.hc.engineering/api/backup", + "DATALAKE_URL": "https://datalake.hc.engineering", "HULYLAKE_URL": "https://lake.hc.engineering", "PULSE_URL": "wss://pulse.hc.engineering/ws", - "COMMUNICATION_API_ENABLED": "true", - "FILES_URL": "https://datalake.hc.engineering/blob/:workspace/:blobId/:filename" + "COMMUNICATION_API_ENABLED": "true" } \ No newline at end of file diff --git a/dev/prod/public/config-huly.json b/dev/prod/public/config-huly.json index a7661a88e69..4a106051299 100644 --- a/dev/prod/public/config-huly.json +++ b/dev/prod/public/config-huly.json @@ -1,14 +1,12 @@ { "ACCOUNTS_URL": "https://account.huly.app/", "UPLOAD_URL": "/files", - "FILES_URL": "/files/:workspace/:filename?file=:blobId&workspace=:workspace", "REKONI_URL": "https://rekoni.huly.app", "TELEGRAM_URL": "https://telegram.huly.app", "GMAIL_URL": "https://gmail.huly.app/", "CALENDAR_URL": "https://calendar.huly.app/", "COLLABORATOR_URL": "wss://collaborator.huly.app", "BRANDING_URL": "https://huly.app/huly-branding_v2.json", - "PREVIEW_CONFIG": "https://huly.app/files/:workspace?file=:blobId&size=:size", "GITHUB_APP": "huly-for-github", "GITHUB_CLIENTID": "Iv1.e263a087de0910e0", "INTERCOM_APP_ID": "", diff --git a/dev/prod/public/config-worker.json b/dev/prod/public/config-worker.json index 3fd5bb9b6c0..886e3ccb9cc 100644 --- a/dev/prod/public/config-worker.json +++ b/dev/prod/public/config-worker.json @@ -6,7 +6,6 @@ "CALENDAR_URL": "https://calendar.hc.engineering/", "COLLABORATOR_URL": "wss://collaborator.hc.engineering", "DESKTOP_UPDATES_CHANNEL": "front", - "FILES_URL": "https://dl.hc.engineering/blob/:workspace/:blobId/:filename", "GITHUB_APP": "huly-github-staging", "GITHUB_CLIENTID": "Iv1.cd9d3f7987474b5e", "GITHUB_URL": "https://github.hc.engineering", @@ -15,16 +14,12 @@ "INTERCOM_APP_ID": "", "LOVE_ENDPOINT": "https://love.hc.engineering/", "MODEL_VERSION": "", - - "PREVIEW_CONFIG": "image|https://dl.hc.engineering/image/fit=scale-down,width=:width,height=:height,dpr=:dpr/:workspace/:blobId;video|https://dl.hc.engineering/video/:workspace/:blobId/meta", - "PRINT_URL": "https://print.hc.engineering", "REKONI_URL": "https://rekoni.hc.engineering", "SIGN_URL": "https://sign.hc.engineering", "STATS_URL": "https://stats.hc.engineering", "TELEGRAM_BOT_URL": "https://telegram-bot.hc.engineering", "TELEGRAM_URL": "https://telegram.hc.engineering", - "UPLOAD_CONFIG": "", "UPLOAD_URL": "https://dl.hc.engineering/upload/form-data/:workspace", "TRANSACTOR_OVERRIDE": "ws://localhost:3335" } \ No newline at end of file diff --git a/packages/presentation/src/file.ts b/packages/presentation/src/file.ts index 72f90c8e435..bf319945eed 100644 --- a/packages/presentation/src/file.ts +++ b/packages/presentation/src/file.ts @@ -19,6 +19,7 @@ import { v4 as uuid } from 'uuid' import plugin from './plugin' import { type FileStorage } from './types' +import { getFileMetadata } from './filetypes' export function getCurrentWorkspaceUuid (): WorkspaceUuid { const workspaceUuid = getMetadata(plugin.metadata.WorkspaceUuid) ?? '' @@ -51,13 +52,18 @@ export function getFileUrl (file: string, filename?: string): string { } /** @public */ -export async function uploadFile (file: File, uuid?: Ref): Promise> { +export async function uploadFile ( + file: File, + uuid?: Ref +): Promise<{ uuid: Ref, metadata: Record }> { uuid ??= generateFileId() as Ref const storage = getFileStorage() await storage.uploadFile(uuid, file) - return uuid + const metadata = (await getFileMetadata(file, uuid)) ?? {} + + return { uuid, metadata } } /** @public */ diff --git a/packages/presentation/src/preview.ts b/packages/presentation/src/preview.ts index ca693a5e596..e4b0c2cdbbb 100644 --- a/packages/presentation/src/preview.ts +++ b/packages/presentation/src/preview.ts @@ -1,12 +1,13 @@ -import { type Blob, type Ref, concatLink } from '@hcengineering/core' +import type { Blob, Ref } from '@hcengineering/core' +import { concatLink } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' import { withRetry } from '@hcengineering/retry' -import { getFileUrl, getFileStorage, getCurrentWorkspaceUuid } from './file' -import { type FileStorage } from './types' - +import { getFileUrl, getCurrentWorkspaceUuid, getFileStorage } from './file' import presentation from './plugin' +const frontImagePreviewUrl = '/files/:workspace?file=:blobId&size=:size' + export interface PreviewMetadata { thumbnail?: { width: number @@ -15,10 +16,6 @@ export interface PreviewMetadata { } } -export interface PreviewConfig { - image: string - video: string -} export interface VideoMeta { hls?: HLSMeta } @@ -48,43 +45,48 @@ export async function getBlobSrcSet (file: Ref, width?: number, height?: n } export function getSrcSet (_blob: Ref, width?: number, height?: number): string { - const fileStorage = getFileStorage() - return blobToSrcSet(fileStorage, _blob, width, height) + return blobToSrcSet(_blob, width, height) } -function blobToSrcSet ( - fileStorage: FileStorage, - blob: Ref, - width: number | undefined, - height: number | undefined -): string { +function blobToSrcSet (blob: Ref, width: number | undefined, height: number | undefined): string { if (blob.includes('://')) { return '' } - return getFileUrl(blob) - - // let url = cfg.image.replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) - // const downloadUrl = getFileUrl(blob) - - // const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin - // if (!url.includes('://')) { - // url = concatLink(frontUrl ?? '', url) - // } - // url = url.replaceAll(':downloadFile', encodeURIComponent(downloadUrl)) - // url = url.replaceAll(':blobId', encodeURIComponent(blob)) - - // let result = '' - // if (width !== undefined) { - // result += - // fileStorage.getImageUrl(blob, { width, height: height ?? width, dpr: 1 }) + - // ' 1x , ' + - // fileStorage.getImageUrl(blob, { width, height: height ?? width, dpr: 2 }) + - // ' 2x, ' + - // fileStorage.getImageUrl(blob, { width, height: height ?? width, dpr: 3 }) + - // ' 3x' - // } - - // return result + + const workspace = encodeURIComponent(getCurrentWorkspaceUuid()) + const name = encodeURIComponent(blob) + + const previewUrl = getMetadata(presentation.metadata.PreviewUrl) ?? '' + if (previewUrl !== '') { + if (width !== undefined) { + return ( + getImagePreviewUrl(workspace, name, width, height ?? width, 1) + + ' 1x , ' + + getImagePreviewUrl(workspace, name, width, height ?? width, 2) + + ' 2x, ' + + getImagePreviewUrl(workspace, name, width, height ?? width, 3) + + ' 3x' + ) + } else { + return '' + } + } + + const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin + const url = concatLink(frontUrl, frontImagePreviewUrl).replaceAll(':workspace', workspace).replaceAll(':blobId', name) + + let result = '' + if (width !== undefined) { + result += + formatImageSize(url, width, height ?? width, 1) + + ' 1x , ' + + formatImageSize(url, width, height ?? width, 2) + + ' 2x, ' + + formatImageSize(url, width, height ?? width, 3) + + ' 3x' + } + + return result } export function getPreviewThumbnail (file: string, width: number, height: number, dpr?: number): string { @@ -137,39 +139,18 @@ function formatImageSize (url: string, width: number, height: number, dpr: numbe * @deprecated, please use Blob direct operations. */ export function getFileSrcSet (_blob: Ref, width?: number, height?: number): string { - return blobToSrcSet(getFileStorage(), _blob, width, height) + return blobToSrcSet(_blob, width, height) } /** * @public */ export async function getVideoMeta (file: string, filename?: string): Promise { - // const cfg = getPreviewConfig() - - // let url = cfg.video - // .replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) - // .replaceAll(':blobId', encodeURIComponent(file)) - - // if (url === '') { - // return undefined - // } - - // const token = getMetadata(presentation.metadata.Token) ?? '' - // const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin - // if (!url.includes('://')) { - // url = concatLink(frontUrl ?? '', url) - // } - - // try { - // const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) - // if (response.ok) { - // const result = (await response.json()) as VideoMeta - // if (result.hls !== undefined) { - // result.hls.source = getBlobUrl(result.hls.source ?? '') - // result.hls.thumbnail = getBlobUrl(result.hls.thumbnail ?? '') - // } - // return result - // } - // } catch {} - return undefined + try { + const storage = getFileStorage() + const meta = await storage.getFileMeta(file) + return meta as VideoMeta + } catch { + return {} + } } diff --git a/packages/presentation/src/storage.ts b/packages/presentation/src/storage.ts index a2b64bcc987..d329be6325c 100644 --- a/packages/presentation/src/storage.ts +++ b/packages/presentation/src/storage.ts @@ -140,8 +140,7 @@ class DatalakeStorage implements FileStorage { if (file.size <= 10 * 1024 * 1024) { const formData = new FormData() - formData.append('file', file) - formData.append('uuid', uuid) + formData.append('file', file, uuid) await uploadXhr( { diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index 65f4732d28b..e4d02c176be 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -12,8 +12,7 @@ import { type Space, type TxOperations, type BlobMetadata, - type AccountRole, - WorkspaceUuid + type AccountRole } from '@hcengineering/core' import { type Asset, type IntlString, type Resource } from '@hcengineering/platform' import { type AnyComponent, type AnySvelteComponent, type ComponentExtensionId } from '@hcengineering/ui/src/types' diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index 645fbf974a5..27eaf346cc5 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -65,7 +65,7 @@ import { deepEqual } from 'fast-equals' import { onDestroy } from 'svelte' import { get, writable } from 'svelte/store' -import { getFileStorage, type KeyedAttribute } from '..' +import { type KeyedAttribute } from '..' import { OptimizeQueryMiddleware, type PresentationPipeline, PresentationPipelineImpl } from './pipeline' import plugin, { type ClientHook } from './plugin' diff --git a/plugins/attachment-resources/src/components/AttachmentPopup.svelte b/plugins/attachment-resources/src/components/AttachmentPopup.svelte index 6c17b87ec5f..799ccce04fd 100644 --- a/plugins/attachment-resources/src/components/AttachmentPopup.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPopup.svelte @@ -15,7 +15,7 @@ -->