| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- // 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:<Function objectId>, 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'));
- });
- }
|