mock-service-cli 4.3.1 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,8 +73,9 @@ npx mock-service-cli [options] [path]
73
73
  | `-R` 或 `--static-server` | 启用静态服务器,指定静态资源目录 | - |
74
74
  | `-w` 或 `--open` | 自动打开 API 概览页、文件浏览器页 | false |
75
75
  | `-e` 或 `--explorer` | 启用文件浏览器服务器,指定要浏览的目录 | ./ |
76
- | `--edit` | 启用文件浏览器的新建、重命名和删除操作 | false |
77
- | `--host` | 将全部 Web 服务暴露到所有 IPv4 网卡 | false |
76
+ | `--edit` | 启用文件浏览器的新建、重命名、删除和上传操作 | false |
77
+ | `--auth <password>` | 为文件浏览器启用密码认证(仅可搭配 `--explorer`) | - |
78
+ | `--host [allowlist-file]` | 暴露全部 IPv4 网卡;传入文件时仅允许白名单 IP 访问 | false |
78
79
 
79
80
  ## 📖 使用示例
80
81
 
@@ -136,13 +137,26 @@ mock-service-cli -e ./ -p 9090
136
137
  # 启用文件编辑操作
137
138
  mock-service-cli -e ./ --edit
138
139
 
140
+ # 启用文件浏览器密码认证
141
+ mock-service-cli -e ./ --auth 'local-password'
142
+
139
143
  # 暴露到局域网(编辑操作仍需额外传入 --edit)
140
144
  mock-service-cli -e ./ --host
145
+
146
+ # 仅向白名单 IP 暴露服务
147
+ mock-service-cli -e ./ --host ./allowed-ips.txt
141
148
  ```
142
149
 
143
150
  默认情况下,Mock、SPA、Static 和 File Explorer 服务只监听 `127.0.0.1`。
144
- `--host` 会使这些服务监听所有 IPv4 网卡,请仅在可信网络中使用。文件浏览器默认只读;
145
- 必须在启动时传入 `--edit` 才能创建、重命名或删除文件。
151
+ `--host` 会使这些服务监听所有 IPv4 网卡,请仅在可信网络中使用。传入白名单文件时,服务仍监听全部 IPv4 网卡,
152
+ 但仅允许 `localhost`、`127.0.0.1`、`::1` 及文件中的 IP/CIDR 访问;空行和行首 `#` 注释会忽略,非法或空白名单会阻止启动。
153
+ 该规则同时作用于 Mock、SPA、Static、File Explorer 和 Socket 服务。
154
+
155
+ 文件浏览器默认只读;必须在启动时传入 `--edit` 才能创建、重命名、删除或上传。上传文件支持多选,上传文件夹会保留目录层级;
156
+ 同名路径不会覆盖,其他无冲突文件仍会继续上传。单文件最大 20MB,单次请求最大 20 个文件和 100MB。
157
+
158
+ `--auth` 会在文件浏览器中显示登录页,并在每次页面加载和每个 API 请求时验证密码。密码仅保存在当前浏览器标签页的
159
+ `sessionStorage` 中,但命令行参数仍可能暴露在 shell 历史和进程列表里,因此只适用于可信本机或局域网环境。
146
160
 
147
161
  ## 📝 编写 Mock 文件
148
162
 
