Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/desktop",
"productName": "ZenNotes",
"version": "2.48.0",
"version": "2.49.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
81 changes: 68 additions & 13 deletions apps/desktop/src/main/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,11 +249,8 @@ describe('createCloudSyncClient', () => {
upload: {
method: 'PUT',
url: 'https://objects.example.test/upload-1?signature=signed',
headers: {
'Content-Length': String(bytes.byteLength),
'Content-Type': 'application/octet-stream',
'X-Upload-Header': 'signed-value'
}
// The Cloud service signs only the host: no length, no type.
headers: { Host: 'objects.example.test', 'X-Upload-Header': 'signed-value' }
}
}
},
Expand Down Expand Up @@ -327,14 +324,13 @@ describe('createCloudSyncClient', () => {
expect(directUpload?.[0]).toBe('https://objects.example.test/upload-1?signature=signed')
expect(directUpload?.[1]?.method).toBe('PUT')
expect(directUpload?.[1]?.redirect).toBe('error')
expect(new Headers(directUpload?.[1]?.headers)).toEqual(
new Headers({
'Content-Length': String(bytes.byteLength),
'Content-Type': 'application/octet-stream',
'X-Upload-Header': 'signed-value'
})
)
expect(new Headers(directUpload?.[1]?.headers).has('Authorization')).toBe(false)
// The stream has no length of its own; without Content-Length the PUT
// goes out chunked and object storage answers 411 (Discord, 2026-09-13).
const putHeaders = new Headers(directUpload?.[1]?.headers)
expect(putHeaders.get('Content-Length')).toBe(String(bytes.byteLength))
expect(putHeaders.get('Content-Type')).toBe(mutation.content.media_type)
expect(putHeaders.get('X-Upload-Header')).toBe('signed-value')
expect(putHeaders.has('Authorization')).toBe(false)
expect(uploadedBodies[0]?.byteLength).toBe(bytes.byteLength)
expect(uploadedBodies[0]?.[0]).toBe(7)
expect(uploadedBodies[0]?.at(-1)).toBe(7)
Expand All @@ -346,6 +342,65 @@ describe('createCloudSyncClient', () => {
expect(new Headers(completion?.[1]?.headers).get('Authorization')).toBe('Bearer secret-token')
})

it('keeps a Content-Length and Content-Type the service already set', async () => {
const bytes = Buffer.alloc(INLINE_UPLOAD_LIMIT_BYTES + 1, 7)
const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-direct-upload-'))
temporaryDirectories.push(directory)
const sourcePath = path.join(directory, 'archive.zip')
await writeFile(sourcePath, bytes)
const mutation = upsertMutation(bytes.byteLength, '')
rememberCloudSyncUploadSource(mutation.content, sourcePath)
const fetchImplementation = vi.fn<typeof fetch>(async (input, options) => {
const url = String(input)
if (url.endsWith('/uploads')) {
return jsonResponse(
{
data: {
id: 'upload-1',
operation_id: mutation.operation_id,
status: 'uploading',
expected_bytes: bytes.byteLength,
expires_at: '2026-08-19T18:30:00.000Z',
upload: {
method: 'PUT',
url: 'https://objects.example.test/upload-1?signature=signed',
headers: {
'Content-Length': String(bytes.byteLength),
'Content-Type': 'application/zip'
}
}
}
},
201
)
}
if (url.startsWith('https://objects.example.test/')) {
for await (const _chunk of options?.body as AsyncIterable<Uint8Array>) {
/* drain */
}
return new Response(null, { status: 200 })
}
if (url.endsWith('/complete')) {
return jsonResponse({
data: {
id: 'upload-1',
operation_id: mutation.operation_id,
status: 'completed',
result: { acknowledged: [], conflicts: [], cursor: 9 }
}
})
}
throw new Error(`Unexpected request: ${url}`)
})
const client = createCloudSyncClient('https://zennotes.org/', 'secret-token', fetchImplementation)

await client.mutate('vault-1', { mutations: [mutation] })

const putHeaders = new Headers(fetchImplementation.mock.calls[1]?.[1]?.headers)
expect(putHeaders.get('Content-Length')).toBe(String(bytes.byteLength))
expect(putHeaders.get('Content-Type')).toBe('application/zip')
})

it('aborts the upload reservation when object storage rejects the PUT', async () => {
const bytes = Buffer.alloc(INLINE_UPLOAD_LIMIT_BYTES + 1, 11)
const mutation = upsertMutation(bytes.byteLength, bytes.toString('base64'))
Expand Down
16 changes: 15 additions & 1 deletion apps/desktop/src/main/cloud-sync-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,27 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient {
throw error
}
const upload = instruction.upload
// The Cloud service signs only the host of the presigned PUT and hands back
// no length, and a streamed file has no length of its own, so fetch would
// send it chunked. Object storage refuses a chunked PUT without a
// Content-Length (411 Length Required), which is what every file above the
// inline limit ran into. The mutation knows the byte count, so it travels
// as Content-Length unless the service already set one; a file that
// changes size mid-upload then fails the request instead of storing a
// truncated or padded object. Content-Type likewise names the media type
// the service reserved.
const headers = new Headers(upload.headers)
if (!headers.has('content-length')) {
headers.set('content-length', String(mutation.content.byte_length))
}
if (!headers.has('content-type')) headers.set('content-type', mutation.content.media_type)
let response: Response

try {
try {
response = await this.fetchImplementation(uploadUrl, {
method: upload.method,
headers: upload.headers,
headers,
body: uploadBody.createBody(),
signal: AbortSignal.timeout(DIRECT_UPLOAD_TIMEOUT_MS),
redirect: 'error',
Expand Down
22 changes: 19 additions & 3 deletions apps/desktop/src/main/cloud-sync-upload-network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ describe('disk-backed Cloud uploads over HTTP', () => {
const fixture = await setup(size)
const result = await fixture.client().mutate('vault', { mutations: [fixture.mutation] })
expect(result.acknowledged).toHaveLength(1)
// Framed with a length, never chunked (the 411 class from Discord).
expect(fixture.putHeaders()?.['content-length']).toBe(String(size))
expect(fixture.putHeaders()?.['transfer-encoding']).toBeUndefined()
expect(fixture.putHeaders()?.['content-type']).toBe('image/jpeg')
const downloaded = Buffer.from(await (await fetch(`${fixture.url}/object`)).arrayBuffer())
expect(downloaded.length).toBe(size)
expect(sha256(downloaded)).toBe(fixture.mutation.content.sha256)
Expand Down Expand Up @@ -112,6 +116,7 @@ async function setup(
}
let failure = initialFailure
let stored: Buffer | null = null
let putHeaders: import('node:http').IncomingHttpHeaders | null = null
let aborts = 0
let completions = 0
let uploadMutation = mutation
Expand All @@ -134,13 +139,23 @@ async function setup(
upload: {
method: 'PUT',
url: `${url}/object`,
headers: { 'Content-Length': String(bytes.length) }
// The Cloud service signs only the host of the presigned PUT and
// hands back nothing else; the client must supply the length.
headers: { Host: new URL(url).host }
}
}
})
})
} else if (request.url === '/object' && request.method === 'PUT') {
if (failure === 'reject') {
putHeaders = request.headers
// Object storage (S3, R2) refuses a chunked PUT that carries no
// Content-Length before reading a byte: 411 Length Required. The old
// fixture accepted anything, which is how the real rejection stayed
// hidden behind green tests.
if (request.headers['transfer-encoding'] || !request.headers['content-length']) {
response.writeHead(411, { Connection: 'close' })
response.end()
} else if (failure === 'reject') {
response.writeHead(403, { Connection: 'close' })
response.end()
} else if (failure === 'disconnect') {
Expand Down Expand Up @@ -217,7 +232,8 @@ async function setup(
failure = null
},
aborts: () => aborts,
completions: () => completions
completions: () => completions,
putHeaders: () => putHeaders
}
}

Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/renderer/export-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
resolveCustomThemeMode
} from '@renderer/lib/custom-themes'
import { withExportTitle } from '@shared/export-title'
import { settleExportImages } from '@renderer/lib/export-images'
import '@renderer/styles/index.css'

const PREFS_KEY = 'zen:prefs:v2'
Expand Down Expand Up @@ -448,7 +449,12 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
<Preview
markdown={withExportTitle(note.body, note.title).markdown}
notePath={note.path}
onRendered={() => setExportState('ready')}
onRendered={() => {
// Images load after the DOM is in place, and the preview defers
// the ones below the viewport; print only once they have all
// settled (#769).
void settleExportImages(document).then(() => setExportState('ready'))
}}
/>
</main>
</>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/server",
"private": true,
"version": "2.48.0",
"version": "2.49.0",
"scripts": {
"dev": "node ../../tooling/scripts/run-go-server-dev.mjs",
"prepare-web": "node ../../tooling/scripts/prepare-server-web-dist.mjs",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zennotes/web",
"private": true,
"version": "2.48.0",
"version": "2.49.0",
"type": "module",
"description": "ZenNotes web client for self-hosted and hosted deployments",
"homepage": "https://zennotes.org",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/export-window.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
import type { AssetMeta, NoteContent, NoteMeta, VaultInfo } from '@shared/ipc'
import { LazyPreview as Preview } from '@renderer/components/LazyPreview'
import { useStore } from '@renderer/store'
import { settleExportImages } from '@renderer/lib/export-images'
import { withExportTitle } from '@shared/export-title'
import '@renderer/styles/index.css'

Expand Down Expand Up @@ -158,6 +159,9 @@ function ExportNoteWindow({ notePath }: { notePath: string }): JSX.Element {
const triggerPrint = async (): Promise<void> => {
if (didTriggerPrint.current) return
didTriggerPrint.current = true
// The preview defers images below the viewport; print only once every
// image has loaded or failed (#769).
await settleExportImages(document)
try {
if ('fonts' in document && document.fonts?.ready) {
await document.fonts.ready
Expand Down
Loading
Loading