/** * Sending files to a LocalSend peer. * * Two steps per the protocol: announce the file list with prepare-upload and * get a session plus one token per file, then POST each file's bytes to * upload. Anything that goes wrong after a session exists is followed by a * best-effort cancel, so the peer does not sit on a half-open transfer. * * node:http/https is used rather than fetch because LocalSend peers use * self-signed certificates by design, and per-request TLS options are not * reachable through fetch. */ import * as fs from "node:fs"; import * as http from "node:http"; import * as https from "node:https"; import type { DeviceInfo, FileDescriptor, LocalSendConfig, Peer, SendResult, SentFile, } from "../types.ts"; import { API_PREFIX, DEFAULT_PORT, PROTOCOL_VERSION, TransferRejectedError, } from "../types.ts"; import { guessFileType, randomId } from "../net.ts"; import { ensureTlsMaterial } from "../tls.ts"; const REQUEST_TIMEOUT_MS = 30_000; const UPLOAD_TIMEOUT_MS = 15 * 60 * 1000; /** One item to send: either a file on disk or in-memory content. */ export interface Payload { readonly fileName: string; readonly path?: string; readonly content?: Buffer; readonly size: number; readonly fileType?: string; readonly modified?: string; } export interface SendOptions { readonly peer: Peer; readonly payloads: ReadonlyArray; readonly pin?: string; readonly signal?: AbortSignal; } interface RawResponse { readonly status: number; readonly body: string; } /** Our own certificate, presented to encrypted peers. */ export interface ClientTls { readonly cert: string; readonly key: string; readonly fingerprint: string; } function requestOptions( peer: Peer, path: string, method: string, headers: http.OutgoingHttpHeaders, clientTls?: ClientTls | null, ) { return { host: peer.host, port: peer.port, path, method, headers, ...(peer.protocol === "https" ? { // LocalSend peers use self-signed certificates: the protocol's // trust anchor is the fingerprint, not a CA chain, so chain // validation would reject every legitimate peer. rejectUnauthorized: false, // The receiver identifies the sender by the fingerprint of the // certificate it presents, so its TLS server asks for a client // certificate. Without one the handshake ends in a // "certificate required" alert before any HTTP is exchanged. ...(clientTls ? { cert: clientTls.cert, key: clientTls.key } : {}), } : {}), }; } function send( peer: Peer, path: string, method: string, body: Buffer | fs.ReadStream | undefined, headers: http.OutgoingHttpHeaders, timeoutMs: number, signal?: AbortSignal, clientTls?: ClientTls | null, ): Promise { const transport = peer.protocol === "https" ? https : http; return new Promise((resolve, reject) => { const req = transport.request( requestOptions(peer, path, method, headers, clientTls), (res) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8"), }), ); res.on("error", reject); }, ); req.setTimeout(timeoutMs, () => { req.destroy( new Error( `${peer.alias} did not respond within ${Math.round(timeoutMs / 1000)}s.`, ), ); }); req.on("error", (err: NodeJS.ErrnoException) => { if (err.code === "ECONNREFUSED") { reject( new Error( `${peer.alias} (${peer.host}:${peer.port}) refused the connection. Is LocalSend still open on that device?`, ), ); return; } if (err.code === "EHOSTUNREACH" || err.code === "ENETUNREACH") { reject(new Error(`${peer.host} is not reachable from this machine.`)); return; } reject(err); }); const abort = () => req.destroy(new Error("Transfer cancelled.")); signal?.addEventListener("abort", abort, { once: true }); req.on("close", () => signal?.removeEventListener("abort", abort)); if (!body) { req.end(); } else if (Buffer.isBuffer(body)) { req.end(body); } else { body.on("error", (err) => req.destroy(err)); body.pipe(req); } }); } /** Translate the protocol's status codes into something a human can act on. */ function explainPrepareFailure(status: number, alias: string, body: string): TransferRejectedError { switch (status) { case 400: return new TransferRejectedError(status, `${alias} rejected the file list as invalid (400). ${body}`.trim()); case 401: return new TransferRejectedError( status, `${alias} requires a PIN, or the PIN was wrong. Ask for the PIN shown on that device and pass it as 'pin'.`, ); case 403: return new TransferRejectedError(status, `${alias} declined the transfer.`); case 409: return new TransferRejectedError( status, `${alias} is busy with another transfer. Wait for it to finish and try again.`, ); case 429: return new TransferRejectedError(status, `${alias} is rate limiting requests. Wait a moment and try again.`); default: return new TransferRejectedError( status, `${alias} could not accept the transfer (HTTP ${status}). ${body}`.trim(), ); } } export interface DescribedPayloads { readonly files: Record; /** file id -> the payload to upload for it. */ readonly byId: Map; } export function buildFileDescriptors( payloads: ReadonlyArray, ): DescribedPayloads { const files: Record = {}; const byId = new Map(); for (const payload of payloads) { const id = randomId(); files[id] = { id, fileName: payload.fileName, size: payload.size, fileType: payload.fileType ?? guessFileType(payload.fileName), ...(payload.modified && { metadata: { modified: payload.modified } }), }; // Keyed by id, so sending two different files that happen to share a // name still uploads the right bytes for each. byId.set(id, payload); } return { files, byId }; } /** * The `info` object describing us in a prepare-upload request. * * Every field here is required by the protocol, and the receiver parses the * body with a strict deserialiser: a single missing field is rejected as * invalid JSON rather than ignored. In particular `port` must always be a * number -- the local setting of 0 means "pick a free port per transfer", * which is meaningless to a peer and would serialise away entirely. */ export function buildSenderInfo( config: LocalSendConfig, peer: Peer, clientTls?: ClientTls | null, ): DeviceInfo { return { alias: config.alias || "pi", version: PROTOCOL_VERSION, deviceModel: config.deviceModel || "pi", deviceType: config.deviceType || "desktop", // Over TLS the receiver checks this against the certificate we presented, // so the two must be the same value. fingerprint: clientTls ? clientTls.fingerprint : config.fingerprint, port: config.port || DEFAULT_PORT, // Describe the connection we are actually speaking on, so the protocol // and the fingerprint tell the receiver a consistent story. protocol: peer.protocol, download: false, }; } export async function sendFiles( config: LocalSendConfig, options: SendOptions, ): Promise { const { peer, payloads } = options; if (payloads.length === 0) { throw new Error("Nothing to send."); } // An encrypted peer demands a client certificate during the handshake, so // it has to exist before the first byte goes out. let clientTls: ClientTls | null = null; if (peer.protocol === "https") { clientTls = ensureTlsMaterial(); if (!clientTls) { throw new Error( `${peer.alias} uses an encrypted connection, which requires this machine to present its own certificate, and none could be created. Check that ~/.pi is writable.`, ); } } const { files, byId } = buildFileDescriptors(payloads); const prepareBody = Buffer.from( JSON.stringify({ info: buildSenderInfo(config, peer, clientTls), files }), "utf-8", ); const pinQuery = options.pin ? `?pin=${encodeURIComponent(options.pin)}` : ""; const prepared = await send( peer, `${API_PREFIX}/prepare-upload${pinQuery}`, "POST", prepareBody, { "Content-Type": "application/json", "Content-Length": prepareBody.length }, REQUEST_TIMEOUT_MS, options.signal, clientTls, ); // 204 means the receiver wants none of the files -- not an error, but // nothing was transferred either. if (prepared.status === 204) { return { peer, sessionId: "", files: [], bytesSent: 0 }; } if (prepared.status !== 200) { throw explainPrepareFailure(prepared.status, peer.alias, prepared.body); } let sessionId = ""; let tokens: Record = {}; try { const parsed = JSON.parse(prepared.body); sessionId = String(parsed.sessionId ?? ""); tokens = parsed.files ?? {}; } catch { throw new Error(`${peer.alias} sent an unreadable prepare-upload response.`); } if (!sessionId) { throw new Error(`${peer.alias} did not return a session id.`); } const results: SentFile[] = []; let bytesSent = 0; try { for (const [fileId, token] of Object.entries(tokens)) { const payload = byId.get(fileId); if (!payload) continue; const query = `?sessionId=${encodeURIComponent(sessionId)}&fileId=${encodeURIComponent(fileId)}&token=${encodeURIComponent(token)}`; const body = payload.content ?? fs.createReadStream(payload.path!); const uploaded = await send( peer, `${API_PREFIX}/upload${query}`, "POST", body, { "Content-Type": "application/octet-stream", "Content-Length": payload.size, }, UPLOAD_TIMEOUT_MS, options.signal, clientTls, ); if (uploaded.status >= 200 && uploaded.status < 300) { results.push({ fileName: payload.fileName, size: payload.size, ok: true }); bytesSent += payload.size; } else { results.push({ fileName: payload.fileName, size: payload.size, ok: false, error: `HTTP ${uploaded.status}${uploaded.body ? `: ${uploaded.body}` : ""}`, }); } } } catch (err) { await cancelSession(peer, sessionId, clientTls).catch(() => {}); throw err; } // A partial failure leaves the peer waiting for the rest of the session. if (results.some((file) => !file.ok)) { await cancelSession(peer, sessionId, clientTls).catch(() => {}); } return { peer, sessionId, files: results, bytesSent }; } export async function cancelSession( peer: Peer, sessionId: string, clientTls?: ClientTls | null, ): Promise { await send( peer, `${API_PREFIX}/cancel?sessionId=${encodeURIComponent(sessionId)}`, "POST", undefined, {}, 5000, undefined, clientTls, ); }