@@ -0,0 +1,59 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>文件浏览器登录</title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f4f6f8; color: #202124; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
10
+ main { width: min(360px, calc(100vw - 32px)); padding: 28px; background: #fff; border: 1px solid #dde1e6; border-radius: 8px; box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08); }
11
+ h1 { margin: 0 0 22px; font-size: 20px; }
12
+ label { display: block; margin-bottom: 8px; font-size: 14px; }
13
+ input { width: 100%; height: 38px; padding: 8px 10px; border: 1px solid #b9c1cb; border-radius: 4px; font: inherit; }
14
+ button { width: 100%; height: 38px; margin-top: 16px; border: 0; border-radius: 4px; background: #276ef1; color: #fff; cursor: pointer; font: inherit; }
15
+ button:disabled { opacity: 0.6; cursor: wait; }
16
+ #error { min-height: 20px; margin: 12px 0 0; color: #c62828; font-size: 13px; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <main>
21
+ <h1>文件浏览器</h1>
22
+ <form id="loginForm">
23
+ <label for="password">访问密码</label>
24
+ <input id="password" type="password" autocomplete="current-password" required autofocus />
25
+ <button id="submitButton" type="submit">登录</button>
26
+ <p id="error" role="alert"></p>
27
+ </form>
28
+ </main>
29
+ <script>
30
+ const storageKey = 'mock-service-cli.file-explorer.password';
31
+ const form = document.getElementById('loginForm');
32
+ const passwordInput = document.getElementById('password');
33
+ const submitButton = document.getElementById('submitButton');
34
+ const errorElement = document.getElementById('error');
35
+
36
+ form.addEventListener('submit', async event => {
37
+ event.preventDefault();
38
+ const password = passwordInput.value;
39
+ submitButton.disabled = true;
40
+ errorElement.textContent = '';
41
+ try {
42
+ const response = await fetch('/__api/auth/verify', {
43
+ method: 'POST',
44
+ headers: { 'Content-Type': 'application/json', 'X-File-Explorer-Password': password },
45
+ body: JSON.stringify({ password })
46
+ });
47
+ if (!response.ok) throw new Error('密码不正确');
48
+ sessionStorage.setItem(storageKey, password);
49
+ window.location.replace('/');
50
+ } catch (error) {
51
+ errorElement.textContent = error.message || '登录失败';
52
+ passwordInput.select();
53
+ } finally {
54
+ submitButton.disabled = false;
55
+ }
56
+ });
57
+ </script>
58
+ </body>
59
+ </html>
@@ -1010,6 +1010,14 @@
1010
1010
  <button class="toolbar-action-btn edit-only edit-inline" onclick="createEntry('file')" title="新建文件">
1011
1011
  新建文件
1012
1012
  </button>
1013
+ <button class="toolbar-action-btn edit-only edit-inline" onclick="document.getElementById('uploadFilesInput').click()" title="上传文件">
1014
+ 上传文件
1015
+ </button>
1016
+ <button class="toolbar-action-btn edit-only edit-inline" onclick="document.getElementById('uploadFolderInput').click()" title="上传文件夹">
1017
+ 上传文件夹
1018
+ </button>
1019
+ <input id="uploadFilesInput" type="file" multiple hidden onchange="uploadSelectedFiles(this.files)" />
1020
+ <input id="uploadFolderInput" type="file" webkitdirectory directory hidden onchange="uploadSelectedFiles(this.files)" />
1013
1021
  <button
1014
1022
  class="toolbar-action-btn danger edit-only edit-inline"
1015
1023
  id="deleteSelectedBtn"
@@ -1093,7 +1101,7 @@
1093
1101
  let searchQuery = '';
1094
1102
  let sortBy = 'name';
1095
1103
  let sortAscending = true;
1096
- let showHiddenFiles = true;
1104
+ let showHiddenFiles = false;
1097
1105
  let showBirthtime = false;
1098
1106
  let currentPreviewContent = '';
1099
1107
  let currentPreviewObjectUrl = null;
@@ -1109,6 +1117,11 @@
1109
1117
  let virtualRenderFrame = null;
1110
1118
  let virtualState = null;
1111
1119
  let fileGridResizeObserver = null;
1120
+ let healthCheckTimer = null;
1121
+ let serviceWasUnavailable = false;
1122
+
1123
+ const EXPLORER_AUTH_STORAGE_KEY = 'mock-service-cli.file-explorer.password';
1124
+ const EXPLORER_PATH_STORAGE_KEY = 'mock-service-cli.file-explorer.path';
1112
1125
 
1113
1126
  const LIST_ROW_HEIGHT = 37;
1114
1127
  const GRID_ROW_HEIGHT = 132;
@@ -1151,6 +1164,53 @@
1151
1164
  fileGridResizeObserver.disconnect();
1152
1165
  fileGridResizeObserver = null;
1153
1166
  }
1167
+ if (healthCheckTimer) {
1168
+ clearInterval(healthCheckTimer);
1169
+ healthCheckTimer = null;
1170
+ }
1171
+ }
1172
+
1173
+ function redirectToLogin() {
1174
+ sessionStorage.removeItem(EXPLORER_AUTH_STORAGE_KEY);
1175
+ window.location.replace('/__login');
1176
+ }
1177
+
1178
+ async function explorerFetch(url, options = {}) {
1179
+ const headers = new Headers(options.headers || {});
1180
+ const password = sessionStorage.getItem(EXPLORER_AUTH_STORAGE_KEY);
1181
+ if (password) headers.set('X-File-Explorer-Password', password);
1182
+ const response = await fetch(url, { ...options, headers });
1183
+ if (response.status === 401) redirectToLogin();
1184
+ return response;
1185
+ }
1186
+
1187
+ async function verifyExplorerAuth() {
1188
+ const password = sessionStorage.getItem(EXPLORER_AUTH_STORAGE_KEY);
1189
+ try {
1190
+ const response = await explorerFetch('/__api/auth/verify', {
1191
+ method: 'POST',
1192
+ headers: { 'Content-Type': 'application/json' },
1193
+ body: JSON.stringify({ password })
1194
+ });
1195
+ if (!response.ok) return false;
1196
+ return Boolean((await response.json()).success);
1197
+ } catch (error) {
1198
+ console.error('Failed to verify file explorer authentication:', error);
1199
+ return false;
1200
+ }
1201
+ }
1202
+
1203
+ function startHealthCheck() {
1204
+ healthCheckTimer = setInterval(async () => {
1205
+ try {
1206
+ const response = await explorerFetch('/__api/health');
1207
+ if (!response.ok) throw new Error(`Unexpected status ${response.status}`);
1208
+ if (serviceWasUnavailable) window.location.reload();
1209
+ serviceWasUnavailable = false;
1210
+ } catch (error) {
1211
+ serviceWasUnavailable = true;
1212
+ }
1213
+ }, 2000);
1154
1214
  }
1155
1215
 
1156
1216
  function syncEditModeUI() {
@@ -1167,8 +1227,9 @@
1167
1227
 
1168
1228
  async function loadExplorerConfig() {
1169
1229
  try {
1170
- const response = await fetch('/__api/config');
1230
+ const response = await explorerFetch('/__api/config');
1171
1231
  const config = await response.json();
1232
+ if (!response.ok || config.error) throw new Error(config.error || 'Failed to load configuration');
1172
1233
  editMode = Boolean(config.editMode);
1173
1234
  } catch (error) {
1174
1235
  console.warn('Failed to load file explorer configuration:', error);
@@ -1308,8 +1369,10 @@
1308
1369
  });
1309
1370
  fileGridResizeObserver.observe(fileGrid);
1310
1371
 
1372
+ if (!(await verifyExplorerAuth())) return;
1311
1373
  await loadExplorerConfig();
1312
- loadDirectory('/');
1374
+ startHealthCheck();
1375
+ loadDirectory(sessionStorage.getItem(EXPLORER_PATH_STORAGE_KEY) || '/');
1313
1376
  });
