// fmode-file-read v0.1.0 — 文件正文代理(读云盘文本) // // ## 为什么需要它 // // 云盘 s3.fmode.cn 是 **public-read**(curl 能拿到 200 和内容),但**完全没配 CORS** // (实测响应里没有任何 Access-Control-* 头)。浏览器里页面脚本 fetch 它 // 必然被拦,界面只能显示「加载失败:Failed to fetch」—— // 用户点开容器交付的 .md / .txt 什么都看不到。 // // 浏览器**顶层导航**(新窗口打开)不受 CORS 限制,那是兜底; // 但要在**页内渲染 Markdown** 就必须拿到文本,只能服务端代取。 // // 本函数:服务端 fetch 云盘文件 → 原样返回文本。浏览器只调 // server.fmode.cn/api/functions(该域已配 ACAO:*),不直接碰 S3。 // // ## 安全约束(三条缺一不可) // // ① **SSRF 防护**:只放行 s3.fmode.cn / file-cloud.fmode.cn 两个域名, // 与 fmode-file-hub 的 _isCloudUrl 同口径。否则这个接口会变成 // "随便读内网地址"的开放代理。 // ② **鉴权**:必须带有效 session token(与 fmode-file-hub 同做法)。 // ③ **大小上限**:文本预览用不了大文件,超过 MAX_TEXT_BYTES 直接拒绝, // 防止一个 20MB 的二进制把函数内存打爆。 // // ## 调用 // // POST /api/functions {token, id:, params:{action:'readText', url}} // 返回 {code:200, text, truncated, bytes} // // ## 注意 // // · 平台约定:必须显式 response.status(200).json(...),不能靠自动返回 // · 云盘对象是 public-read,所以服务端 fetch **不需要**带签名 var MAX_TEXT_BYTES = 2 * 1024 * 1024; // 2MB:文本预览足够,再大就不是"预览"了 var FETCH_TIMEOUT_MS = 15000; function _ok(response, data) { var body = { code: 200 }; if (data) for (var k in data) body[k] = data[k]; response.status(200).json(body); } function _fail(response, code, msg) { response.status(200).json({ code: code, msg: msg }); } function _str(v, max) { if (v === undefined || v === null) return ''; var s = String(v).trim(); return max && s.length > max ? s.slice(0, max) : s; } function _sessionToken() { // 与 fmodeagent-status / fmode-file-hub 同口径:请求头优先(平台实测只保留请求头) if (request.headers && request.headers['x-parse-session-token']) { return request.headers['x-parse-session-token']; } if (params && params.token) return params.token; if (request.body && request.body.token) return request.body.token; return null; } async function _getUser(t) { if (!t) return null; if (request.user && request.user.id) return request.user; try { var q = new Parse.Query(Parse.Session); q.equalTo('sessionToken', t); q.include('user'); var s = await q.first({ useMasterKey: true }); return s ? s.get('user') : null; } catch (e) { return null; } } /** 与 fmode-file-hub 的 _isCloudUrl 同口径:只认本平台云盘域名 */ function _isCloudUrl(u) { return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u); } /** 带超时的 fetch(平台支持全局 fetch;不加超时的话慢文件会拖死函数) */ async function _fetchWithTimeout(url) { var controller = typeof AbortController === 'function' ? new AbortController() : null; var timer = controller ? setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS) : null; try { return await fetch(url, controller ? { signal: controller.signal } : undefined); } finally { if (timer) clearTimeout(timer); } } /** * readText —— 读云盘文本文件 * * params: { url } */ async function opReadText(user, response) { var p = params || {}; var url = _str(p.url, 2000); if (!url) return _fail(response, 400, 'url required'); /* ① SSRF:只放行本平台云盘域名 */ if (!_isCloudUrl(url)) return _fail(response, 400, 'only cloud storage urls are allowed'); var res; try { res = await _fetchWithTimeout(url); } catch (e) { return _fail(response, 502, 'fetch failed: ' + (e && e.message ? e.message : 'unknown')); } if (!res.ok) return _fail(response, res.status === 404 ? 404 : 502, 'upstream ' + res.status); /* ③ 大小上限:先看 Content-Length,没有就按实际读取量截断 */ var len = Number(res.headers.get('content-length')); if (isFinite(len) && len > MAX_TEXT_BYTES) { return _fail(response, 413, 'file too large for text preview'); } var buf; try { buf = await res.arrayBuffer(); } catch (e) { return _fail(response, 502, 'read failed'); } var truncated = false; var bytes = buf.byteLength; if (bytes > MAX_TEXT_BYTES) { buf = buf.slice(0, MAX_TEXT_BYTES); bytes = MAX_TEXT_BYTES; truncated = true; } /* * 只返回文本。二进制(图片/压缩包/PDF)不该走这个接口 —— * 交给原地址直接展示即可,这里如实告知而不是返回一堆乱码。 */ var text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(buf)); } catch (e) { return _fail(response, 415, 'not a utf-8 text file'); } return _ok(response, { text: text, truncated: truncated, bytes: bytes }); } // ==================== 入口 ==================== function handler(request, response) { (async function () { var token = _sessionToken(); if (!token) return _fail(response, 401, 'unauthorized'); var user = await _getUser(token); if (!user) return _fail(response, 401, 'invalid session'); var action = _str(params && params.action, 40) || 'readText'; if (action === 'readText') return opReadText(user, response); return _fail(response, 400, 'unknown action: ' + action); })().catch(function (e) { _fail(response, 500, 'server error: ' + (e && e.message ? e.message : 'unknown')); }); }