Hub stage 2: install, update and uninstall apps on several servers
Writes use only <ID>_ADMIN_TOKEN and need the apps.write capability. Store URLs are resolved from each server's own listing. The config is saved to history before an uninstall. Zip uploads are fanned out to the servers. Every target's result is audited. The console gets a row menu on the matrix, a target picker, a results dialog with Retry failed, the store and install from file in All servers mode, and single-server actions for writers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3d8df08fa0
commit
6acd2d656d
27 files changed
+1991
-71
No files matched your search
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
A small multipart/form-data reader for the app zip uploads, and a body reader with a size
|
||||
limit. No dependencies; the whole body stays in memory (the limit is 20 MB).
|
||||
*/
|
||||
import type http from 'node:http'
|
||||
|
||||
export const MAX_UPLOAD = 20 * 1024 * 1024
|
||||
|
||||
export class TooLarge extends Error {
|
||||
constructor(limit: number) {
|
||||
super(`The upload is larger than ${Math.floor(limit / (1024 * 1024))} MB.`)
|
||||
this.name = 'TooLarge'
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Reads the request body, counting bytes as they arrive; throws `TooLarge` above `limit`.
|
||||
Past the limit nothing more is kept, but the rest is still read (up to DRAIN more bytes), so the
|
||||
client gets its 413 instead of a reset connection.
|
||||
*/
|
||||
const DRAIN = 32 * 1024 * 1024
|
||||
|
||||
export async function readLimited(req: http.IncomingMessage, limit: number = MAX_UPLOAD): Promise<Buffer> {
|
||||
const declared = Number(req.headers['content-length'])
|
||||
if (Number.isFinite(declared) && declared > limit + DRAIN) throw new TooLarge(limit)
|
||||
const chunks: Buffer[] = []
|
||||
let size = 0
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length
|
||||
if (size > limit + DRAIN) throw new TooLarge(limit)
|
||||
if (size <= limit) chunks.push(chunk as Buffer)
|
||||
}
|
||||
if (size > limit) throw new TooLarge(limit)
|
||||
return Buffer.concat(chunks)
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
field: string
|
||||
filename: string
|
||||
type: string
|
||||
data: Buffer
|
||||
}
|
||||
|
||||
export interface Multipart {
|
||||
fields: Record<string, string>
|
||||
files: UploadedFile[]
|
||||
}
|
||||
|
||||
export function boundaryOf(contentType: string): string | null {
|
||||
const match = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)
|
||||
return match === null ? null : (match[1] ?? match[2])
|
||||
}
|
||||
|
||||
const CRLF2 = Buffer.from('\r\n\r\n')
|
||||
|
||||
/* Throws on anything that is not a well-formed multipart body. */
|
||||
export function parseMultipart(body: Buffer, contentType: string): Multipart {
|
||||
const boundary = boundaryOf(contentType)
|
||||
if (boundary === null || !/^multipart\/form-data/i.test(contentType)) throw new Error('Expected multipart/form-data with a boundary.')
|
||||
const delimiter = Buffer.from(`--${boundary}`)
|
||||
const result: Multipart = { fields: {}, files: [] }
|
||||
|
||||
let at = body.indexOf(delimiter)
|
||||
if (at < 0) throw new Error('The multipart body has no boundary.')
|
||||
for (;;) {
|
||||
at += delimiter.length
|
||||
// "--" after a delimiter closes the body.
|
||||
if (body[at] === 0x2d && body[at + 1] === 0x2d) return result
|
||||
if (body[at] !== 0x0d || body[at + 1] !== 0x0a) throw new Error('Malformed multipart body.')
|
||||
at += 2
|
||||
const headerEnd = body.indexOf(CRLF2, at)
|
||||
if (headerEnd < 0) throw new Error('Malformed multipart part.')
|
||||
const headers = body.subarray(at, headerEnd).toString('utf8')
|
||||
const start = headerEnd + CRLF2.length
|
||||
// The part ends at the next CRLF + delimiter, so binary content can hold anything else.
|
||||
const next = body.indexOf(Buffer.concat([Buffer.from('\r\n'), delimiter]), start)
|
||||
if (next < 0) throw new Error('The multipart body is not terminated.')
|
||||
const data = body.subarray(start, next)
|
||||
|
||||
const disposition = /^content-disposition:\s*form-data;(.*)$/im.exec(headers)?.[1] ?? ''
|
||||
const name = /\bname="([^"]*)"/i.exec(disposition)?.[1]
|
||||
const filename = /\bfilename="([^"]*)"/i.exec(disposition)?.[1]
|
||||
if (name === undefined) throw new Error('A multipart part has no name.')
|
||||
if (filename === undefined) result.fields[name] = data.toString('utf8')
|
||||
else {
|
||||
const type = /^content-type:\s*(.+)$/im.exec(headers)?.[1].trim() ?? 'application/octet-stream'
|
||||
result.files.push({ field: name, filename, type, data: Buffer.from(data) })
|
||||
}
|
||||
at = next + 2
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user