diff --git a/.vscode/launch.json b/.vscode/launch.json index b9f801d1192..093e6e3c592 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -833,7 +833,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/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index dbe07c961b5..794ec70b31a 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -145,6 +145,9 @@ importers: '@hcengineering/storage': specifier: ^0.7.5 version: 0.7.5 + '@hcengineering/storage-client': + specifier: ^0.7.6 + version: 0.7.6 '@hcengineering/text': specifier: ^0.7.5 version: 0.7.5(prosemirror-inputrules@1.4.0)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2) @@ -3241,6 +3244,9 @@ packages: '@hcengineering/server@0.7.5': resolution: {integrity: sha512-N8XDY3dPnMNqvyl/wwwa7luJ/TiOGDazekAuYi4VWfdyRt5X43lWjSVkQxhZmyFMIc1Ccmhfc0tyv1oEn++4xg==} + '@hcengineering/storage-client@0.7.6': + resolution: {integrity: sha512-+WgE5buPwcP2aTc0BiokM1rgDA0RXALL0GckVXb0haZnKJFHSAd8wT34Yf2F20Cwc0sSaRGeS57H9Vz2YokHGw==} + '@hcengineering/storage@0.7.5': resolution: {integrity: sha512-E7PiVruvqVhxUDqMEmsTOKu9knVeElyyU796wbRE5SEFOd9MY0p2lLr7vcwmbc0U/NPA+JD5NWHZwoTmdvBcgg==} @@ -5701,7 +5707,7 @@ packages: version: 0.0.0 '@rush-temp/presentation@file:projects/presentation.tgz': - resolution: {integrity: sha512-BeyUrrJQ0fcJqY271gqIIulRq1jKDnzEL+wNhbGbDvAXm0JUIcTsbX88LjYHLcRHaJ7k223iGhdctQpiOEY5Bg==, tarball: file:projects/presentation.tgz} + resolution: {integrity: sha512-Bc77dEEqJ5ktzx8GnxQItwE6jfyGmWlGS7PCPBmyelM88/BK55DnZ6EkkSJJqVVT6SeirFTEVa7cP+yAlNEmKQ==, tarball: file:projects/presentation.tgz} version: 0.0.0 '@rush-temp/print-assets@file:projects/print-assets.tgz': @@ -15878,6 +15884,10 @@ snapshots: '@hcengineering/server-token': 0.7.5 utf-8-validate: 6.0.4 + '@hcengineering/storage-client@0.7.6': + dependencies: + '@hcengineering/core': 0.7.7 + '@hcengineering/storage@0.7.5': dependencies: '@hcengineering/core': 0.7.7 @@ -28972,6 +28982,7 @@ snapshots: '@hcengineering/platform-rig': 0.7.19(@babel/core@7.23.9)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.9.3)))(postcss@8.5.3)(sass@1.93.2)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.15.29)(typescript@5.9.3)) '@hcengineering/query': 0.7.6 '@hcengineering/retry': 0.7.5 + '@hcengineering/storage-client': 0.7.6 '@hcengineering/text': 0.7.5(prosemirror-inputrules@1.4.0)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2) '@testing-library/jest-dom': 6.6.3 '@types/jest': 29.5.12 diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 1f2e747293f..3f41a17982a 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,11 @@ 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 { getFileMetadata } from './filetypes' export function getCurrentWorkspaceUuid (): WorkspaceUuid { const workspaceUuid = getMetadata(plugin.metadata.WorkspaceUuid) ?? '' return workspaceUuid as WorkspaceUuid } -/** - * @public - */ -export function generateFileId (): string { - return uuid() +function getToken (): string { + return getMetadata(plugin.metadata.Token) ?? '' } -/** - * @public - */ -export function getUploadUrl (): string { - const template = getMetadata(plugin.metadata.UploadURL) ?? defaultUploadUrl - - return template.replaceAll(':workspace', encodeURIComponent(getCurrentWorkspaceUuid())) +/** @public */ +export function generateFileId (): string { + return uuid() } -function getUploadConfig (): UploadConfig { - return getMetadata(plugin.metadata.UploadConfig) ?? { 'form-data': { url: getUploadUrl() } } +/** @public */ +export function createFileStorage (uploadUrl: string, datalakeUrl?: string, hulylakeUrl?: string): FileStorage { + return createStorageClient({ uploadUrl, datalakeUrl, hulylakeUrl }) } -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)) -} - -/** - * @public - */ -export async function uploadFile (file: File, uuid?: Ref): Promise> { - uuid ??= generateFileId() as Ref - - const params = getFileUploadParams(uuid, file) + const workspace = getCurrentWorkspaceUuid() - if (params.method === 'signed-url') { - await uploadFileWithSignedUrl(file, uuid, params.url) - } else { - await uploadFileWithFormData(file, uuid, params.url) - } - - return uuid + const storage = getFileStorage() + return storage.getFileUrl(workspace, file, filename) } -/** - * @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) +/** @public */ +export async function uploadFile ( + file: File, + uuid?: Ref +): Promise<{ uuid: Ref, metadata: Record }> { + uuid ??= generateFileId() as Ref - const resp = await fetch(uploadUrl, { - method: 'POST', - headers: { - Authorization: 'Bearer ' + (getMetadata(plugin.metadata.Token) as string) - }, - body: data - }) + const token = getToken() + const workspace = getCurrentWorkspaceUuid() - 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 storage = getFileStorage() + await storage.uploadFile(token, workspace, uuid, file) - const result = (await resp.json()) as FileUploadResult[] - if (result.length !== 1) { - throw Error('Bad upload response') - } + const metadata = (await getFileMetadata(file, uuid)) ?? {} - if ('error' in result[0]) { - throw Error(`Failed to upload file: ${result[0].error}`) - } + return { uuid, metadata } } -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) - } - }) +/** @public */ +export async function deleteFile (file: string): Promise { + const token = getToken() + const workspace = getCurrentWorkspaceUuid() - 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) - } - }) - } + const storage = getFileStorage() + await storage.deleteFile(token, workspace, file) } export async function getJsonOrEmpty (file: string, name: string): Promise { diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index b52c3d78930..952c76f0bcf 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -38,10 +38,9 @@ import { } from '@hcengineering/core' import type { Asset, IntlString, Metadata, Plugin, StatusCode } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' +import { type FileStorage } from '@hcengineering/storage-client' 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, @@ -51,6 +50,8 @@ import { type ObjectSearchCategory } from './types' +export type { FileStorage } from '@hcengineering/storage-client' + /** * @public */ @@ -165,7 +166,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 +174,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..15cdc2f5727 100644 --- a/packages/presentation/src/preview.ts +++ b/packages/presentation/src/preview.ts @@ -3,9 +3,11 @@ import { concatLink } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' import { withRetry } from '@hcengineering/retry' -import { getFileUrl, getCurrentWorkspaceUuid } from './file' +import { getFileUrl, getCurrentWorkspaceUuid, getFileStorage } from './file' import presentation from './plugin' +const frontImagePreviewUrl = '/files/:workspace?file=:blobId&size=:size' + export interface PreviewMetadata { thumbnail?: { width: number @@ -14,11 +16,6 @@ export interface PreviewMetadata { } } -export interface PreviewConfig { - image: string - video: string -} - export interface VideoMeta { hls?: HLSMeta } @@ -28,50 +25,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,15 +45,10 @@ 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) + return blobToSrcSet(_blob, width, height) } -function blobToSrcSet ( - cfg: PreviewConfig, - blob: Ref, - width: number | undefined, - height: number | undefined -): string { +function blobToSrcSet (blob: Ref, width: number | undefined, height: number | undefined): string { if (blob.includes('://')) { return '' } @@ -124,15 +72,8 @@ function blobToSrcSet ( } } - 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', name) + const url = concatLink(frontUrl, frontImagePreviewUrl).replaceAll(':workspace', workspace).replaceAll(':blobId', name) let result = '' if (width !== undefined) { @@ -198,33 +139,21 @@ 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(_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 - } + try { + const token = getMetadata(presentation.metadata.Token) ?? '' + const workspace = getCurrentWorkspaceUuid() - const token = getMetadata(presentation.metadata.Token) ?? '' - const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin - if (!url.includes('://')) { - url = concatLink(frontUrl ?? '', url) + const storage = getFileStorage() + const meta = await storage.getFileMeta(token, workspace, file) + return meta as VideoMeta + } catch { + return {} } - - try { - const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }) - if (response.ok) { - return (await response.json()) as VideoMeta - } - } catch {} } 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 @@ -->