Skip to content
Open
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
33 changes: 33 additions & 0 deletions apps/desktop/electron/connection-config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,38 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
}

/**
* Build the kanban /events stream URL. Same WS gate as /api/ws (credential in
* the query string: `token` in loopback/token mode, single-use `ticket` on an
* OAuth-gated gateway), plus the stream's own params: `since` seeds the event
* cursor (omitted at <= 0 so the server starts from "now"), `board` pins the
* board at the handshake (the UI reconnects to switch boards).
*
* @param {string} baseUrl
* @param {['token'|'ticket', string]} authParam
* @param {{ since?: number, board?: string|null }} [options]
*/
function buildKanbanEventsWsUrl(baseUrl, authParam, options = {}) {
const parsed = new URL(baseUrl)
const wsScheme = parsed.protocol === 'https:' ? 'wss' : 'ws'
const prefix = parsed.pathname.replace(/\/+$/, '')
const params = new URLSearchParams()

params.set(authParam[0] === 'ticket' ? 'ticket' : 'token', String(authParam[1] ?? ''))

const since = Math.floor(Number(options.since))
if (Number.isFinite(since) && since > 0) {
params.set('since', String(since))
}

const board = String(options.board ?? '').trim()
if (board) {
params.set('board', board)
}

return `${wsScheme}://${parsed.host}${prefix}/api/plugins/kanban/events?${params.toString()}`
}

