fmode-file-read.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. // fmode-file-read v0.1.0 — 文件正文代理(读云盘文本)
  2. //
  3. // ## 为什么需要它
  4. //
  5. // 云盘 s3.fmode.cn 是 **public-read**(curl 能拿到 200 和内容),但**完全没配 CORS**
  6. // (实测响应里没有任何 Access-Control-* 头)。浏览器里页面脚本 fetch 它
  7. // 必然被拦,界面只能显示「加载失败:Failed to fetch」——
  8. // 用户点开容器交付的 .md / .txt 什么都看不到。
  9. //
  10. // 浏览器**顶层导航**(新窗口打开)不受 CORS 限制,那是兜底;
  11. // 但要在**页内渲染 Markdown** 就必须拿到文本,只能服务端代取。
  12. //
  13. // 本函数:服务端 fetch 云盘文件 → 原样返回文本。浏览器只调
  14. // server.fmode.cn/api/functions(该域已配 ACAO:*),不直接碰 S3。
  15. //
  16. // ## 安全约束(三条缺一不可)
  17. //
  18. // ① **SSRF 防护**:只放行 s3.fmode.cn / file-cloud.fmode.cn 两个域名,
  19. // 与 fmode-file-hub 的 _isCloudUrl 同口径。否则这个接口会变成
  20. // "随便读内网地址"的开放代理。
  21. // ② **鉴权**:必须带有效 session token(与 fmode-file-hub 同做法)。
  22. // ③ **大小上限**:文本预览用不了大文件,超过 MAX_TEXT_BYTES 直接拒绝,
  23. // 防止一个 20MB 的二进制把函数内存打爆。
  24. //
  25. // ## 调用
  26. //
  27. // POST /api/functions {token, id:<Function objectId>, params:{action:'readText', url}}
  28. // 返回 {code:200, text, truncated, bytes}
  29. //
  30. // ## 注意
  31. //
  32. // · 平台约定:必须显式 response.status(200).json(...),不能靠自动返回
  33. // · 云盘对象是 public-read,所以服务端 fetch **不需要**带签名
  34. var MAX_TEXT_BYTES = 2 * 1024 * 1024; // 2MB:文本预览足够,再大就不是"预览"了
  35. var FETCH_TIMEOUT_MS = 15000;
  36. function _ok(response, data) {
  37. var body = { code: 200 };
  38. if (data) for (var k in data) body[k] = data[k];
  39. response.status(200).json(body);
  40. }
  41. function _fail(response, code, msg) {
  42. response.status(200).json({ code: code, msg: msg });
  43. }
  44. function _str(v, max) {
  45. if (v === undefined || v === null) return '';
  46. var s = String(v).trim();
  47. return max && s.length > max ? s.slice(0, max) : s;
  48. }
  49. function _sessionToken() {
  50. // 与 fmodeagent-status / fmode-file-hub 同口径:请求头优先(平台实测只保留请求头)
  51. if (request.headers && request.headers['x-parse-session-token']) {
  52. return request.headers['x-parse-session-token'];
  53. }
  54. if (params && params.token) return params.token;
  55. if (request.body && request.body.token) return request.body.token;
  56. return null;
  57. }
  58. async function _getUser(t) {
  59. if (!t) return null;
  60. if (request.user && request.user.id) return request.user;
  61. try {
  62. var q = new Parse.Query(Parse.Session);
  63. q.equalTo('sessionToken', t);
  64. q.include('user');
  65. var s = await q.first({ useMasterKey: true });
  66. return s ? s.get('user') : null;
  67. } catch (e) {
  68. return null;
  69. }
  70. }
  71. /** 与 fmode-file-hub 的 _isCloudUrl 同口径:只认本平台云盘域名 */
  72. function _isCloudUrl(u) {
  73. return /^https:\/\/(s3|file-cloud)\.fmode\.cn\//i.test(u);
  74. }
  75. /** 带超时的 fetch(平台支持全局 fetch;不加超时的话慢文件会拖死函数) */
  76. async function _fetchWithTimeout(url) {
  77. var controller = typeof AbortController === 'function' ? new AbortController() : null;
  78. var timer = controller ? setTimeout(function () { controller.abort(); }, FETCH_TIMEOUT_MS) : null;
  79. try {
  80. return await fetch(url, controller ? { signal: controller.signal } : undefined);
  81. } finally {
  82. if (timer) clearTimeout(timer);
  83. }
  84. }
  85. /**
  86. * readText —— 读云盘文本文件
  87. *
  88. * params: { url }
  89. */
  90. async function opReadText(user, response) {
  91. var p = params || {};
  92. var url = _str(p.url, 2000);
  93. if (!url) return _fail(response, 400, 'url required');
  94. /* ① SSRF:只放行本平台云盘域名 */
  95. if (!_isCloudUrl(url)) return _fail(response, 400, 'only cloud storage urls are allowed');
  96. var res;
  97. try {
  98. res = await _fetchWithTimeout(url);
  99. } catch (e) {
  100. return _fail(response, 502, 'fetch failed: ' + (e && e.message ? e.message : 'unknown'));
  101. }
  102. if (!res.ok) return _fail(response, res.status === 404 ? 404 : 502, 'upstream ' + res.status);
  103. /* ③ 大小上限:先看 Content-Length,没有就按实际读取量截断 */
  104. var len = Number(res.headers.get('content-length'));
  105. if (isFinite(len) && len > MAX_TEXT_BYTES) {
  106. return _fail(response, 413, 'file too large for text preview');
  107. }
  108. var buf;
  109. try {
  110. buf = await res.arrayBuffer();
  111. } catch (e) {
  112. return _fail(response, 502, 'read failed');
  113. }
  114. var truncated = false;
  115. var bytes = buf.byteLength;
  116. if (bytes > MAX_TEXT_BYTES) {
  117. buf = buf.slice(0, MAX_TEXT_BYTES);
  118. bytes = MAX_TEXT_BYTES;
  119. truncated = true;
  120. }
  121. /*
  122. * 只返回文本。二进制(图片/压缩包/PDF)不该走这个接口 ——
  123. * 交给原地址直接展示即可,这里如实告知而不是返回一堆乱码。
  124. */
  125. var text;
  126. try {
  127. text = new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(buf));
  128. } catch (e) {
  129. return _fail(response, 415, 'not a utf-8 text file');
  130. }
  131. return _ok(response, { text: text, truncated: truncated, bytes: bytes });
  132. }
  133. // ==================== 入口 ====================
  134. function handler(request, response) {
  135. (async function () {
  136. var token = _sessionToken();
  137. if (!token) return _fail(response, 401, 'unauthorized');
  138. var user = await _getUser(token);
  139. if (!user) return _fail(response, 401, 'invalid session');
  140. var action = _str(params && params.action, 40) || 'readText';
  141. if (action === 'readText') return opReadText(user, response);
  142. return _fail(response, 400, 'unknown action: ' + action);
  143. })().catch(function (e) {
  144. _fail(response, 500, 'server error: ' + (e && e.message ? e.message : 'unknown'));
  145. });
  146. }