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.65.0",
"version": "2.66.0",
"description": "ZenNotes desktop shell",
"private": true,
"main": "./out/main/index.js",
Expand Down
68 changes: 68 additions & 0 deletions apps/desktop/src/main/cloud-publish-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import type { CloudPublishUploadTarget } from '@zennotes/bridge-contract/cloud-sync'
import type { CloudPublishAssetPlatform } from '@zennotes/shared-domain/cloud-publish-uploads'
import type { PublishAssetSource } from './cloud-sync-client'

/**
* Where a published note's attachments come from: a local vault's files on
* disk (resolved through the vault's traversal guard by the caller), or a
* remote vault's, fetched from its server.
*/
export interface DesktopPublishAssetFiles {
/** The file's absolute path in a local vault, or null when the vault is remote. */
localPath(vaultRelativePath: string): string | null
readRemote(vaultRelativePath: string): Promise<Uint8Array>
}

export interface PublishAssetUploader {
uploadPublishAsset(target: CloudPublishUploadTarget, source: PublishAssetSource): Promise<void>
}

type PublishAssetHandle = Pick<PublishAssetSource, 'file' | 'bytes'>

/**
* The desktop side of a staged publish: hash each attachment as it streams off
* the disk, then PUT it from disk again, so a 100 MB note is never held in
* memory or carried across IPC. A remote vault's files arrive as bytes.
*/
export function desktopPublishAssetPlatform(
uploader: PublishAssetUploader,
files: DesktopPublishAssetFiles
): CloudPublishAssetPlatform<PublishAssetHandle> {
return {
async describe(asset) {
const file = files.localPath(asset.path)
if (file !== null) {
const info = await stat(file)
if (!info.isFile()) throw new Error(`ZenNotes could not read the attachment “${asset.ref}”.`)
return { byteLength: info.size, sha256: await hashFile(file), handle: { file } }
}
const bytes = await files.readRemote(asset.path)
return {
byteLength: bytes.byteLength,
sha256: createHash('sha256').update(bytes).digest('hex'),
handle: { bytes }
}
},
async upload(target, asset) {
await uploader.uploadPublishAsset(target, {
mime: asset.mime,
byteLength: asset.byteLength,
...asset.handle
})
},
async readBase64(asset) {
const file = files.localPath(asset.path)
const bytes = file !== null ? await readFile(file) : Buffer.from(await files.readRemote(asset.path))
return bytes.toString('base64')
}
}
}

async function hashFile(file: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(file)) hash.update(chunk as Buffer)
return hash.digest('hex')
}
148 changes: 148 additions & 0 deletions apps/desktop/src/main/cloud-publish-uploads-network.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { createServer, type IncomingMessage } from 'node:http'
import { once } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import type { AddressInfo } from 'node:net'
import { afterEach, expect, it } from 'vitest'
import { publishWithStagedUploads } from '@zennotes/shared-domain/cloud-publish-uploads'
import { desktopPublishAssetPlatform } from './cloud-publish-assets'
import { createCloudSyncClient } from './cloud-sync-client'

// A fake Cloud and object store on loopback: the real desktop client stages each
// attachment with a streamed PUT, then publishes naming the upload.

const directories: string[] = []

afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})

async function readBody(request: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = []
for await (const chunk of request) chunks.push(Buffer.from(chunk))
return Buffer.concat(chunks)
}