/**
* Build the WS URL the renderer would connect with, so the connection test can
* exercise the same transport the app actually uses.
Expand Down Expand Up @@ -270,6 +302,7 @@ module.exports = {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
buildKanbanEventsWsUrl,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/electron/connection-config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
buildKanbanEventsWsUrl,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
Expand Down Expand Up @@ -213,6 +214,54 @@ test('buildGatewayWsUrlWithTicket url-encodes the ticket', () => {
assert.equal(buildGatewayWsUrlWithTicket('https://host', 'a+b/c'), 'wss://host/api/ws?ticket=a%2Bb%2Fc')
})

// --- buildKanbanEventsWsUrl ---

test('buildKanbanEventsWsUrl (token mode) carries token + since + board', () => {
const url = buildKanbanEventsWsUrl('http://127.0.0.1:8321', ['token', 'tok 1'], { since: 42, board: 'ops' })
assert.equal(url, 'ws://127.0.0.1:8321/api/plugins/kanban/events?token=tok+1&since=42&board=ops')
})

test('buildKanbanEventsWsUrl (ticket mode) uses ?ticket= and wss on https', () => {
const url = buildKanbanEventsWsUrl('https://gw.example.com', ['ticket', 'tkt-9'], { since: 7 })
assert.equal(url, 'wss://gw.example.com/api/plugins/kanban/events?ticket=tkt-9&since=7')
assert.ok(!url.includes('token='))
})

test('buildKanbanEventsWsUrl honors a path prefix', () => {
const url = buildKanbanEventsWsUrl('https://host/hermes/', ['token', 't'], {})
assert.equal(url, 'wss://host/hermes/api/plugins/kanban/events?token=t')
})

test('buildKanbanEventsWsUrl omits since when zero/negative/NaN and board when blank', () => {
assert.equal(
buildKanbanEventsWsUrl('http://host', ['token', 't'], { since: 0, board: '' }),
'ws://host/api/plugins/kanban/events?token=t'
)
assert.equal(
buildKanbanEventsWsUrl('http://host', ['token', 't'], { since: -3, board: ' ' }),
'ws://host/api/plugins/kanban/events?token=t'
)
assert.equal(
buildKanbanEventsWsUrl('http://host', ['token', 't'], { since: Number.NaN }),
'ws://host/api/plugins/kanban/events?token=t'
)
assert.equal(buildKanbanEventsWsUrl('http://host', ['token', 't']), 'ws://host/api/plugins/kanban/events?token=t')
})

test('buildKanbanEventsWsUrl floors fractional since values', () => {
assert.equal(
buildKanbanEventsWsUrl('http://host', ['token', 't'], { since: 9.7 }),
'ws://host/api/plugins/kanban/events?token=t&since=9'
)
})

test('buildKanbanEventsWsUrl url-encodes the credential and board', () => {
assert.equal(
buildKanbanEventsWsUrl('http://host', ['ticket', 'a+b/c'], { board: 'my board' }),
'ws://host/api/plugins/kanban/events?ticket=a%2Bb%2Fc&board=my+board'
)
})

// --- authModeFromStatus ---

test('authModeFromStatus returns oauth when auth_required is true', () => {
Expand Down
103 changes: 103 additions & 0 deletions apps/desktop/electron/kanban-attachments.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
'use strict'

const path = require('node:path')

// Mirror of the backend's per-upload cap (plugins/kanban/dashboard/
// plugin_api.py _MAX_ATTACHMENT_BYTES). Checked before buffering so a huge
// pick fails fast with the same 413 detail the backend would return, instead
// of shipping 2 GB over IPC just to be rejected.
const MAX_KANBAN_ATTACHMENT_BYTES = 25 * 1024 * 1024

function attachmentSizeError() {
const detail = `attachment exceeds ${MAX_KANBAN_ATTACHMENT_BYTES / (1024 * 1024)} MB limit`
return new Error(`413: ${JSON.stringify({ detail })}`)
}

// Same reduction the backend applies (_safe_attachment_name): strip directory
// components on both separators, drop control chars and leading dots. The
// server already sanitised the stored filename, but the download target is
// joined under the user's Downloads dir, so never trust it verbatim.
function safeAttachmentBasename(raw) {
let name = String(raw || '')
.replace(/\\/g, '/')
.split('/')
.pop()
.trim()
name = Array.from(name)
.filter(ch => ch >= ' ' && ch !== '\x7f')
.join('')
.trim()
name = name.replace(/^\.+/, '').trim()
if (!name) {
return 'attachment'
}
return name.slice(0, 200)
}

// Collision-resolved save path under `dir`: foo.pdf → foo (1).pdf → foo (2).pdf.
// Splits at the FIRST dot to mirror the backend's collision naming, so a file
// uploaded and downloaded twice round-trips to the same shape.
function resolveDownloadTarget(dir, filename, exists) {
const safe = safeAttachmentBasename(filename)
const dotIndex = safe.indexOf('.')
const stem = dotIndex === -1 ? safe : safe.slice(0, dotIndex)
const ext = dotIndex === -1 ? '' : safe.slice(dotIndex)
let candidate = safe
let n = 1
while (exists(path.join(dir, candidate))) {
candidate = `${stem} (${n})${ext}`
n += 1
}
return path.join(dir, candidate)
}

// RFC 2388 multipart/form-data body for the kanban upload route
// (POST /tasks/:id/attachments — fields: uploaded_by, file). Built as a
// Buffer so binary payloads survive untouched.
function buildKanbanAttachmentMultipart({ contentType, fileBuffer, filename, uploadedBy }, randomHex) {
if (!Buffer.isBuffer(fileBuffer)) {
throw new Error('buildKanbanAttachmentMultipart: fileBuffer must be a Buffer')
}
if (fileBuffer.length > MAX_KANBAN_ATTACHMENT_BYTES) {
throw attachmentSizeError()
}

const boundary = `----hermesKanbanAttachment${randomHex || Math.random().toString(16).slice(2)}`
// Quoted-string escaping for Content-Disposition; CR/LF cannot be escaped
// portably, so drop them outright.
const safeName = safeAttachmentBasename(filename).replace(/"/g, '%22')
const parts = []

if (uploadedBy) {
parts.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="uploaded_by"\r\n\r\n${uploadedBy}\r\n`,
'utf8'
)
)
}

parts.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeName}"\r\n` +
`Content-Type: ${contentType || 'application/octet-stream'}\r\n\r\n`,
'utf8'
),
fileBuffer,
Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')
)

return {
body: Buffer.concat(parts),
boundary,
contentType: `multipart/form-data; boundary=${boundary}`
}
}

module.exports = {
attachmentSizeError,
buildKanbanAttachmentMultipart,
MAX_KANBAN_ATTACHMENT_BYTES,
resolveDownloadTarget,
safeAttachmentBasename
}
88 changes: 88 additions & 0 deletions apps/desktop/electron/kanban-attachments.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
'use strict'

const test = require('node:test')
const assert = require('node:assert/strict')
const path = require('node:path')

const {
buildKanbanAttachmentMultipart,
MAX_KANBAN_ATTACHMENT_BYTES,
resolveDownloadTarget,
safeAttachmentBasename
} = require('./kanban-attachments.cjs')

test('safeAttachmentBasename strips directories, control chars, and leading dots', () => {
assert.equal(safeAttachmentBasename('../../etc/passwd'), 'passwd')
assert.equal(safeAttachmentBasename('C:\\Users\\evil\\..\\notes.txt'), 'notes.txt')
assert.equal(safeAttachmentBasename('.hidden'), 'hidden')
assert.equal(safeAttachmentBasename('re\x00port\n.pdf'), 'report.pdf')
assert.equal(safeAttachmentBasename(''), 'attachment')
assert.equal(safeAttachmentBasename('...'), 'attachment')
assert.equal(safeAttachmentBasename(`${'a'.repeat(300)}.txt`).length, 200)
})

test('resolveDownloadTarget keeps the name when free', () => {
const target = resolveDownloadTarget('/downloads', 'report.pdf', () => false)
assert.equal(target, path.join('/downloads', 'report.pdf'))
})

test('resolveDownloadTarget resolves collisions like the backend (first-dot split)', () => {
const taken = new Set([
path.join('/downloads', 'archive.tar.gz'),
path.join('/downloads', 'archive (1).tar.gz')
])
const target = resolveDownloadTarget('/downloads', 'archive.tar.gz', p => taken.has(p))
assert.equal(target, path.join('/downloads', 'archive (2).tar.gz'))
})

test('resolveDownloadTarget handles extensionless names', () => {
const taken = new Set([path.join('/downloads', 'Makefile')])
const target = resolveDownloadTarget('/downloads', 'Makefile', p => taken.has(p))
assert.equal(target, path.join('/downloads', 'Makefile (1)'))
})

test('buildKanbanAttachmentMultipart emits uploaded_by and file parts with the boundary', () => {
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x00, 0xff])
const { body, boundary, contentType } = buildKanbanAttachmentMultipart(
{
contentType: 'image/png',
fileBuffer: payload,
filename: 'shot.png',
uploadedBy: 'desktop'
},
'deadbeef'
)

assert.equal(boundary, '----hermesKanbanAttachmentdeadbeef')
assert.equal(contentType, `multipart/form-data; boundary=${boundary}`)

const text = body.toString('latin1')
assert.match(text, /Content-Disposition: form-data; name="uploaded_by"\r\n\r\ndesktop\r\n/)
assert.match(text, /Content-Disposition: form-data; name="file"; filename="shot.png"\r\n/)
assert.match(text, /Content-Type: image\/png\r\n\r\n/)
assert.ok(text.endsWith(`\r\n--${boundary}--\r\n`))
// The binary payload must survive byte-for-byte inside the body.
assert.notEqual(body.indexOf(payload), -1)
})

test('buildKanbanAttachmentMultipart escapes quotes and strips paths from the filename', () => {
const { body } = buildKanbanAttachmentMultipart(
{
fileBuffer: Buffer.from('x'),
filename: '../dir/we"ird.txt'
},
'cafe'
)
const text = body.toString('utf8')
assert.match(text, /filename="we%22ird.txt"/)
assert.doesNotMatch(text, /name="uploaded_by"/)
assert.match(text, /Content-Type: application\/octet-stream\r\n/)
})

test('buildKanbanAttachmentMultipart rejects payloads over the backend cap with a 413-shaped error', () => {
const big = Buffer.alloc(MAX_KANBAN_ATTACHMENT_BYTES + 1)
assert.throws(
() => buildKanbanAttachmentMultipart({ fileBuffer: big, filename: 'big.bin' }),
/^Error: 413: .*25 MB limit/
)
})
Loading