Skip to content

src/upload-from-url.ts

201 lines · 6.9 KB · TypeScript
Raw
/**
 * Save a remote HTTP(S) URL into Dropbox via `files/save_url` (Dropbox fetches the URL).
 * Use when MCP chat cannot carry large base64 (phone / MCP-only). Requires `confirm: true`.
 * Polls `files/save_url/check_job_status` when Dropbox returns an async job.
 *
 * @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`).
 * @param params.url - Public HTTP(S) URL Dropbox can fetch.
 * @param params.confirm - Required `true` to perform the mutating call.
 * @param params.dryRun - When true, return the planned request without calling Dropbox.
 * @param params.pollMs - Poll interval for async jobs (default 1500).
 * @param params.maxPolls - Max poll attempts (default 40).
 * @returns Dropbox file metadata (or dry-run / async failure details).
 *
 * @example
 * import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url'
 * const result = await uploadFromUrl({
 *   path: '/Apps/Kody/inbox/photo.webp',
 *   url: 'https://example.com/photo.webp',
 *   confirm: true,
 * })
 */

import { DropboxApiError, dropboxRpc, request } from './request.ts'
import { APP_FOLDER_ROOT, suggestAppFolderPath } from './paths.ts'
import type { DropboxRequestResult, JsonRecord } from './types.ts'
import {
	optionalNumber,
	requireDropboxPath,
	requireRecord,
	requireString,
} from './types.ts'

export type UploadFromUrlInput = {
	path: string
	url: string
	confirm?: boolean
	dryRun?: boolean
	pollMs?: number
	maxPolls?: number
}

/**
 * Save a remote HTTP(S) URL into Dropbox via `files/save_url` (Dropbox fetches the URL).
 * Use when MCP chat cannot carry large base64 (phone / MCP-only). Requires `confirm: true`.
 * Polls `files/save_url/check_job_status` when Dropbox returns an async job.
 *
 * @param params.path - Destination Dropbox path starting with `/` (prefer `/Apps/Kody/...`).
 * @param params.url - Public HTTP(S) URL Dropbox can fetch.
 * @param params.confirm - Required `true` to perform the mutating call.
 * @param params.dryRun - When true, return the planned request without calling Dropbox.
 * @param params.pollMs - Poll interval for async jobs (default 1500).
 * @param params.maxPolls - Max poll attempts (default 40).
 * @returns Dropbox file metadata (or dry-run / async failure details).
 *
 * @example
 * import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url'
 * const result = await uploadFromUrl({
 *   path: '/Apps/Kody/inbox/photo.webp',
 *   url: 'https://example.com/photo.webp',
 *   confirm: true,
 * })
 */
export async function uploadFromUrl(
	input: UploadFromUrlInput,
): Promise<DropboxRequestResult | JsonRecord> {
	const path = requireDropboxPath(input.path)
	const url = requireString(input.url, 'url')
	if (!/^https?:\/\//i.test(url)) {
		throw new Error('url must be an http(s) URL Dropbox can fetch.')
	}
	const pollMs = optionalNumber(input.pollMs, 'pollMs') ?? 1500
	const maxPolls = optionalNumber(input.maxPolls, 'maxPolls') ?? 40
	if (!Number.isInteger(pollMs) || pollMs < 200 || pollMs > 30_000) {
		throw new Error('pollMs must be an integer from 200 through 30000.')
	}
	if (!Number.isInteger(maxPolls) || maxPolls < 1 || maxPolls > 120) {
		throw new Error('maxPolls must be an integer from 1 through 120.')
	}

	if (input.dryRun) {
		return {
			dryRun: true,
			endpoint: 'files/save_url',
			path,
			url,
			poll: 'files/save_url/check_job_status when async_job_id is returned',
			authorization: '[managed dropbox OAuth integration]',
		}
	}

	if (input.confirm !== true) {
		throw new Error(
			'upload-from-url may mutate Dropbox. Pass confirm: true only after explicit user approval, or use dryRun: true.',
		)
	}

	try {
		const started = await request({
			endpoint: 'files/save_url',
			body: { path, url },
			confirm: true,
		})
		const body = unwrapBody(started)
		const tag = body['.tag']
		if (tag === 'complete') {
			const { ['.tag']: _tag, ...metadata } = body
			return metadata
		}
		if (typeof body.async_job_id === 'string' && body.async_job_id) {
			return await pollSaveUrlJob(body.async_job_id, pollMs, maxPolls)
		}
		if (tag === 'async_job_id' && typeof body.async_job_id === 'string') {
			return await pollSaveUrlJob(body.async_job_id, pollMs, maxPolls)
		}
		return body
	} catch (error) {
		throw enhancePathError(error, path)
	}
}