async function fixture(stagedUploads: boolean) {
const vault = await mkdtemp(path.join(os.tmpdir(), 'zennotes-publish-network-'))
directories.push(vault)
const files = [Buffer.alloc(700_000, 7), Buffer.alloc(1_300_000, 9)]
await writeFile(path.join(vault, 'a.png'), files[0]!)
await writeFile(path.join(vault, 'b.png'), files[1]!)

const puts: Array<{ url: string; headers: IncomingMessage['headers']; body: Buffer }> = []
const publishes: Array<{ method: string; contentType: string; payload: Record<string, unknown>; files: number }> = []
const server = createServer(async (request, response) => {
try {
const body = await readBody(request)
const url = request.url ?? ''
if (url === '/api/v1/shares/uploads' && request.method === 'POST') {
if (!stagedUploads) {
response.writeHead(404, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ message: 'Not Found' }))
return
}
const { assets } = JSON.parse(body.toString()) as { assets: Array<{ ref: string }> }
const host = request.headers.host
response.writeHead(201, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({
data: {
id: '01upload',
expires_at: '2026-10-09T21:00:00Z',
uploads: assets.map((asset) => ({ ref: asset.ref, method: 'PUT', url: `http://${host}/storage/${asset.ref}?sig=1`, headers: { Host: host } }))
}
}))
return
}
if (url.startsWith('/storage/') && request.method === 'PUT') {
puts.push({ url, headers: request.headers, body })
response.writeHead(200)
response.end()
return
}
if (url.startsWith('/api/v1/shares')) {
const contentType = request.headers['content-type'] ?? ''
let payload: Record<string, unknown>
let fileCount = 0
if (contentType.startsWith('multipart/form-data')) {
const form = await new Request('http://localhost/', { method: 'POST', headers: { 'Content-Type': contentType }, body }).formData()
payload = JSON.parse(form.get('payload') as string)
fileCount = form.getAll('assets[]').length
} else {
payload = JSON.parse(JSON.parse(body.toString()).payload)
}
publishes.push({ method: request.method ?? '', contentType, payload, files: fileCount })
response.writeHead(request.method === 'POST' ? 201 : 200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ id: 1, slug: 'trip', url: 'http://localhost/s/trip' }))
return
}
response.writeHead(404)
response.end()
} catch (error) {
response.writeHead(500)
response.end(String(error))
}
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address() as AddressInfo
const client = createCloudSyncClient(`http://127.0.0.1:${address.port}`, 'test-only-token')
const platform = desktopPublishAssetPlatform(client, {
localPath: (rel) => path.join(vault, rel),
readRemote: async () => { throw new Error('not remote') }
})
const input = {
note_path: 'Trip.md',
title: 'Trip',
markdown: '![](a.png) ![](b.png)',
assets: [
{ ref: 'a.png', name: 'a.png', mime: 'image/png', path: 'a.png' },
{ ref: 'b.png', name: 'b.png', mime: 'image/png', path: 'b.png' }
]
}
const close = async () => {
server.closeAllConnections()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
return { client, platform, input, files, puts, publishes, close }
}

it('stages attachments with streamed PUTs that carry their length, then publishes naming the upload', async () => {
const { client, platform, input, files, puts, publishes, close } = await fixture(true)
try {
const result = await publishWithStagedUploads(client, input, platform)
await publishWithStagedUploads(client, input, platform, { shareId: result.id })

expect(puts.slice(0, 2).map((put) => put.url).sort()).toEqual(['/storage/a.png?sig=1', '/storage/b.png?sig=1'])
for (const put of puts) {
const expected = put.url.startsWith('/storage/a.png') ? files[0]! : files[1]!
expect(put.headers['content-length']).toBe(String(expected.byteLength))
expect(put.headers['transfer-encoding']).toBeUndefined()
expect(put.headers['content-type']).toBe('image/png')
expect(put.headers.authorization).toBeUndefined()
expect(put.body.equals(expected)).toBe(true)
}
expect(publishes).toEqual([
expect.objectContaining({ method: 'POST', files: 0, payload: expect.objectContaining({ upload_id: '01upload', asset_refs: ['a.png', 'b.png'] }) }),
expect.objectContaining({ method: 'PUT', files: 0, payload: expect.objectContaining({ upload_id: '01upload', asset_refs: ['a.png', 'b.png'] }) })
])
} finally {
await close()
}
})

it('publishes in one multipart request when the service answers 404 for staged uploads', async () => {
const { client, platform, input, puts, publishes, close } = await fixture(false)
try {
await publishWithStagedUploads(client, input, platform)

expect(puts).toEqual([])
expect(publishes).toEqual([
expect.objectContaining({ method: 'POST', files: 2, payload: expect.objectContaining({ asset_refs: ['a.png', 'b.png'] }) })
])
expect(publishes[0]?.payload).not.toHaveProperty('upload_id')
} finally {
await close()
}
})
39 changes: 39 additions & 0 deletions apps/desktop/src/main/cloud-sync-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,45 @@ describe('createCloudSyncClient', () => {
expect(new Headers(options?.headers).has('Content-Type')).toBe(false)
})

it('streams a staged publish attachment to its presigned URL with an explicit length, never the token', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'zennotes-publish-upload-'))
temporaryDirectories.push(directory)
const photo = path.join(directory, 'photo.png')
await writeFile(photo, Buffer.from([1, 2, 3, 4]))
const sent: Array<{ url: string; init: RequestInit; body: Buffer }> = []
const fetchImplementation = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const chunks: Buffer[] = []
for await (const chunk of init?.body as unknown as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk))
sent.push({ url: String(input), init: init ?? {}, body: Buffer.concat(chunks) })
return new Response(null, { status: 200 })
}) as unknown as typeof fetch
const client = createCloudSyncClient('https://zennotes.org', 'secret-token', fetchImplementation)
const target = { ref: 'photo.png', method: 'PUT' as const, url: 'https://storage.test/shares/abc/photo.png?sig=1', headers: { Host: 'storage.test' } }

await client.uploadPublishAsset(target, { mime: 'image/png', byteLength: 4, file: photo })