1314
1377
 
1315
1378
  window.addEventListener('beforeunload', cleanupEventListeners);
@@ -1394,7 +1457,7 @@
1394
1457
  fileGrid.innerHTML = '<div class="loading"><div class="spinner"></div><p>加载中...</p></div>';
1395
1458
 
1396
1459
  try {
1397
- const response = await fetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1460
+ const response = await explorerFetch(`/__api/list?path=${encodeURIComponent(path)}`, {
1398
1461
  signal: directoryRequestController.signal
1399
1462
  });
1400
1463
  const data = await response.json();
@@ -1405,6 +1468,7 @@
1405
1468
  if (requestId !== directoryRequestId) return;
1406
1469
 
1407
1470
  currentPath = data.currentPath;
1471
+ sessionStorage.setItem(EXPLORER_PATH_STORAGE_KEY, currentPath);
1408
1472
  allFiles = data.files || [];
1409
1473
 
1410
1474
  if (document.getElementById('searchInput')) {
@@ -1548,7 +1612,7 @@
1548
1612
 
1549
1613
  async function openInExplorer(path) {
1550
1614
  try {
1551
- const response = await fetch('/__api/open-in-explorer', {
1615
+ const response = await explorerFetch('/__api/open-in-explorer', {
1552
1616
  method: 'POST',
1553
1617
  headers: {
1554
1618
  'Content-Type': 'application/json'
@@ -1568,7 +1632,7 @@
1568
1632
  }
1569
1633
 
1570
1634
  async function requestJson(url, options) {
1571
- const response = await fetch(url, options);
1635
+ const response = await explorerFetch(url, options);
1572
1636
  const data = await response.json();
1573
1637
  if (!response.ok || data.error) {
1574
1638
  throw new Error(data.error || '请求失败');
@@ -1576,6 +1640,34 @@
1576
1640
  return data;
1577
1641
  }
1578
1642
 
1643
+ async function uploadSelectedFiles(files) {
1644
+ const input = document.activeElement;
1645
+ if (!files || files.length === 0) return;
1646
+ const formData = new FormData();
1647
+ formData.append('parentPath', currentPath);
1648
+ Array.from(files).forEach(file => {
1649
+ formData.append('files', file, file.webkitRelativePath || file.name);
1650
+ });
1651
+
1652
+ try {
1653
+ const response = await explorerFetch('/__api/upload', { method: 'POST', body: formData });
1654
+ const result = await response.json();
1655
+ if (!response.ok && response.status !== 207) throw new Error(result.error || '上传失败');
1656
+ const messages = [];
1657
+ if (result.uploaded.length) messages.push(`已上传 ${result.uploaded.length} 项`);
1658
+ if (result.failed.length) messages.push(`${result.failed.length} 项未上传: ${result.failed.map(item => item.name).join(', ')}`);
1659
+ showToast(messages.join(';'));
1660
+ await loadDirectory(currentPath);
1661
+ } catch (error) {
1662
+ console.error('上传失败:', error);
1663
+ alert(`上传失败: ${error.message}`);
1664
+ } finally {
1665
+ document.getElementById('uploadFilesInput').value = '';
1666
+ document.getElementById('uploadFolderInput').value = '';
1667
+ if (input && input.blur) input.blur();
1668
+ }
1669
+ }
1670
+
1579
1671
  async function createEntry(type) {
1580
1672
  const label = type === 'directory' ? '文件夹' : '文件';
1581
1673
  const name = window.prompt(`请输入${label}名称`);
@@ -1638,14 +1730,23 @@
1638
1730
  }
1639
1731
  }
1640
1732
 
1641
- function downloadFile(filePath, fileName) {
1642
- const url = `/__api/file?path=${encodeURIComponent(filePath)}`;
1643
- const a = document.createElement('a');
1644
- a.href = url;
1645
- a.download = fileName;
1646
- document.body.appendChild(a);
1647
- a.click();
1648
- document.body.removeChild(a);
1733
+ async function downloadFile(filePath, fileName) {
1734
+ try {
1735
+ const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(filePath)}&download=1`);
1736
+ if (!response.ok) throw new Error('下载失败');
1737
+ const objectUrl = URL.createObjectURL(await response.blob());
1738
+ const a = document.createElement('a');
1739
+ a.href = objectUrl;
1740
+ a.download = fileName || '';
1741
+ a.style.display = 'none';
1742
+ document.body.appendChild(a);
1743
+ a.click();
1744
+ a.remove();
1745
+ URL.revokeObjectURL(objectUrl);
1746
+ } catch (error) {
1747
+ console.error('下载失败:', error);
1748
+ alert(`下载失败: ${error.message}`);
1749
+ }
1649
1750
  }
1650
1751
 
1651
1752
  async function deletePath(filePath, fileName) {
@@ -1655,7 +1756,7 @@
1655
1756
  if (!confirmed) return;
1656
1757
 
1657
1758
  try {
1658
- const response = await fetch('/__api/path', {
1759
+ const response = await explorerFetch('/__api/path', {
1659
1760
  method: 'DELETE',
1660
1761
  headers: {
1661
1762
  'Content-Type': 'application/json'
@@ -2324,7 +2425,6 @@
2324
2425
  <button class="zoom-btn" onclick="resetImage()">🔄 重置</button>
2325
2426
  </div>
2326
2427
  <img
2327
- src="/__api/file?path=${encodeURIComponent(path)}"
2328
2428
  class="image-preview"
2329
2429
  id="previewImage"
2330
2430
  alt="Preview"
@@ -2332,8 +2432,11 @@
2332
2432
  >
2333
2433
  </div>
2334
2434
  `;
2435
+ const imageResponse = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
2436
+ if (!imageResponse.ok) throw new Error('图片加载失败');
2437
+ currentPreviewObjectUrl = URL.createObjectURL(await imageResponse.blob());
2438
+ document.getElementById('previewImage').src = currentPreviewObjectUrl;
2335
2439
  } else if (BINARY_EXTS.includes(ext)) {
2336
- const downloadUrl = `/__api/file?path=${encodeURIComponent(path)}`;
2337
2440
  const fileSize = fileInfo ? formatSize(fileInfo.size) : '未知大小';
2338
2441
 
2339
2442
  body.innerHTML = `
@@ -2345,13 +2448,13 @@
2345
2448
  </div>
2346
2449
  <div class="empty-state">
2347
2450
  <p>此文件类型无法预览内容</p>
2348
- <p><a href="${downloadUrl}" download="${path.split('/').pop()}">点击下载</a></p>
2451
+ <p><button class="toolbar-action-btn" id="previewDownloadBtn">点击下载</button></p>
2349
2452
  </div>
2350
2453
  </div>
2351
2454
  `;
2455
+ document.getElementById('previewDownloadBtn').addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2352
2456
  } else {
2353
2457
  if (isLargeFile) {
2354
- const downloadUrl = `/__api/file?path=${encodeURIComponent(path)}`;
2355
2458
  body.innerHTML = `
2356
2459
  <div class="preview-container">
2357
2460
  <div class="binary-info">
@@ -2361,12 +2464,13 @@
2361
2464
  </div>
2362
2465
  <div class="empty-state">
2363
2466
  <p>该文件超过 ${formatSize(LARGE_FILE_THRESHOLD)},不建议在线预览</p>
2364
- <p><a href="${downloadUrl}" download="${path.split('/').pop()}">点击下载</a></p>
2467
+ <p><button class="toolbar-action-btn" id="previewDownloadBtn">点击下载</button></p>
2365
2468
  </div>
2366
2469
  </div>
2367
2470
  `;
2471
+ document.getElementById('previewDownloadBtn').addEventListener('click', () => downloadFile(path, path.split('/').pop()));
2368
2472
  } else {
2369
- const response = await fetch(`/__api/file?path=${encodeURIComponent(path)}`);
2473
+ const response = await explorerFetch(`/__api/file?path=${encodeURIComponent(path)}`);
2370
2474
  const contentType = response.headers.get('content-type');
2371
2475
 
2372
2476
  if (contentType && contentType.includes('application/json')) {