/* * parser.example-cloud - reference JS plugin for the YunGet plugin market. * * WHAT IT DOES * Turns a link whose scheme is example:// into exactly one downloadable http(s) request: * * example://share/hello.bin -> https://example.com/share/hello.bin * example://share/hello.bin?name=a.txt -> same URL, saved as a.txt * example://share/hello.bin?resolve=1 -> ask the provider API for a direct link first * * `example` is not a real protocol: the host has to be told about it by the manifest, and * example.com is IANA's documentation domain, so nothing is actually downloaded from it. The * value of this plugin is that it is small enough to read end to end while still exercising * the whole ABI surface a real parser needs: declaring permissions, self-reporting the id and * version, returning a request descriptor, cleaning untrusted input, and staying inside the * sandbox (a bare file NAME, never a path). * * CONVENTIONS THIS FILE FOLLOWS (the market tooling depends on them) * 1. `plugin.requires` and `plugin.defineMeta` get FLAT object literals - no nested objects. * The host reads those two objects with a small non-JS scanner, so a nested literal would * hide `permissions`/`version` from the permission preview the user sees before installing. * 2. The id and version below MUST be byte-for-byte the same as turbodl-plugin.json and as the * version published in plugins.json. The store compares them against the downloaded, signed * script: a mismatch means the index lied about what it is shipping, so install is refused. * 3. `parse` returns null for anything it does not own, so the next parser in the chain gets a * turn; it never throws for ordinary "not my link" input. * * Source of truth for the ABI: TurboDL, turbo-plugin-js/src/main/resources/dev/turbodl/plugin/js/abi.js */ // Declared host capabilities. Everything the script touches must be listed here, or the call // fails at runtime with a `permission` error instead of silently doing nothing. plugin.requires({ permissions: ['http', 'crypto'], abiMajor: 1 }); // Self-reported identity. `name` is what the UI shows, `id`/`version` are what the market checks. plugin.defineMeta({ id: 'parser.example-cloud', name: 'Example Cloud Parser', version: '1.0.0' }); var SCHEME = 'example://'; var API_BASE = 'https://example.com'; /** The ABI hands a parser either the raw string or a descriptor object carrying `input`. */ function readInput(input) { if (input && input.input !== undefined) return String(input.input); if (typeof input === 'string') return input; return ''; } /** Split `example://[?]` into its parts. Returns null for links we do not own. */ function parseLink(text) { var raw = readInput(text).trim(); if (raw.length <= SCHEME.length) return null; if (raw.slice(0, SCHEME.length).toLowerCase() !== SCHEME) return null; var rest = raw.slice(SCHEME.length); var query = ''; var question = rest.indexOf('?'); if (question >= 0) { query = rest.slice(question + 1); rest = rest.slice(0, question); } // `example:///a` and `example://a` mean the same thing; an empty path is not our link. var path = rest.replace(/^\/+/, ''); if (path === '') return null; var params = parseQuery(query); return { path: path, name: params.name || '', resolve: params.resolve === '1' }; } /** Minimal query parsing. Values are decoded defensively - they are attacker-controlled text. */ function parseQuery(query) { var out = {}; if (!query) return out; var pairs = query.split('&'); for (var i = 0; i < pairs.length; i++) { var pair = pairs[i]; if (pair === '') continue; var equals = pair.indexOf('='); var key = equals < 0 ? pair : pair.slice(0, equals); var value = equals < 0 ? '' : pair.slice(equals + 1); try { value = decodeURIComponent(value.replace(/\+/g, ' ')); } catch (e) { // A malformed escape is not worth failing the whole parse over: keep the raw text and let // sanitizeFileName() turn it into a safe name. host.log.debug('undecodable query value kept as-is:', value); } out[decodeURIComponent(key)] = value; } return out; } /** * File names arrive from a link, so they are untrusted. Keep a bare name (no directory part) and * strip only what is actually dangerous: * - path separators and Windows-reserved characters (`<>:"|?*`); * - control characters; * - leading/trailing dots and spaces (".", "..", " . " all name a directory or are dropped by * the filesystem); * - any embedded "..". * Everything else is KEPT - a name is user-visible text, so Chinese, spaces and parentheses have * to survive: replacing "every character outside [A-Za-z0-9._-]" would turn a Chinese file name * into "mp4". The descriptor takes a NAME, not a path - the host picks the directory. */ function sanitizeFileName(name) { var last = String(name === undefined || name === null ? '' : name) .split('/') .pop() .split('\\') .pop(); var forbidden = '<>:"|?*/\\'; var cleaned = ''; for (var i = 0; i < last.length; i++) { var character = last.charAt(i); var code = last.charCodeAt(i); if (code < 32 || code === 127) continue; if (forbidden.indexOf(character) >= 0) continue; cleaned += character; } cleaned = cleaned.replace(/^[.\s]+/, '').replace(/[.\s]+$/, ''); cleaned = cleaned.replace(/\.\./g, '_'); if (cleaned === '') return ''; // 80 characters, not bytes: 80 CJK characters are 240 UTF-8 bytes, still inside the 255-byte // limit every common filesystem has. return cleaned.slice(0, 80); } /** Last resort name: derived from the path, so a link without `?name=` still saves sensibly. */ function defaultFileName(path) { return sanitizeFileName(path) || 'example.bin'; } /** * Optional provider round-trip, used only when the link asks for it with `resolve=1`. * * This is the shape a real share-link parser needs: exchange the share id for a direct URL * through the provider's API, and fall back to the deterministic URL when that fails for ANY * reason (offline, non-2xx, HTML instead of JSON, redirect that the host refuses to follow). * The call is bounded (3 s, 64 KiB) so a slow provider cannot stall the parse. */ function resolveThroughApi(path) { try { var response = host.http.request({ url: API_BASE + '/api/share/' + path + '.json', method: 'GET', as: 'text', timeoutMillis: 3000, maxBytes: 65536 }); if (!response || response.ok !== true || typeof response.body !== 'string') return null; var payload = JSON.parse(response.body); if (!payload || typeof payload.url !== 'string') return null; // Only https: the engine refuses other schemes anyway, and failing here is easier to read. if (payload.url.slice(0, 8) !== 'https://') return null; return { url: payload.url, fileName: payload.fileName }; } catch (e) { host.log.debug('resolve fell back to the direct URL:', e && e.message ? e.message : String(e)); return null; } } plugin.registerParser({ parse: function (input) { var link = parseLink(input); if (!link) return null; var url = API_BASE + '/' + link.path; var fileName = sanitizeFileName(link.name) || defaultFileName(link.path); if (link.resolve) { var resolved = resolveThroughApi(link.path); if (resolved) { url = resolved.url; fileName = sanitizeFileName(resolved.fileName) || fileName; } } // A content-derived dedupe key: the same link always maps to the same task, however the user // spells it. Doubles as a demonstration of the granted `crypto` capability. var digest = host.crypto.digest('SHA-256', url); return [{ url: url, fileName: fileName, stableKey: 'example-cloud-' + digest.hex.slice(0, 16), connections: 8, tags: { scheme: 'example', plugin: 'parser.example-cloud' } }]; } }); plugin.onInit(function () { host.log.info('ready: example:// links -> ' + API_BASE); });