expect(sent).toHaveLength(1)
const headers = new Headers(sent[0]?.init.headers)
expect(sent[0]?.url).toBe(target.url)
expect(sent[0]?.init.method).toBe('PUT')
expect(sent[0]?.init.redirect).toBe('error')
expect(headers.get('content-length')).toBe('4')
expect(headers.get('content-type')).toBe('image/png')
expect(headers.get('authorization')).toBeNull()
expect([...(sent[0]?.body ?? [])]).toEqual([1, 2, 3, 4])

await expect(client.uploadPublishAsset(target, { mime: 'image/png', byteLength: 5, file: photo }))
.rejects.toMatchObject({ code: 'DIRECT_UPLOAD_SIZE_MISMATCH' })
await expect(client.uploadPublishAsset({ ...target, url: 'http://storage.test/x' }, { mime: 'image/png', byteLength: 4, file: photo }))
.rejects.toThrow()
expect(sent).toHaveLength(1)

const refused = createCloudSyncClient('https://zennotes.org', 'secret-token', (async () =>
new Response('denied', { status: 403 })) as unknown as typeof fetch)
await expect(refused.uploadPublishAsset(target, { mime: 'image/png', byteLength: 4, bytes: new Uint8Array([1, 2, 3, 4]) }))
.rejects.toMatchObject({ status: 403, code: 'DIRECT_UPLOAD_FAILED' })
})

it('keeps files at the inline limit in the mutation request', async () => {
const fetchImplementation = vi
.fn<typeof fetch>()
Expand Down
66 changes: 65 additions & 1 deletion apps/desktop/src/main/cloud-sync-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createHash } from 'node:crypto'
import { createReadStream, type ReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import type {
CloudPublishUploadTarget,
CloudSyncCapacityConflict,
CloudSyncConflict,
CloudSyncConflictCode,
Expand Down Expand Up @@ -57,12 +58,25 @@ export interface DesktopCloudSyncClientOptions {
contentReferences?: boolean
}

/** One staged publish attachment to PUT: a local file streamed from disk, or a
* remote vault's file already in memory. */
export interface PublishAssetSource {
mime: string
byteLength: number
file?: string
bytes?: Uint8Array
}

export type DesktopCloudSyncClient = CloudSyncApiClient & {
uploadPublishAsset(target: CloudPublishUploadTarget, source: PublishAssetSource): Promise<void>
}

export function createCloudSyncClient(
baseUrl: string,
token: string,
fetchImplementation: typeof fetch = fetch,
options: DesktopCloudSyncClientOptions = {}
): CloudSyncApiClient {
): DesktopCloudSyncClient {
const accountId = options.accountId ?? createHash('sha256').update(token).digest('hex')
return new DesktopCloudSyncApiClient(
(options.rateLimits ?? cloudSyncRateLimits).wrap(
Expand Down Expand Up @@ -202,6 +216,56 @@ class DesktopCloudSyncApiClient extends CloudSyncApiClient {
return await this.completeDirectUpload(vaultId, instruction.id, mutation)
}

/**
* PUT one staged publish attachment to the presigned URL the service gave it,
* with the same safeguards as a sync upload: HTTPS only, no redirects, and an
* explicit Content-Length (object storage refuses a chunked PUT, 411), from a
* file that must still be the size that was declared.
*/
async uploadPublishAsset(target: CloudPublishUploadTarget, source: PublishAssetSource): Promise<void> {
this.signal?.throwIfAborted()
const uploadUrl = secureDirectUploadUrl(target.url)
const headers = new Headers(target.headers)
if (!headers.has('content-length')) headers.set('content-length', String(source.byteLength))
if (!headers.has('content-type')) headers.set('content-type', source.mime)

let reader: ReadStream | undefined
let body: FetchBody
if (source.file !== undefined) {
const sourceStats = await stat(source.file)
if (!sourceStats.isFile() || sourceStats.size !== source.byteLength) throw directUploadSizeMismatch()
body = (reader = createReadStream(source.file)) as unknown as FetchBody
} else if (source.bytes !== undefined) {
if (source.bytes.byteLength !== source.byteLength) throw directUploadSizeMismatch()
body = source.bytes as FetchBody
} else {
throw new Error('A publish upload needs a file or its bytes.')
}

let response: Response
try {
response = await this.fetchImplementation(uploadUrl, {
method: target.method,
headers,
body,
signal: requestSignal(this.signal, DIRECT_UPLOAD_TIMEOUT_MS),
redirect: 'error',
...(reader ? { duplex: 'half' } : {})
})
} finally {
reader?.destroy()
}
await response.body?.cancel().catch(() => {})

if (!response.ok) {
throw new CloudServiceRequestError(
`ZenNotes Cloud object upload failed (${response.status}).`,
response.status,
'DIRECT_UPLOAD_FAILED'
)
}
}

private async abortQuietly(vaultId: string, uploadId: string): Promise<void> {
await this.abortUpload(vaultId, uploadId).catch(() => {})
}
Expand Down
Loading
Loading