mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-26 19:44:57 +02:00
feat(print): add optional paginated DOCX previews (#11036)
* feat(print): add optional paginated DOCX previews Signed-off-by: Ratel-pwn <2448574643@qq.com> * chore(print): align preview changes with repository checks Signed-off-by: Ratel-pwn <2448574643@qq.com> * style(print): normalize preview formatting Signed-off-by: Ratel-pwn <2448574643@qq.com> --------- Signed-off-by: Ratel-pwn <2448574643@qq.com>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Stáhnout vše",
|
||||
"PrintFailed": "Tisk se nezdařil",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Zkusit znovu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Alle herunterladen",
|
||||
"PrintFailed": "Druck fehlgeschlagen",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Wiederholen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Download all",
|
||||
"PrintFailed": "Print failed",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Retry"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Descargar todo",
|
||||
"PrintFailed": "Error al imprimir",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Reintentar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Tout télécharger",
|
||||
"PrintFailed": "Échec de l'impression",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Réessayer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Scarica tutto",
|
||||
"PrintFailed": "Stampa non riuscita",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Riprova"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "すべてダウンロード",
|
||||
"PrintFailed": "印刷に失敗しました",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "再試行"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "모두 다운로드",
|
||||
"PrintFailed": "인쇄 실패",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "다시 시도"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Pobierz wszystkie",
|
||||
"PrintFailed": "Wydruk nie powiódł się",
|
||||
"PrintSettings": "Ustawienia wydruku",
|
||||
"LandscapeMode": "Orientacja pozioma"
|
||||
"LandscapeMode": "Orientacja pozioma",
|
||||
"Retry": "Ponów"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Baixar tudo",
|
||||
"PrintFailed": "Falha na impressão",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Tentar novamente"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Descarregar tudo",
|
||||
"PrintFailed": "Falha na impressão",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Reintentar"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Скачать все",
|
||||
"PrintFailed": "Ошибка печати",
|
||||
"PrintSettings": "Настройки печати",
|
||||
"LandscapeMode": "Ландшафтный режим"
|
||||
"LandscapeMode": "Ландшафтный режим",
|
||||
"Retry": "Повторить"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "Tümünü indir",
|
||||
"PrintFailed": "Yazdırma başarısız",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "Tekrar Dene"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"DownloadAll": "全部下载",
|
||||
"PrintFailed": "打印失败",
|
||||
"PrintSettings": "Print settings",
|
||||
"LandscapeMode": "Landscape mode"
|
||||
"LandscapeMode": "Landscape mode",
|
||||
"Retry": "重试"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
import { type Blob, type BlobMetadata, type Ref } from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation, { getFileUrl } from '@hcengineering/presentation'
|
||||
import { convertToHTML } from '@hcengineering/print'
|
||||
import { EmbeddedHTML, Spinner, themeStore } from '@hcengineering/ui'
|
||||
import print, { convertForPreview, type ConvertedPreview } from '@hcengineering/print'
|
||||
import { Button, EmbeddedHTML, EmbeddedPDF, Label, Spinner, themeStore } from '@hcengineering/ui'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
export let value: Ref<Blob>
|
||||
export let name: string
|
||||
@@ -17,27 +18,49 @@
|
||||
export let metadata: BlobMetadata | undefined
|
||||
|
||||
let isLoading = true
|
||||
let convertedFile: string | undefined
|
||||
let failed = false
|
||||
let convertedFile: ConvertedPreview | undefined
|
||||
let request: AbortController | undefined
|
||||
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
$: if (value !== undefined) {
|
||||
async function loadPreview (file: Ref<Blob> | undefined): Promise<void> {
|
||||
request?.abort()
|
||||
const controller = new AbortController()
|
||||
request = controller
|
||||
isLoading = true
|
||||
failed = false
|
||||
convertedFile = undefined
|
||||
|
||||
convertToHTML(value, token).then(
|
||||
(res) => {
|
||||
convertedFile = res
|
||||
isLoading = false
|
||||
},
|
||||
(err: any) => {
|
||||
try {
|
||||
if (file === undefined) {
|
||||
throw new Error('Missing document')
|
||||
}
|
||||
const result = await convertForPreview(file, token, controller.signal)
|
||||
if (!controller.signal.aborted && file === value) {
|
||||
convertedFile = result
|
||||
}
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted && file === value) {
|
||||
failed = true
|
||||
Analytics.handleError(err)
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted && file === value) {
|
||||
isLoading = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
$: src = convertedFile === undefined ? '' : getFileUrl(convertedFile as Ref<Blob>, name)
|
||||
$: void loadPreview(value)
|
||||
|
||||
onDestroy(() => {
|
||||
request?.abort()
|
||||
})
|
||||
|
||||
$: previewName = convertedFile?.contentType === 'application/pdf' ? name.replace(/\.docx$/i, '') + '.pdf' : name
|
||||
$: src = convertedFile === undefined ? '' : getFileUrl(convertedFile.id as Ref<Blob>, previewName)
|
||||
$: originalSrc = value === undefined ? '' : getFileUrl(value, name)
|
||||
|
||||
$: colors = $themeStore.dark
|
||||
? `
|
||||
@@ -264,26 +287,43 @@
|
||||
`
|
||||
</script>
|
||||
|
||||
{#if src}
|
||||
{#if isLoading}
|
||||
<div class="centered">
|
||||
<Spinner size="medium" />
|
||||
{#if isLoading}
|
||||
<div class="centered">
|
||||
<Spinner size="medium" />
|
||||
</div>
|
||||
{:else if failed}
|
||||
<div class="centered failed">
|
||||
<Label label={presentation.string.FailedToPreview} />
|
||||
<div class="flex-row-center flex-gap-2">
|
||||
<Button label={print.string.Retry} on:click={() => loadPreview(value)} />
|
||||
{#if originalSrc}
|
||||
<a href={originalSrc} download={name}>
|
||||
<Label label={presentation.string.DownloadOriginal} />
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<EmbeddedHTML {src} {name} {css} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if src}
|
||||
{#key src}
|
||||
{#if convertedFile?.contentType === 'application/pdf'}
|
||||
<EmbeddedPDF {src} name={previewName} />
|
||||
{:else}
|
||||
<EmbeddedHTML {src} {name} {css} />
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
iframe {
|
||||
border: none;
|
||||
}
|
||||
.centered {
|
||||
flex-grow: 1;
|
||||
width: 100;
|
||||
height: 100;
|
||||
width: 100%;
|
||||
min-height: 20rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.failed {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// Copyright © 2026 Hardcore Engineering Inc.
|
||||
//
|
||||
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { convertForPreview, convertToHTML } from '../utils'
|
||||
|
||||
jest.mock('@hcengineering/platform', () => ({ getMetadata: jest.fn() }), { virtual: true })
|
||||
jest.mock('../plugin', () => ({ __esModule: true, default: { metadata: { PrintURL: 'print-url' } } }))
|
||||
|
||||
describe('DOCX preview conversion', () => {
|
||||
const fetchMock = jest.fn()
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
globalThis.fetch = fetchMock
|
||||
jest.mocked(getMetadata).mockReturnValue('https://print.example')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
function respond (body: unknown, status = 200): void {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify(body), { status }))
|
||||
}
|
||||
|
||||
it.each(['application/pdf', 'text/html'] as const)('returns a validated %s preview', async (contentType) => {
|
||||
respond({ id: 'preview-id', contentType })
|
||||
const controller = new AbortController()
|
||||
|
||||
await expect(convertForPreview('source-id', 'workspace-token', controller.signal)).resolves.toEqual({
|
||||
id: 'preview-id',
|
||||
contentType
|
||||
})
|
||||
const [url, options] = fetchMock.mock.calls[0]
|
||||
expect(url.toString()).toBe('https://print.example/convert/source-id?format=preview')
|
||||
expect(options).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer workspace-token', Accept: 'application/json' },
|
||||
signal: controller.signal
|
||||
})
|
||||
})
|
||||
|
||||
it('treats the legacy response as HTML', async () => {
|
||||
respond({ id: 'legacy-id' })
|
||||
await expect(convertForPreview('source-id', 'token')).resolves.toEqual({
|
||||
id: 'legacy-id',
|
||||
contentType: 'text/html'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{},
|
||||
null,
|
||||
{ id: '' },
|
||||
{ id: ' ' },
|
||||
{ id: 123 },
|
||||
{ id: 'id', contentType: null },
|
||||
{ id: 'id', contentType: 'image/png' }
|
||||
])('rejects malformed preview metadata: %j', async (body) => {
|
||||
respond(body)
|
||||
await expect(convertForPreview('source-id', 'token')).rejects.toThrow('Invalid preview response')
|
||||
})
|
||||
|
||||
it('rejects a missing token without requesting conversion', async () => {
|
||||
await expect(convertForPreview('source-id', '')).rejects.toThrow('Missing authentication token')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed conversion even for a non-JSON service error', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('Gateway unavailable', { status: 503 }))
|
||||
await expect(convertForPreview('source-id', 'token')).rejects.toThrow('503')
|
||||
})
|
||||
|
||||
it('reports the service conversion error', async () => {
|
||||
respond({ message: 'Conversion queue is full' }, 503)
|
||||
await expect(convertForPreview('source-id', 'token')).rejects.toThrow('Conversion queue is full')
|
||||
})
|
||||
|
||||
it('rejects malformed success JSON', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('not json'))
|
||||
await expect(convertForPreview('source-id', 'token')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('preserves an aborted request rejection', async () => {
|
||||
const error = new DOMException('Aborted', 'AbortError')
|
||||
fetchMock.mockRejectedValue(error)
|
||||
await expect(convertForPreview('source-id', 'token')).rejects.toBe(error)
|
||||
})
|
||||
|
||||
it('requests HTML explicitly for existing callers', async () => {
|
||||
respond({ id: 'html-id', contentType: 'text/html' })
|
||||
await expect(convertToHTML('source-id', 'token')).resolves.toBe('html-id')
|
||||
expect(fetchMock.mock.calls[0][0].toString()).toBe('https://print.example/convert/source-id?format=html')
|
||||
})
|
||||
|
||||
it('preserves the empty-token behavior of the HTML helper', async () => {
|
||||
await expect(convertToHTML('source-id', '')).resolves.toBe('')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -15,7 +15,8 @@ export const print = plugin(printId, {
|
||||
DownloadAll: '' as IntlString,
|
||||
PrintFailed: '' as IntlString,
|
||||
PrintSettings: '' as IntlString,
|
||||
LandscapeMode: '' as IntlString
|
||||
LandscapeMode: '' as IntlString,
|
||||
Retry: '' as IntlString
|
||||
},
|
||||
component: {
|
||||
PrintToPDF: '' as AnyComponent,
|
||||
|
||||
@@ -51,12 +51,51 @@ export async function printToPDF (link: string, token: string, options?: PrintTo
|
||||
return res.id
|
||||
}
|
||||
|
||||
export interface ConvertedPreview {
|
||||
id: string
|
||||
contentType: 'application/pdf' | 'text/html'
|
||||
}
|
||||
|
||||
export async function convertForPreview (file: string, token: string, signal?: AbortSignal): Promise<ConvertedPreview> {
|
||||
if (token === '') {
|
||||
throw new Error('Missing authentication token')
|
||||
}
|
||||
|
||||
const url = new URL(`${getPrintBaseURL()}/convert/${file}`)
|
||||
url.searchParams.set('format', 'preview')
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => undefined)
|
||||
throw new Error(`Failed to convert preview: ${error?.message ?? response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
if (typeof result?.id !== 'string' || result.id.trim() === '') {
|
||||
throw new Error('Invalid preview response')
|
||||
}
|
||||
const contentType = result.contentType === undefined ? 'text/html' : result.contentType
|
||||
if (contentType !== 'application/pdf' && contentType !== 'text/html') {
|
||||
throw new Error('Invalid preview response')
|
||||
}
|
||||
|
||||
return { id: result.id, contentType }
|
||||
}
|
||||
|
||||
export async function convertToHTML (file: string, token: string): Promise<string> {
|
||||
if (token === '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const url: URL = new URL(`${getPrintBaseURL()}/convert/${file}`)
|
||||
url.searchParams.set('format', 'html')
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# DOCX preview
|
||||
|
||||
Drive can preview DOCX files with their page layout by converting a copy to PDF
|
||||
through Gotenberg / LibreOffice. The existing Huly PDF viewer displays the result;
|
||||
the original DOCX stays available for download and is never overwritten.
|
||||
|
||||
## Enable
|
||||
|
||||
Deploy the updated Print service and frontend together. Retain the Print service's
|
||||
existing `SECRET`, `ACCOUNTS_URL`, `FRONT_URL`, and storage configuration. Set the
|
||||
frontend's `PRINT_URL` to its authenticated Print HTTP endpoint. If the deployment
|
||||
does not already have Print, provision it first; enabling Gotenberg alone does not
|
||||
make Drive previews available.
|
||||
|
||||
Merge `compose.docx-preview.yml` into a Compose deployment whose Print service is
|
||||
named `print`. Adapt the existing application network name if it is not `default`.
|
||||
The example adds `GOTENBERG_URL=http://gotenberg:3000`. Gotenberg has no published
|
||||
port and only joins an internal network shared with Print. Do not expose its API
|
||||
to browsers or the Internet. The endpoint must be an administrator-controlled URL.
|
||||
|
||||
The example pins Gotenberg 8.37.0, disables URL downloads, webhooks, Chromium
|
||||
routes and external document references, and uses the engine's default disabled
|
||||
macro execution. It limits the converter to one CPU, 1 GiB RAM and 512 MiB of
|
||||
temporary storage. LibreOffice shuts down after 30 seconds idle. Large or complex
|
||||
documents may fail within these limits; users can retry or download the original.
|
||||
|
||||
## Behavior and compatibility
|
||||
|
||||
- Only `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
|
||||
is registered and accepted. Legacy `.doc` is deliberately out of scope.
|
||||
- The new client requests `GET /convert/:file?format=preview`, authenticated as
|
||||
before. The response is `{ id, contentType }`, with `application/pdf` when the
|
||||
converter is configured, otherwise `text/html`.
|
||||
- Requests with no format and `format=html` retain the existing HTML behavior.
|
||||
Explicit `format=pdf` returns 503 if no converter is configured. A new client
|
||||
accepts a legacy server response without `contentType` as HTML.
|
||||
- PDF cache IDs include source blob ID, ETag and `pdf-v1`; HTML cache IDs stay
|
||||
unchanged. Cached data lives in the same workspace storage as the source.
|
||||
Source changes trigger new previews. Old derived blobs follow the deployment's
|
||||
existing storage retention policy; this change does not add automatic cleanup.
|
||||
- Each Print process runs one PDF conversion and queues at most four more PDF jobs.
|
||||
Requests for the same workspace/source version share a job. Queue overflow
|
||||
returns 503. Each PDF conversion has a 60 second deadline; waiting for queued
|
||||
jobs adds to that time. PDF conversion input is limited to 25 MiB and output to
|
||||
50 MiB. HTML conversion runs independently of this queue and retains its existing
|
||||
behavior without the PDF input limit.
|
||||
- Conversion failure shows retry and original download controls. Navigating away
|
||||
cancels the browser request; a conversion already running may finish and cache
|
||||
its result for the next viewer.
|
||||
|
||||
LibreOffice preserves pages, tables, embedded images, headers and footers, but
|
||||
does not guarantee pixel-identical Microsoft Word rendering. Gotenberg includes
|
||||
Noto CJK, Carlito, Caladea and Liberation fonts. Missing corporate fonts can change
|
||||
line wrapping and pagination; add properly licensed fonts to a derived converter
|
||||
image when fidelity requires them. Changing fonts or the conversion engine does
|
||||
not invalidate existing cached PDFs; bump the PDF cache revision when rolling out
|
||||
a rendering change that should regenerate them.
|
||||
|
||||
Supporting `.doc` later is a small integration extension because LibreOffice
|
||||
already reads it: register and validate its MIME type, send the appropriate
|
||||
filename, and add legacy-document fixtures. Font and layout compatibility still
|
||||
need testing. Password-protected, corrupt or unsupported documents currently show
|
||||
the failure state rather than requesting a password.
|
||||
|
||||
## Validation
|
||||
|
||||
Focused Jest tests cover conversion limits, timeouts, output validation, queue
|
||||
coalescing, authentication, cache separation, source versions and legacy HTML,
|
||||
including large HTML sources and HTML conversion while a PDF job is stalled.
|
||||
Client tests cover result negotiation and request errors. No full project build
|
||||
is required for these targeted checks.
|
||||
|
||||
Before deployment, manually test Drive with a DOCX containing Chinese and English
|
||||
text, explicit and automatic page breaks, tables, an image and page numbers. Check
|
||||
the original download, reopening a cached preview, changing file versions,
|
||||
switching documents while loading, and retry after a converter failure. Test with
|
||||
the deployment's actual fonts and browser PDF settings; browsers configured to
|
||||
download PDFs instead of displaying them may not provide inline preview.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Merge into a deployment that already defines the print service with the updated image:
|
||||
# docker compose -f compose.yml -f compose.docx-preview.yml up -d print gotenberg
|
||||
# The print service retains its existing network, storage, secret and account settings.
|
||||
services:
|
||||
print:
|
||||
environment:
|
||||
GOTENBERG_URL: http://gotenberg:3000
|
||||
networks:
|
||||
default: {}
|
||||
document-preview: {}
|
||||
depends_on:
|
||||
gotenberg:
|
||||
condition: service_healthy
|
||||
|
||||
gotenberg:
|
||||
# Gotenberg 8.37.0, pinned so conversion behavior does not change on restart.
|
||||
image: gotenberg/gotenberg@sha256:f29984bd1e226bf1b93ba90af06000afa8b315853e99d27b9aaa41b93f15c769
|
||||
restart: unless-stopped
|
||||
cpus: 1
|
||||
mem_limit: 1g
|
||||
pids_limit: 256
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
tmpfs:
|
||||
- /tmp:rw,nosuid,size=512m
|
||||
command:
|
||||
- gotenberg
|
||||
- --api-timeout=60s
|
||||
- --api-body-limit=26MB
|
||||
- --api-disable-download-from
|
||||
- --webhook-disable
|
||||
- --chromium-disable-routes
|
||||
- --libreoffice-deny-list=.*
|
||||
- --libreoffice-max-queue-size=4
|
||||
- --libreoffice-idle-shutdown-timeout=30s
|
||||
healthcheck:
|
||||
test: [CMD, curl, --fail, --silent, http://localhost:3000/health]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
document-preview: {}
|
||||
|
||||
networks:
|
||||
document-preview:
|
||||
internal: true
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright © 2026 Huly Contributors.
|
||||
import { type Server } from 'http'
|
||||
|
||||
import config from '../config'
|
||||
import { convertToPdf } from '../preview'
|
||||
import { createServer } from '../server'
|
||||
|
||||
const mockStorage = { stat: jest.fn(), read: jest.fn(), put: jest.fn(), close: jest.fn() }
|
||||
const mockContext: { with: jest.Mock, error: jest.Mock } = {
|
||||
with: jest.fn(async (_name, _attrs, operation) => await Promise.resolve(operation(mockContext))),
|
||||
error: jest.fn()
|
||||
}
|
||||
jest.mock(
|
||||
'cors',
|
||||
() => () => (_req: unknown, _res: unknown, next: () => void) => {
|
||||
next()
|
||||
},
|
||||
{ virtual: true }
|
||||
)
|
||||
jest.mock('@hcengineering/api-client', () => ({}), { virtual: true })
|
||||
jest.mock('@hcengineering/core', () => ({ newMetrics: jest.fn() }), { virtual: true })
|
||||
jest.mock('@hcengineering/server-core', () => ({ initStatisticsContext: () => mockContext }), { virtual: true })
|
||||
jest.mock('@hcengineering/server-storage', () => ({ buildStorageFromConfig: () => mockStorage }), { virtual: true })
|
||||
jest.mock('@hcengineering/server-guest-resources', () => ({}), { virtual: true })
|
||||
jest.mock('@hcengineering/analytics-service', () => ({}), { virtual: true })
|
||||
jest.mock(
|
||||
'@hcengineering/account-client',
|
||||
() => ({
|
||||
getClient: (_url: string, token: string) => ({
|
||||
getLoginInfoByToken: async () =>
|
||||
token === 'invalid' ? {} : { workspace: token, workspaceDataId: token, workspaceUrl: token }
|
||||
}),
|
||||
isWorkspaceLoginInfo: (info: any) => info.workspace !== undefined
|
||||
}),
|
||||
{ virtual: true }
|
||||
)
|
||||
jest.mock('../config', () => ({
|
||||
__esModule: true,
|
||||
default: { GotenbergUrl: 'http://converter', AccountsUrl: 'http://accounts' }
|
||||
}))
|
||||
jest.mock('../print', () => ({}))
|
||||
jest.mock('../convert', () => ({ convertToHtml: jest.fn(async () => '<p>HTML</p>') }))
|
||||
jest.mock('../preview', () => ({
|
||||
...jest.requireActual('../preview'),
|
||||
convertToPdf: jest.fn(async () => Buffer.from('%PDF-fixture'))
|
||||
}))
|
||||
|
||||
describe('authenticated conversion route', () => {
|
||||
let server: Server
|
||||
let base: string
|
||||
let etag: string
|
||||
let sourceType: string
|
||||
let size: number
|
||||
const cache = new Map<string, any>()
|
||||
const docx = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
beforeAll(async () => {
|
||||
const app = createServer({} as any, []).app
|
||||
await new Promise<void>((resolve) => {
|
||||
server = app.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
base = `http://127.0.0.1:${(server.address() as any).port}`
|
||||
})
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
server.close(() => {
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
})
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
cache.clear()
|
||||
etag = 'version-one'
|
||||
sourceType = docx
|
||||
size = 12
|
||||
config.GotenbergUrl = 'http://converter'
|
||||
mockStorage.stat.mockImplementation(async (_ctx, ws, id) =>
|
||||
id === 'source' ? { contentType: sourceType, etag, size } : cache.get(`${ws.uuid}/${id}`)
|
||||
)
|
||||
mockStorage.read.mockResolvedValue([Buffer.from('document')])
|
||||
mockStorage.put.mockImplementation(async (_ctx, ws, id, _bytes, contentType) => {
|
||||
cache.set(`${ws.uuid}/${id}`, { contentType })
|
||||
})
|
||||
})
|
||||
const request = async (query = '?format=preview', token = 'workspace-a'): Promise<Response> =>
|
||||
await fetch(`${base}/convert/source${query}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
|
||||
it('authenticates before storage access', async () => {
|
||||
expect((await request('', 'invalid')).status).toBe(401)
|
||||
expect(mockStorage.stat).not.toHaveBeenCalled()
|
||||
})
|
||||
it('caches the PDF by workspace and source version while retaining the source', async () => {
|
||||
const first = await (await request()).json()
|
||||
expect(first.contentType).toBe('application/pdf')
|
||||
expect(await (await request()).json()).toEqual(first)
|
||||
expect(convertToPdf).toHaveBeenCalledTimes(1)
|
||||
await request('?format=preview', 'workspace-b')
|
||||
expect(convertToPdf).toHaveBeenCalledTimes(2)
|
||||
etag = 'version-two'
|
||||
expect((await (await request()).json()).id).not.toBe(first.id)
|
||||
expect(convertToPdf).toHaveBeenCalledTimes(3)
|
||||
expect(mockStorage.put.mock.calls.every((call) => call[2] !== 'source')).toBe(true)
|
||||
})
|
||||
it('keeps old clients on HTML and falls back when no PDF converter is configured', async () => {
|
||||
expect((await (await request('')).json()).contentType).toBe('text/html')
|
||||
config.GotenbergUrl = ''
|
||||
expect((await (await request()).json()).contentType).toBe('text/html')
|
||||
expect((await request('?format=pdf')).status).toBe(503)
|
||||
expect(convertToPdf).not.toHaveBeenCalled()
|
||||
})
|
||||
it('rejects DOC and oversized sources before reading their bytes', async () => {
|
||||
sourceType = 'application/msword'
|
||||
expect((await request()).status).toBe(400)
|
||||
sourceType = docx
|
||||
size = 26 * 1024 * 1024
|
||||
expect((await request()).status).toBe(413)
|
||||
expect(mockStorage.read).not.toHaveBeenCalled()
|
||||
})
|
||||
it('completes HTML conversion while a PDF conversion is stalled', async () => {
|
||||
let releasePdf!: (value: Buffer) => void
|
||||
let markStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve
|
||||
})
|
||||
jest.mocked(convertToPdf).mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<Buffer>((resolve) => {
|
||||
releasePdf = resolve
|
||||
markStarted()
|
||||
})
|
||||
)
|
||||
const pdf = request()
|
||||
await started
|
||||
let deadline: ReturnType<typeof setTimeout> | undefined
|
||||
try {
|
||||
const html = await Promise.race([
|
||||
request('?format=html'),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
deadline = setTimeout(() => {
|
||||
reject(new Error('HTML is blocked by PDF conversion'))
|
||||
}, 1000)
|
||||
})
|
||||
])
|
||||
expect(html.status).toBe(200)
|
||||
expect((await html.json()).contentType).toBe('text/html')
|
||||
} finally {
|
||||
clearTimeout(deadline)
|
||||
releasePdf(Buffer.from('%PDF-fixture'))
|
||||
await pdf
|
||||
}
|
||||
})
|
||||
it.each(['', '?format=html'])('preserves oversized legacy HTML conversion for %s', async (query) => {
|
||||
size = 26 * 1024 * 1024
|
||||
mockStorage.read.mockResolvedValue([Buffer.alloc(size)])
|
||||
const response = await request(query)
|
||||
expect(response.status).toBe(200)
|
||||
expect((await response.json()).contentType).toBe('text/html')
|
||||
expect(mockStorage.read).toHaveBeenCalledTimes(1)
|
||||
expect(convertToPdf).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not cache failed conversions and permits retry', async () => {
|
||||
jest.mocked(convertToPdf).mockRejectedValueOnce(new Error('converter failed'))
|
||||
expect((await request()).status).toBe(500)
|
||||
expect(mockStorage.put).not.toHaveBeenCalled()
|
||||
expect((await request()).status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright © 2026 Huly Contributors.
|
||||
import { convertToPdf, createPreviewQueue, getPreviewId } from '../preview'
|
||||
|
||||
describe('document preview', () => {
|
||||
afterEach(() => jest.restoreAllMocks())
|
||||
|
||||
it('preserves legacy cache keys and separates PDF versions', () => {
|
||||
expect(getPreviewId('file', '"etag"', 'html')).toBe('file@etag')
|
||||
expect(getPreviewId('file', '"etag"', 'pdf')).not.toBe(getPreviewId('file', '"etag"', 'html'))
|
||||
expect(getPreviewId('file', 'new', 'pdf')).not.toBe(getPreviewId('file', 'old', 'pdf'))
|
||||
})
|
||||
|
||||
it('serializes conversions, coalesces duplicates and bounds the queue', async () => {
|
||||
const queue = createPreviewQueue(1)
|
||||
let release!: (value: string) => void
|
||||
const first = jest.fn(
|
||||
async () =>
|
||||
await new Promise<string>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
)
|
||||
const second = jest.fn(async () => 'second')
|
||||
const a = queue.run('a', first)
|
||||
const duplicate = queue.run('a', first)
|
||||
const b = queue.run('b', second)
|
||||
await expect(queue.run('c', second)).rejects.toMatchObject({ code: 503 })
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
expect(second).not.toHaveBeenCalled()
|
||||
release('first')
|
||||
expect(await a).toBe('first')
|
||||
expect(await duplicate).toBe('first')
|
||||
expect(await b).toBe('second')
|
||||
})
|
||||
|
||||
it('releases failed jobs so retry is possible', async () => {
|
||||
const queue = createPreviewQueue()
|
||||
await expect(
|
||||
queue.run('a', async () => {
|
||||
throw new Error('broken')
|
||||
})
|
||||
).rejects.toThrow('broken')
|
||||
await expect(queue.run('a', async () => 'retry')).resolves.toBe('retry')
|
||||
})
|
||||
|
||||
it('sends document bytes and returns a validated PDF', async () => {
|
||||
const request = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('%PDF-1.7\nfixture', {
|
||||
headers: { 'Content-Type': 'application/pdf' }
|
||||
})
|
||||
)
|
||||
expect((await convertToPdf(Buffer.from('docx'), 'http://converter:3000/')).toString()).toContain('%PDF-')
|
||||
const [url, options] = request.mock.calls[0]
|
||||
expect(url).toBe('http://converter:3000/forms/libreoffice/convert')
|
||||
expect(options?.method).toBe('POST')
|
||||
expect((options?.body as FormData).get('files')).toBeInstanceOf(Blob)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[200, 'text/html', '%PDF-1.7'],
|
||||
[200, 'application/pdf', 'not a pdf'],
|
||||
[500, 'application/pdf', '%PDF-1.7']
|
||||
])('rejects an invalid converter response %s %s', async (status, contentType, body) => {
|
||||
jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: { 'Content-Type': contentType }
|
||||
})
|
||||
)
|
||||
await expect(convertToPdf(Buffer.from('docx'), 'http://converter')).rejects.toMatchObject({ code: 502 })
|
||||
})
|
||||
|
||||
it('bounds both input and streamed output', async () => {
|
||||
const request = jest.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response('%PDF-too-large', {
|
||||
headers: { 'Content-Type': 'application/pdf' }
|
||||
})
|
||||
)
|
||||
await expect(convertToPdf(Buffer.alloc(26 * 1024 * 1024), 'http://converter')).rejects.toMatchObject({ code: 413 })
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
await expect(convertToPdf(Buffer.from('docx'), 'http://converter', { maxOutputBytes: 8 })).rejects.toMatchObject({
|
||||
code: 502
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts stalled conversions', async () => {
|
||||
jest.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (_url, options) =>
|
||||
await new Promise((_resolve, reject) => {
|
||||
options?.signal?.addEventListener('abort', () => {
|
||||
reject(new Error('aborted'))
|
||||
})
|
||||
})
|
||||
)
|
||||
await expect(convertToPdf(Buffer.from('docx'), 'http://converter', { timeoutMs: 10 })).rejects.toMatchObject({
|
||||
code: 504
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ export interface Config {
|
||||
FrontUrl: string
|
||||
AllowedHostnames: string[]
|
||||
PuppeteerArgs: string[]
|
||||
GotenbergUrl: string
|
||||
}
|
||||
|
||||
const parseNumber = (str: string | undefined): number | undefined => (str !== undefined ? Number(str) : undefined)
|
||||
@@ -27,7 +28,8 @@ const config: Config = (() => {
|
||||
AccountsUrl: process.env.ACCOUNTS_URL,
|
||||
FrontUrl: process.env.FRONT_URL,
|
||||
AllowedHostnames: allowedHostnames == null ? [] : allowedHostnames.split(','),
|
||||
PuppeteerArgs: puppeteerArgs.split(',')
|
||||
PuppeteerArgs: puppeteerArgs.split(','),
|
||||
GotenbergUrl: process.env.GOTENBERG_URL ?? ''
|
||||
}
|
||||
|
||||
const missingEnv = (Object.keys(params) as Array<keyof Config>).filter((key) => params[key] === undefined)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Copyright © 2026 Huly Contributors.
|
||||
import { ApiError } from './error'
|
||||
|
||||
export const maxDocumentBytes = 25 * 1024 * 1024
|
||||
|
||||
export function getPreviewId (file: string, etag: string, format: 'html' | 'pdf'): string {
|
||||
const legacyId = `${file}@${etag.replaceAll('"', '')}`
|
||||
return format === 'html' ? legacyId : `${legacyId}@pdf-v1`
|
||||
}
|
||||
|
||||
/** One converter process, bounded waiting jobs, and shared results for identical source versions. */
|
||||
export function createPreviewQueue (maxPending = 4): {
|
||||
run: <T>(key: string, operation: () => Promise<T>) => Promise<T>
|
||||
} {
|
||||
const results = new Map<string, Promise<unknown>>()
|
||||
const pending: Array<() => void> = []
|
||||
let active = false
|
||||
|
||||
return {
|
||||
run<T>(key: string, operation: () => Promise<T>): Promise<T> {
|
||||
const existing = results.get(key)
|
||||
if (existing !== undefined) return existing as Promise<T>
|
||||
if (active && pending.length >= maxPending) {
|
||||
return Promise.reject(new ApiError(503, 'Document preview is busy. Please try again.'))
|
||||
}
|
||||
let resolveJob!: (value: T) => void
|
||||
let rejectJob!: (reason: unknown) => void
|
||||
const result = new Promise<T>((resolve, reject) => {
|
||||
resolveJob = resolve
|
||||
rejectJob = reject
|
||||
})
|
||||
results.set(key, result)
|
||||
const start = (): void => {
|
||||
active = true
|
||||
const finish = (): void => {
|
||||
results.delete(key)
|
||||
active = false
|
||||
pending.shift()?.()
|
||||
}
|
||||
void Promise.resolve()
|
||||
.then(operation)
|
||||
.then(
|
||||
(value) => {
|
||||
finish()
|
||||
resolveJob(value)
|
||||
},
|
||||
(error) => {
|
||||
finish()
|
||||
rejectJob(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (active) pending.push(start)
|
||||
else start()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Send bytes, never a source URL: document storage stays behind Huly authentication. */
|
||||
export async function convertToPdf (
|
||||
document: Buffer,
|
||||
endpoint: string,
|
||||
options: { timeoutMs?: number, maxOutputBytes?: number } = {}
|
||||
): Promise<Buffer> {
|
||||
if (document.length > maxDocumentBytes) throw new ApiError(413, 'Document exceeds the 25 MiB preview limit')
|
||||
const maxOutput = options.maxOutputBytes ?? 50 * 1024 * 1024
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, options.timeoutMs ?? 60000)
|
||||
try {
|
||||
const body = new FormData()
|
||||
body.append('files', new Blob([new Uint8Array(document)]), 'document.docx')
|
||||
const response = await fetch(`${endpoint.replace(/\/+$/, '')}/forms/libreoffice/convert`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
signal: controller.signal,
|
||||
redirect: 'error'
|
||||
})
|
||||
if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim() !== 'application/pdf') {
|
||||
await response.body?.cancel()
|
||||
throw new ApiError(502, 'Failed to convert document to PDF')
|
||||
}
|
||||
if (Number(response.headers.get('content-length')) > maxOutput) {
|
||||
await response.body?.cancel()
|
||||
throw new ApiError(502, 'Converted document exceeds the preview limit')
|
||||
}
|
||||
const reader = response.body?.getReader()
|
||||
if (reader === undefined) throw new ApiError(502, 'Empty PDF response')
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.length
|
||||
if (size > maxOutput) {
|
||||
await reader.cancel()
|
||||
throw new ApiError(502, 'Converted document exceeds the preview limit')
|
||||
}
|
||||
chunks.push(Buffer.from(value))
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
const pdf = Buffer.concat(chunks)
|
||||
if (pdf.subarray(0, 5).toString() !== '%PDF-') throw new ApiError(502, 'Invalid PDF response')
|
||||
return pdf
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new ApiError(504, 'Document conversion timed out')
|
||||
if (error instanceof ApiError) throw error
|
||||
throw new ApiError(502, 'Document converter is unavailable')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import { join } from 'path'
|
||||
|
||||
import config from './config'
|
||||
import { convertToHtml } from './convert'
|
||||
import { convertToPdf, createPreviewQueue, getPreviewId, maxDocumentBytes } from './preview'
|
||||
import { ApiError } from './error'
|
||||
import { PrintOptions, print, validKinds, validPageOrientations } from './print'
|
||||
import { withMeasureContext } from './middleware'
|
||||
@@ -177,6 +178,7 @@ export function createServer (
|
||||
allowedHostnames: string[]
|
||||
): { app: Express, close: () => void } {
|
||||
const storageAdapter = buildStorageFromConfig(storageConfig)
|
||||
const pdfPreviewQueue = createPreviewQueue()
|
||||
const measureCtx = initStatisticsContext('print', {
|
||||
factory: () =>
|
||||
createOpenTelemetryMetricsContext(
|
||||
@@ -255,29 +257,48 @@ export function createServer (
|
||||
throw new ApiError(400, `File of this type (${stat.contentType}) cannot be converted`)
|
||||
}
|
||||
|
||||
const convertId = getConvertId(file, stat.etag)
|
||||
if (req.query.format !== undefined && !['preview', 'html', 'pdf'].includes(req.query.format as string)) {
|
||||
throw new ApiError(400, 'Unsupported preview format')
|
||||
}
|
||||
const format =
|
||||
(req.query.format === 'preview' || req.query.format === 'pdf') && config.GotenbergUrl !== '' ? 'pdf' : 'html'
|
||||
if (req.query.format === 'pdf' && config.GotenbergUrl === '') {
|
||||
throw new ApiError(503, 'PDF document preview is not configured')
|
||||
}
|
||||
const contentType = format === 'pdf' ? 'application/pdf' : 'text/html'
|
||||
const convertId = getPreviewId(file, stat.etag, format)
|
||||
const convertStats = await storageAdapter.stat(ctx, wsUuid, convertId)
|
||||
|
||||
if (convertStats === undefined) {
|
||||
const originalFile = await storageAdapter.read(ctx, wsUuid, file)
|
||||
const convert = async (): Promise<void> => {
|
||||
// Another request may have filled the cache while this job waited for the converter.
|
||||
if ((await storageAdapter.stat(ctx, wsUuid, convertId)) !== undefined) return
|
||||
const originalFile = await storageAdapter.read(ctx, wsUuid, file)
|
||||
|
||||
if (originalFile === undefined) {
|
||||
throw new ApiError(404, `File ${file} not found`)
|
||||
if (originalFile === undefined) {
|
||||
throw new ApiError(404, `File ${file} not found`)
|
||||
}
|
||||
|
||||
const input = Buffer.concat(originalFile as any)
|
||||
if (format === 'pdf' && input.length > maxDocumentBytes) {
|
||||
throw new ApiError(413, 'Document exceeds the 25 MiB preview limit')
|
||||
}
|
||||
const output =
|
||||
format === 'pdf'
|
||||
? await ctx.with('convertToPdf', {}, () => convertToPdf(input, config.GotenbergUrl))
|
||||
: Buffer.from(await ctx.with('convertToHtml', {}, () => convertToHtml(input)))
|
||||
await storageAdapter.put(ctx, wsUuid, convertId, output, contentType, output.length)
|
||||
}
|
||||
|
||||
const htmlRes = await ctx.with('convertToHtml', {}, () => convertToHtml(Buffer.concat(originalFile as any)))
|
||||
|
||||
if (htmlRes === undefined) {
|
||||
throw new ApiError(400, 'Failed to convert')
|
||||
if (format === 'pdf') {
|
||||
if (stat.size > maxDocumentBytes) throw new ApiError(413, 'Document exceeds the 25 MiB preview limit')
|
||||
await pdfPreviewQueue.run(JSON.stringify([wsUuid.uuid, wsUuid.dataId, convertId]), convert)
|
||||
} else {
|
||||
await convert()
|
||||
}
|
||||
|
||||
const htmlBuf = Buffer.from(htmlRes)
|
||||
|
||||
await storageAdapter.put(ctx, wsUuid, convertId, htmlBuf, 'text/html', htmlBuf.length)
|
||||
}
|
||||
|
||||
res.contentType('application/json')
|
||||
res.send({ id: convertId })
|
||||
res.send({ id: convertId, contentType })
|
||||
})
|
||||
)
|
||||
|
||||
@@ -350,7 +371,3 @@ export function listen (e: Express, port: number, host?: string): Server {
|
||||
|
||||
return host !== undefined ? e.listen(port, host, cb) : e.listen(port, cb)
|
||||
}
|
||||
|
||||
function getConvertId (file: string, etag: string): string {
|
||||
return `${file}@${etag.replaceAll('"', '')}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user