async function pollSaveUrlJob(
	asyncJobId: string,
	pollMs: number,
	maxPolls: number,
): Promise<JsonRecord> {
	for (let attempt = 0; attempt < maxPolls; attempt += 1) {
		const status = await dropboxRpc<JsonRecord>('files/save_url/check_job_status', {
			async_job_id: asyncJobId,
		})
		const tag = status['.tag']
		if (tag === 'complete') {
			const { ['.tag']: _tag, ...metadata } = status
			return { ...metadata, async_job_id: asyncJobId }
		}
		if (tag === 'failed') {
			throw new Error(`files/save_url job failed: ${JSON.stringify(status)}`)
		}
		await sleep(pollMs)
	}
	throw new Error(
		`files/save_url job ${asyncJobId} still in progress after ${maxPolls} polls. Check later with files/save_url/check_job_status.`,
	)
}

function unwrapBody(result: DropboxRequestResult | JsonRecord): JsonRecord {
	if (result && typeof result === 'object' && 'body' in result && 'ok' in result) {
		const body = (result as DropboxRequestResult).body
		if (!body || typeof body !== 'object' || Array.isArray(body)) {
			throw new Error('Unexpected files/save_url response body.')
		}
		return body as JsonRecord
	}
	return result as JsonRecord
}

function sleep(ms: number): Promise<void> {
	return new Promise((resolve) => setTimeout(resolve, ms))
}

function enhancePathError(error: unknown, path: string): Error {
	if (!(error instanceof DropboxApiError)) {
		return error instanceof Error ? error : new Error(String(error))
	}
	const details = JSON.stringify(error.details ?? '')
	const looksLikePath =
		error.status === 409 ||
		/path\/not_found|path\/malformed|not_found/i.test(details) ||
		/path\/not_found|path\/malformed|not_found/i.test(error.message)
	if (!looksLikePath) return error
	const suggestion = suggestAppFolderPath(path)
	const hint =
		suggestion !== path
			? ` Path may need the Kody app-folder prefix. Try "${suggestion}" (APP_FOLDER_ROOT is ${APP_FOLDER_ROOT}).`
			: ` Writable Kody files usually live under ${APP_FOLDER_ROOT}/….`
	return new DropboxApiError(`${error.message}.${hint}`, {
		status: error.status,
		statusText: error.statusText,
		requestId: error.requestId,
		details: error.details,
	})
}

/**
 * Save a remote URL into Dropbox via files/save_url.
 * Requires `confirm: true`; use `dryRun: true` to preview.
 * @example
 * import uploadFromUrl from 'kody:@kentcdodds/dropbox/upload-from-url'
 * const result = await uploadFromUrl({
 *   path: '/Apps/Kody/inbox/photo.webp',
 *   url: 'https://example.com/photo.webp',
 *   confirm: true,
 * })
 */
export default async function uploadFromUrlEntrypoint(
	params: Partial<UploadFromUrlInput> & Record<string, unknown> = {},
) {
	const input = requireRecord(params, 'upload-from-url')
	requireDropboxPath(input.path)
	requireString(input.url, 'url')
	return uploadFromUrl(input as unknown as UploadFromUrlInput)
}