chatyou commited on
Commit
4be9fde
·
verified ·
1 Parent(s): 926840d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +739 -460
app.py CHANGED
@@ -1,478 +1,757 @@
1
- // Cloudflare Worker - 虚拟 WebDAV 适配器(适配新版 API)
2
- // 基于新版 Hugging Face Bucket API,支持空目录(通过 .dir 文件标记)
3
- // 请将下方的 API_BASE 替换为实际部署的 Space 地址
4
-
5
- const API_BASE = 'https://your-space.hf.space'; // ⚠️ 必须替换
6
- const DAV_NS = 'DAV:';
7
- const DIR_MARKER = '.dir';
8
-
9
- let filesCache = [];
10
- let cacheTime = 0;
11
- const CACHE_TTL = 5000;
12
-
13
- // --------------------- 工具函数 ---------------------
14
-
15
- function parsePath(urlString) {
16
- try {
17
- const url = new URL(urlString);
18
- let path = url.pathname.replace(/^\/webdav/, '') || '/';
19
- if (path !== '/' && path.endsWith('/')) {
20
- path = path.slice(0, -1);
21
- }
22
- return path;
23
- } catch {
24
- return '/';
25
- }
26
- }
27
-
28
- function buildApiUrl(path) {
29
- const cleanPath = path.startsWith('/') ? path.slice(1) : path;
30
- return `${API_BASE}/file/${encodeURIComponent(cleanPath)}`;
31
- }
32
-
33
- async function getFileList() {
34
- const now = Date.now();
35
- if (now - cacheTime <= CACHE_TTL && filesCache.length) return filesCache;
36
- try {
37
- const res = await fetch(`${API_BASE}/list`);
38
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
39
- const data = await res.json();
40
- filesCache = Array.isArray(data) ? data : [];
41
- cacheTime = now;
42
- } catch (e) {
43
- console.error('getFileList error:', e);
44
- filesCache = [];
45
- cacheTime = now;
46
- }
47
- return filesCache;
48
- }
49
-
50
- function dirToMarker(dirPath) {
51
- const cleanPath = dirPath.startsWith('/') ? dirPath.slice(1) : dirPath;
52
- return cleanPath ? `${cleanPath}${DIR_MARKER}` : '';
53
- }
54
-
55
- async function getPathInfo(path) {
56
- const files = await getFileList();
57
- const cleanPath = path === '/' ? '' : path.slice(1).replace(/\/$/, '');
58
-
59
- if (cleanPath === '') return { exists: true, type: 'directory', files };
60
-
61
- if (files.includes(cleanPath) && !cleanPath.endsWith(DIR_MARKER))
62
- return { exists: true, type: 'file', files };
63
-
64
- if (files.includes(cleanPath + DIR_MARKER))
65
- return { exists: true, type: 'directory', files };
66
-
67
- const hasChildren = files.some(f => f.startsWith(cleanPath + '/') && !f.endsWith(DIR_MARKER));
68
- if (hasChildren) return { exists: true, type: 'directory', files };
69
-
70
- return { exists: false, type: null, files };
71
- }
72
-
73
- function getDirectoryContents(dirPath, files) {
74
- const prefix = dirPath === '/' ? '' : dirPath.slice(1).replace(/\/$/, '');
75
- const markerDirs = new Set();
76
- for (const f of files) {
77
- if (f.endsWith(DIR_MARKER)) markerDirs.add(f.slice(0, -DIR_MARKER.length));
78
- }
79
-
80
- const entries = new Map();
81
-
82
- // 处理真实文件
83
- for (const file of files) {
84
- if (file.endsWith(DIR_MARKER)) continue;
85
-
86
- let belongs = false;
87
- if (prefix === '') {
88
- if (!file.includes('/')) belongs = true;
89
- } else {
90
- if (file.startsWith(prefix + '/')) belongs = true;
91
- }
92
- if (!belongs) continue;
93
-
94
- const relPath = prefix === '' ? file : file.slice(prefix.length + 1);
95
- const parts = relPath.split('/');
96
- const name = parts[0];
97
- if (!name) continue;
98
-
99
- const fullPath = prefix ? `${prefix}/${name}` : name;
100
- const isDir = parts.length > 1 || markerDirs.has(fullPath);
101
-
102
- if (!entries.has(name) || (isDir && entries.get(name).type !== 'directory')) {
103
- entries.set(name, { name, type: isDir ? 'directory' : 'file', path: fullPath });
104
- }
105
- }
106
-
107
- // 处理标记目录(确保空目录显示)
108
- for (const marker of markerDirs) {
109
- let belongs = false;
110
- if (prefix === '') {
111
- if (!marker.includes('/')) belongs = true;
112
- } else {
113
- if (marker.startsWith(prefix + '/')) belongs = true;
114
- }
115
- if (!belongs) continue;
116
-
117
- const relPath = prefix === '' ? marker : marker.slice(prefix.length + 1);
118
- const parts = relPath.split('/');
119
- const name = parts[0];
120
- if (!name) continue;
121
-
122
- const fullPath = prefix ? `${prefix}/${name}` : name;
123
- if (!entries.has(name) || entries.get(name).type !== 'directory') {
124
- entries.set(name, { name, type: 'directory', path: fullPath });
125
- }
126
- }
127
-
128
- return Array.from(entries.values());
129
- }
130
-
131
- async function getAllFilesInDir(dirPath) {
132
- const files = await getFileList();
133
- const cleanDir = dirPath.startsWith('/') ? dirPath.slice(1).replace(/\/$/, '') : dirPath.replace(/\/$/, '');
134
- return files.filter(f => f.startsWith(cleanDir + '/') && !f.endsWith(DIR_MARKER));
135
- }
136
-
137
- async function createDirectory(dirPath) {
138
- const markerPath = dirToMarker(dirPath);
139
- const parts = markerPath.split('/').filter(p => p);
140
- const filename = parts.pop();
141
- const dirParts = parts.join('/');
142
-
143
- const emptyBlob = new Blob([]);
144
- const formData = new FormData();
145
- formData.append('file', emptyBlob, filename);
146
- if (dirParts) formData.append('dir', dirParts + '/' + filename);
147
-
148
- try {
149
- const response = await fetch(`${API_BASE}/upload`, { method: 'POST', body: formData });
150
- if (!response.ok) return response;
151
- const result = await response.json();
152
- return result.success ? response : new Response(null, { status: 500 });
153
- } catch (error) {
154
- console.error('创建目录失败:', error);
155
- return new Response(null, { status: 500 });
156
- }
157
- }
158
-
159
- async function deleteDirectory(dirPath) {
160
- const files = await getFileList();
161
- const cleanDir = dirPath.startsWith('/') ? dirPath.slice(1).replace(/\/$/, '') : dirPath.replace(/\/$/, '');
162
-
163
- const filesToDelete = files.filter(f => f === cleanDir + DIR_MARKER || f.startsWith(cleanDir + '/'));
164
-
165
- if (filesToDelete.length === 0) return { success: true, count: 0 };
166
-
167
- try {
168
- await Promise.all(filesToDelete.map(f =>
169
- fetch(`${API_BASE}/delete/${encodeURIComponent(f)}`, { method: 'DELETE' })
170
- ));
171
- return { success: true, count: filesToDelete.length };
172
- } catch (error) {
173
- console.error('删除目录失败:', error);
174
- return { success: false, count: 0 };
175
- }
176
- }
177
-
178
- async function generatePropfindResponse(request, path, depth) {
179
- const baseUrl = new URL(request.url).origin;
180
- const fullUrl = baseUrl + '/webdav' + path;
181
-
182
- const info = await getPathInfo(path);
183
- if (!info.exists) return null;
184
-
185
- let xml = `<?xml version="1.0" encoding="utf-8"?>
186
- <multistatus xmlns="${DAV_NS}">`;
187
-
188
- const isDir = info.type === 'directory';
189
- xml += `
190
- <response>
191
- <href>${isDir ? fullUrl + '/' : fullUrl}</href>
192
- <propstat>
193
- <prop>
194
- <resourcetype>${isDir ? '<collection/>' : ''}</resourcetype>
195
- <displayname>${escapeXml(path === '/' ? '根目录' : path.split('/').pop())}</displayname>
196
- <getcontenttype>${isDir ? 'httpd/unix-directory' : 'application/octet-stream'}</getcontenttype>
197
- <getlastmodified>${new Date().toUTCString()}</getlastmodified>
198
- <creationdate>${new Date().toISOString()}</creationdate>
199
- </prop>
200
- <status>HTTP/1.1 200 OK</status>
201
- </propstat>
202
- </response>`;
203
-
204
- if (isDir && depth !== '0') {
205
- const children = getDirectoryContents(path, info.files);
206
- for (const child of children) {
207
- const childIsDir = child.type === 'directory';
208
- const childPath = path + (path.endsWith('/') ? '' : '/') + child.name;
209
- const childUrl = baseUrl + '/webdav' + childPath + (childIsDir ? '/' : '');
210
- xml += `
211
- <response>
212
- <href>${childUrl}</href>
213
- <propstat>
214
- <prop>
215
- <resourcetype>${childIsDir ? '<collection/>' : ''}</resourcetype>
216
- <displayname>${escapeXml(child.name)}</displayname>
217
- <getcontenttype>${childIsDir ? 'httpd/unix-directory' : 'application/octet-stream'}</getcontenttype>
218
- <getlastmodified>${new Date().toUTCString()}</getlastmodified>
219
- <creationdate>${new Date().toISOString()}</creationdate>
220
- </prop>
221
- <status>HTTP/1.1 200 OK</status>
222
- </propstat>
223
- </response>`;
224
- }
225
- }
226
-
227
- xml += '\n</multistatus>';
228
- return xml;
229
- }
230
-
231
- function escapeXml(unsafe) {
232
- return unsafe.replace(/[<>&'"]/g, c => {
233
- switch (c) {
234
- case '<': return '&lt;';
235
- case '>': return '&gt;';
236
- case '&': return '&amp;';
237
- case "'": return '&apos;';
238
- case '"': return '&quot;';
239
- default: return c;
240
- }
241
- });
242
- }
243
-
244
- // --------------------- Worker 主入口 ---------------------
245
-
246
- export default {
247
- async fetch(request, env, ctx) {
248
- try {
249
- const url = new URL(request.url);
250
- const path = parsePath(url.pathname);
251
- const method = request.method;
252
-
253
- if (method === 'OPTIONS') {
254
- return new Response(null, {
255
- headers: {
256
- 'DAV': '1,2',
257
- 'Allow': 'OPTIONS,GET,HEAD,PUT,DELETE,PROPFIND,MKCOL,COPY,MOVE',
258
- 'Access-Control-Allow-Origin': '*',
259
- 'Access-Control-Allow-Methods': 'OPTIONS,GET,HEAD,PUT,DELETE,PROPFIND,MKCOL,COPY,MOVE',
260
- 'Access-Control-Allow-Headers': 'Content-Type,Depth,Destination,Overwrite',
261
- 'Cache-Control': 'no-cache',
262
- },
263
- });
264
- }
265
-
266
- if (method === 'PROPFIND') {
267
- const depth = request.headers.get('Depth') || '1';
268
- const xml = await generatePropfindResponse(request, path, depth);
269
- if (!xml) return new Response('Not Found', { status: 404 });
270
- return new Response(xml, {
271
- status: 207,
272
- headers: {
273
- 'Content-Type': 'application/xml; charset=utf-8',
274
- 'DAV': '1,2',
275
- 'Access-Control-Allow-Origin': '*',
276
- 'Cache-Control': 'no-cache',
277
- },
278
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  }
 
 
 
280
 
281
- if (method === 'GET' && path !== '/') {
282
- const info = await getPathInfo(path);
283
- if (!info.exists) return new Response('Not Found', { status: 404 });
284
- if (info.type === 'directory') return new Response('Method Not Allowed', { status: 405 });
285
-
286
- const upstream = await fetch(buildApiUrl(path));
287
- if (!upstream.ok) return new Response('Upstream error', { status: 502 });
288
- return new Response(upstream.body, {
289
- headers: {
290
- 'Content-Type': 'application/octet-stream',
291
- 'Content-Disposition': `attachment; filename="${encodeURIComponent(path.split('/').pop())}"`,
292
- 'Access-Control-Allow-Origin': '*',
293
- },
294
- });
295
- }
296
 
297
- if (method === 'HEAD') {
298
- const info = await getPathInfo(path);
299
- return new Response(null, { status: info.exists ? 200 : 404 });
 
 
 
300
  }
 
 
301
 
302
- if (method === 'PUT' && path !== '/') {
303
- const info = await getPathInfo(path);
304
- if (info.exists && info.type === 'directory')
305
- return new Response('Conflict', { status: 409 });
306
-
307
- const parts = path.split('/').filter(p => p);
308
- const filename = parts.pop();
309
- const dirPath = parts.join('/');
310
-
311
- const formData = new FormData();
312
- try {
313
- const blob = await request.blob();
314
- formData.append('file', blob, filename);
315
- if (dirPath) formData.append('dir', dirPath + '/' + filename);
316
-
317
- const uploadRes = await fetch(`${API_BASE}/upload`, { method: 'POST', body: formData });
318
- if (!uploadRes.ok) return new Response('Upload failed', { status: uploadRes.status });
319
- const result = await uploadRes.json();
320
-
321
- if (result.success) {
322
- cacheTime = 0;
323
- return new Response(null, { status: info.exists ? 204 : 201 });
324
- } else {
325
- return new Response('Upload failed', { status: 500 });
326
- }
327
- } catch (error) {
328
- console.error('PUT error:', error);
329
- return new Response('Upload error', { status: 500 });
330
  }
331
- }
332
-
333
- if (method === 'DELETE') {
334
- const info = await getPathInfo(path);
335
- if (!info.exists) return new Response('Not Found', { status: 404 });
336
-
337
- if (info.type === 'directory') {
338
- const result = await deleteDirectory(path);
339
- if (!result.success) return new Response('Delete failed', { status: 500 });
340
  } else {
341
- const cleanPath = path.slice(1);
342
- await fetch(`${API_BASE}/delete/${encodeURIComponent(cleanPath)}`, { method: 'DELETE' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  }
344
- cacheTime = 0;
345
- return new Response(null, { status: 204 });
 
346
  }
 
347
 
348
- if (method === 'MKCOL') {
349
- const info = await getPathInfo(path);
350
- if (info.exists) return new Response('Method Not Allowed', { status: 405 });
351
-
352
- const response = await createDirectory(path);
353
- if (response.ok) {
354
- cacheTime = 0;
355
- return new Response(null, { status: 201 });
356
- } else {
357
- return new Response('MKCOL failed', { status: 500 });
358
- }
 
 
 
 
 
 
 
 
 
359
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
- if (method === 'COPY') {
362
- const destination = request.headers.get('Destination');
363
- const overwrite = request.headers.get('Overwrite') !== 'F';
364
- if (!destination) return new Response('Destination header required', { status: 400 });
365
-
366
- const destUrl = new URL(destination);
367
- const destPath = parsePath(destUrl.pathname);
368
-
369
- const srcInfo = await getPathInfo(path);
370
- const dstInfo = await getPathInfo(destPath);
371
-
372
- if (!srcInfo.exists || srcInfo.type !== 'file')
373
- return new Response('Source not found or is a directory', { status: 404 });
374
- if (dstInfo.exists && !overwrite)
375
- return new Response('Destination exists', { status: 412 });
376
-
377
- try {
378
- const srcRes = await fetch(buildApiUrl(path));
379
- if (!srcRes.ok) return new Response('Source fetch failed', { status: 502 });
380
-
381
- const parts = destPath.split('/').filter(p => p);
382
- const filename = parts.pop();
383
- const dirPath = parts.join('/');
384
-
385
- const formData = new FormData();
386
- formData.append('file', await srcRes.blob(), filename);
387
- if (dirPath) formData.append('dir', dirPath + '/' + filename);
388
-
389
- const uploadRes = await fetch(`${API_BASE}/upload`, { method: 'POST', body: formData });
390
- if (!uploadRes.ok) return new Response('Upload failed', { status: uploadRes.status });
391
- const result = await uploadRes.json();
392
-
393
- if (result.success) {
394
- cacheTime = 0;
395
- return new Response(null, { status: dstInfo.exists ? 204 : 201, headers: { 'Location': destination } });
396
- } else {
397
- return new Response('Copy failed', { status: 500 });
398
- }
399
- } catch (error) {
400
- console.error('COPY error:', error);
401
- return new Response('Copy error', { status: 500 });
402
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
403
  }
404
 
405
- if (method === 'MOVE') {
406
- const destination = request.headers.get('Destination');
407
- const overwrite = request.headers.get('Overwrite') !== 'F';
408
- if (!destination) return new Response('Destination header required', { status: 400 });
409
-
410
- const destUrl = new URL(destination);
411
- const destPath = parsePath(destUrl.pathname);
412
-
413
- const srcInfo = await getPathInfo(path);
414
- const dstInfo = await getPathInfo(destPath);
415
-
416
- if (!srcInfo.exists || srcInfo.type !== 'file')
417
- return new Response('Source not found or is a directory', { status: 404 });
418
- if (dstInfo.exists && !overwrite)
419
- return new Response('Destination exists', { status: 412 });
420
-
421
- try {
422
- const srcRes = await fetch(buildApiUrl(path));
423
- if (!srcRes.ok) return new Response('Source fetch failed', { status: 502 });
424
-
425
- const parts = destPath.split('/').filter(p => p);
426
- const filename = parts.pop();
427
- const dirPath = parts.join('/');
428
-
429
- const formData = new FormData();
430
- formData.append('file', await srcRes.blob(), filename);
431
- if (dirPath) formData.append('dir', dirPath + '/' + filename);
432
-
433
- const uploadRes = await fetch(`${API_BASE}/upload`, { method: 'POST', body: formData });
434
- if (!uploadRes.ok) return new Response('Upload failed', { status: uploadRes.status });
435
- const result = await uploadRes.json();
436
-
437
- if (result.success) {
438
- const cleanPath = path.slice(1);
439
- await fetch(`${API_BASE}/delete/${encodeURIComponent(cleanPath)}`, { method: 'DELETE' });
440
- cacheTime = 0;
441
- return new Response(null, { status: dstInfo.exists ? 204 : 201, headers: { 'Location': destination } });
442
- } else {
443
- return new Response('Move failed', { status: 500 });
444
- }
445
- } catch (error) {
446
- console.error('MOVE error:', error);
447
- return new Response('Move error', { status: 500 });
 
 
 
 
 
 
448
  }
 
 
449
  }
450
-
451
- if (path === '/') {
452
- const files = await getFileList();
453
- const markers = files.filter(f => f.endsWith(DIR_MARKER));
454
- const html = `<!DOCTYPE html>
455
- <html>
456
- <head><meta charset="UTF-8"><title>虚拟 WebDAV 服务</title>
457
- <style>body{font-family:sans-serif;margin:40px;line-height:1.6} pre{background:#f4f4f4;padding:10px;border-radius:5px} .info{background:#d4edda;border-left:4px solid #28a745;padding:10px} .stats{display:flex;gap:20px} .stat-box{background:#f8f9fa;padding:15px;border-radius:5px;flex:1;text-align:center}</style>
458
- </head>
459
- <body>
460
- <h1>📁 虚拟 WebDAV 服务</h1>
461
- <p>基于新版 Hugging Face Bucket API,支持空目录(通过隐藏文件 ${DIR_MARKER} 标记)。</p>
462
- <div class="info"><strong>✅ 修正:</strong> 子目录列表已修复,现在应能正确显示。</div>
463
- <div class="stats"><div class="stat-box"><h3>总文件数</h3><div style="font-size:24px">${files.length}</div></div><div class="stat-box"><h3>标记目录数</h3><div style="font-size:24px">${markers.length}</div></div></div>
464
- <p>连接地址:<code>${url.origin}/webdav</code></p>
465
- <h2>文件列表(不含标记文件)</h2>
466
- <pre>${JSON.stringify(files.filter(f => !f.endsWith(DIR_MARKER)), null, 2)}</pre>
467
- </body>
468
- </html>`;
469
- return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' } });
470
  }
 
471
 
472
- return new Response('Not Found', { status: 404 });
473
- } catch (err) {
474
- console.error('Unhandled error:', err);
475
- return new Response('Internal Server Error', { status: 500 });
476
- }
477
- },
478
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ from flask import Flask, request, jsonify, send_file
4
+ from huggingface_hub import (
5
+ batch_bucket_files,
6
+ download_bucket_files,
7
+ list_bucket_tree
8
+ )
9
+
10
+ app = Flask(__name__)
11
+
12
+ HF_TOKEN = os.environ.get("HF_TOKEN")
13
+ if not HF_TOKEN:
14
+ raise ValueError("HF_TOKEN environment variable not set. Please add it to Space Secrets.")
15
+
16
+ BUCKET_ID = "nagose/filebed"
17
+
18
+ HTML_CONTENT = """<!DOCTYPE html>
19
+ <html lang="zh-CN">
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
23
+ <title>HF Bucket 文件管理器 · 支持目录导航</title>
24
+ <style>
25
+ * { margin: 0; padding: 0; box-sizing: border-box; }
26
+ body {
27
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
28
+ background: linear-gradient(145deg, #f0f4fa 0%, #e6ecf4 100%);
29
+ min-height: 100vh;
30
+ padding: 2rem 1rem;
31
+ display: flex;
32
+ justify-content: center;
33
+ align-items: center;
34
+ }
35
+ .container {
36
+ max-width: 1200px;
37
+ width: 100%;
38
+ margin: 0 auto;
39
+ }
40
+ .grid {
41
+ display: grid;
42
+ grid-template-columns: 1fr 1fr;
43
+ gap: 1.5rem;
44
+ }
45
+ .card {
46
+ background: rgba(255, 255, 255, 0.8);
47
+ backdrop-filter: blur(10px);
48
+ -webkit-backdrop-filter: blur(10px);
49
+ border: 1px solid rgba(255, 255, 255, 0.5);
50
+ border-radius: 28px;
51
+ box-shadow: 0 20px 40px -12px rgba(0, 20, 40, 0.25);
52
+ padding: 1.8rem;
53
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
54
+ }
55
+ .card:hover {
56
+ transform: translateY(-4px);
57
+ box-shadow: 0 30px 50px -15px rgba(0, 30, 60, 0.3);
58
+ }
59
+ h2 {
60
+ font-size: 1.8rem;
61
+ font-weight: 600;
62
+ margin-bottom: 1.5rem;
63
+ color: #1a2b3c;
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 0.5rem;
67
+ }
68
+ .upload-area {
69
+ display: flex;
70
+ flex-direction: column;
71
+ gap: 1.2rem;
72
+ margin-bottom: 2rem;
73
+ }
74
+ .file-label {
75
+ background: #f1f5f9;
76
+ border-radius: 60px;
77
+ padding: 0.8rem 1.5rem;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 0.8rem;
81
+ border: 2px dashed #b0c4de;
82
+ cursor: pointer;
83
+ transition: all 0.2s;
84
+ width: fit-content;
85
+ font-weight: 500;
86
+ color: #1e293b;
87
+ }
88
+ .file-label:hover {
89
+ background: #e2eaf3;
90
+ border-color: #3b82f6;
91
+ }
92
+ input[type="file"] { display: none; }
93
+ .dir-input {
94
+ padding: 0.8rem 1.2rem;
95
+ border-radius: 40px;
96
+ border: 1px solid #cbd5e1;
97
+ font-size: 1rem;
98
+ background: white;
99
+ width: 100%;
100
+ }
101
+ .button {
102
+ background: #3b82f6;
103
+ border: none;
104
+ color: white;
105
+ font-weight: 600;
106
+ padding: 0.8rem 2rem;
107
+ border-radius: 40px;
108
+ font-size: 1rem;
109
+ cursor: pointer;
110
+ box-shadow: 0 6px 14px rgba(59, 130, 246, 0.3);
111
+ transition: all 0.15s ease;
112
+ width: fit-content;
113
+ border: 1px solid rgba(255,255,255,0.2);
114
+ }
115
+ .button:hover {
116
+ background: #2563eb;
117
+ transform: scale(1.02);
118
+ box-shadow: 0 10px 20px rgba(37, 99, 235, 0.4);
119
+ }
120
+ .button.secondary {
121
+ background: #64748b;
122
+ box-shadow: 0 6px 14px rgba(100, 116, 139, 0.3);
123
+ }
124
+ .button.secondary:hover {
125
+ background: #475569;
126
+ }
127
+ .button.danger {
128
+ background: #ef4444;
129
+ box-shadow: 0 6px 14px rgba(239, 68, 68, 0.25);
130
+ }
131
+ .button.danger:hover {
132
+ background: #dc2626;
133
+ }
134
+ .button.small {
135
+ padding: 0.4rem 1rem;
136
+ font-size: 0.9rem;
137
+ }
138
+ .log {
139
+ background: #1e293b;
140
+ color: #b9e6f0;
141
+ padding: 1.2rem;
142
+ border-radius: 24px;
143
+ font-family: 'JetBrains Mono', monospace;
144
+ font-size: 0.9rem;
145
+ border-left: 6px solid #3b82f6;
146
+ white-space: pre-wrap;
147
+ max-height: 250px;
148
+ overflow: auto;
149
+ margin-top: 1.5rem;
150
+ }
151
+ .log p { margin: 0.2rem 0; }
152
+ .timestamp { color: #94a3b8; margin-right: 0.5rem; }
153
+ .file-list {
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 0.6rem;
157
+ margin-top: 1rem;
158
+ max-height: 400px;
159
+ overflow-y: auto;
160
+ padding-right: 0.5rem;
161
+ }
162
+ .file-item {
163
+ display: flex;
164
+ justify-content: space-between;
165
+ align-items: center;
166
+ background: #ffffffd6;
167
+ backdrop-filter: blur(4px);
168
+ padding: 0.8rem 1.5rem;
169
+ border-radius: 40px;
170
+ box-shadow: 0 4px 8px rgba(0,0,0,0.02);
171
+ border: 1px solid rgba(255,255,255,0.6);
172
+ }
173
+ .file-link {
174
+ color: #1e3a8a;
175
+ font-weight: 500;
176
+ text-decoration: none;
177
+ word-break: break-all;
178
+ cursor: pointer;
179
+ display: flex;
180
+ align-items: center;
181
+ gap: 0.5rem;
182
+ }
183
+ .file-link:hover {
184
+ text-decoration: underline;
185
+ color: #2563eb;
186
+ }
187
+ .directory-link {
188
+ color: #b45309;
189
+ font-weight: 500;
190
+ cursor: pointer;
191
+ display: flex;
192
+ align-items: center;
193
+ gap: 0.5rem;
194
+ }
195
+ .directory-link:hover {
196
+ text-decoration: underline;
197
+ color: #d97706;
198
+ }
199
+ .empty-message {
200
+ text-align: center;
201
+ color: #64748b;
202
+ padding: 2rem;
203
+ font-style: italic;
204
+ }
205
+ .api-test {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 1.5rem;
209
+ }
210
+ .api-section {
211
+ border-top: 1px solid #cbd5e1;
212
+ padding-top: 1rem;
213
+ }
214
+ .api-row {
215
+ display: flex;
216
+ align-items: center;
217
+ gap: 1rem;
218
+ flex-wrap: wrap;
219
+ margin: 0.8rem 0;
220
+ }
221
+ .api-input {
222
+ flex: 1;
223
+ padding: 0.8rem 1.2rem;
224
+ border-radius: 40px;
225
+ border: 1px solid #cbd5e1;
226
+ font-size: 1rem;
227
+ background: white;
228
+ }
229
+ .api-result {
230
+ background: #1e293b;
231
+ color: #b9e6f0;
232
+ padding: 1rem;
233
+ border-radius: 20px;
234
+ font-family: monospace;
235
+ font-size: 0.9rem;
236
+ white-space: pre-wrap;
237
+ max-height: 200px;
238
+ overflow: auto;
239
+ }
240
+ .api-docs {
241
+ background: #f8fafc;
242
+ border-radius: 20px;
243
+ padding: 1rem;
244
+ margin-top: 1rem;
245
+ }
246
+ .api-docs h3 {
247
+ font-size: 1.2rem;
248
+ margin-bottom: 0.8rem;
249
+ color: #0f172a;
250
+ }
251
+ .api-docs ul {
252
+ list-style: none;
253
+ padding-left: 0;
254
+ }
255
+ .api-docs li {
256
+ margin: 0.8rem 0;
257
+ padding: 0.8rem;
258
+ background: white;
259
+ border-radius: 16px;
260
+ border-left: 4px solid #3b82f6;
261
+ }
262
+ .api-docs code {
263
+ background: #e2e8f0;
264
+ padding: 0.2rem 0.4rem;
265
+ border-radius: 6px;
266
+ font-family: monospace;
267
+ }
268
+ .nav-bar {
269
+ display: flex;
270
+ gap: 0.5rem;
271
+ margin-bottom: 1rem;
272
+ align-items: center;
273
+ }
274
+ .nav-bar input {
275
+ flex: 1;
276
+ }
277
+ footer {
278
+ margin-top: 2rem;
279
+ text-align: center;
280
+ color: #64748b;
281
+ font-size: 0.9rem;
282
+ }
283
+ @media (max-width: 768px) {
284
+ .grid { grid-template-columns: 1fr; }
285
+ }
286
+ </style>
287
+ </head>
288
+ <body>
289
+ <div class="container">
290
+ <h1 style="font-size: 2.5rem; margin-bottom: 2rem; color: #0f172a;">📦 HF Bucket 文件管理器 (目录导航)</h1>
291
+ <div class="grid">
292
+ <!-- 左侧:文件上传与浏览 -->
293
+ <div class="card">
294
+ <h2>📤 文件上传</h2>
295
+ <div class="upload-area">
296
+ <label for="fileInput" class="file-label" id="fileLabel">📎 选择文件</label>
297
+ <input type="file" id="fileInput">
298
+ <input type="text" id="uploadDir" class="dir-input" placeholder="目标目录 (可选,如 images/ 或 logs/2026/)" value="">
299
+ <button class="button" id="uploadBtn">⬆️ 上传到 Bucket</button>
300
+ </div>
301
+ <h2 style="margin-top: 2rem;">📁 浏览</h2>
302
+ <div class="nav-bar">
303
+ <input type="text" id="currentDir" class="dir-input" placeholder="当前目录 (留空为根目录)" value="">
304
+ <button class="button secondary small" id="listBtn">列出文件</button>
305
+ <button class="button secondary small" id="goUpBtn" title="返回上级">⬆️ 上级</button>
306
+ </div>
307
+ <div id="fileList" class="file-list">
308
+ <div class="empty-message">加载中...</div>
309
+ </div>
310
+ <div class="log" id="log">就绪</div>
311
+ </div>
312
+
313
+ <!-- 右侧:API 测试面板 + 使用说明 -->
314
+ <div class="card">
315
+ <h2>🧪 API 测试</h2>
316
+ <div class="api-test">
317
+ <div class="api-section">
318
+ <h3>📤 上传文件 (POST /upload) 支持��录</h3>
319
+ <div class="api-row">
320
+ <input type="file" id="apiFileInput" style="flex:1; padding:0.5rem;">
321
+ <span style="color:#64748b;">(留空则使用左侧文件)</span>
322
+ </div>
323
+ <div class="api-row">
324
+ <input type="text" id="apiUploadDir" class="api-input" placeholder="目标目录 (可选,如 images/)" value="">
325
+ <button class="button secondary small" id="apiUploadBtn">上传测试</button>
326
+ </div>
327
+ </div>
328
+
329
+ <div class="api-section">
330
+ <h3>📋 列出文件 (GET /list?dir=...)</h3>
331
+ <div class="api-row">
332
+ <input type="text" id="apiListDir" class="api-input" placeholder="目录 (留空为根目录)" value="">
333
+ <button class="button secondary small" id="apiListBtn">获取列表</button>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="api-section">
338
+ <h3>📥 获取文件信息 (HEAD /file/&lt;filename&gt;)</h3>
339
+ <div class="api-row">
340
+ <input type="text" class="api-input" id="apiFilename" placeholder="输入文件名(可包含目录)">
341
+ <button class="button secondary small" id="apiGetBtn">检查</button>
342
+ </div>
343
+ </div>
344
+
345
+ <div class="api-section">
346
+ <h3>🗑️ 删除文件 (DELETE /delete/&lt;filename&gt;)</h3>
347
+ <div class="api-row">
348
+ <input type="text" class="api-input" id="apiDeleteFilename" placeholder="要删除的文件名(可包含目录)">
349
+ <button class="button danger small" id="apiDeleteBtn">删除</button>
350
+ </div>
351
+ </div>
352
+
353
+ <div class="api-result" id="apiResult">点击按钮查看结果</div>
354
+
355
+ <div class="api-docs">
356
+ <h3>📚 API 使用说明 (支持目录导航)</h3>
357
+ <ul>
358
+ <li><code><strong>POST /upload</strong></code> - 上传文件到指定目录</li>
359
+ <li><code><strong>GET /list?dir=...</strong></code> - 列出指定目录下的第一层文件和目录(目录路径以 / 结尾)</li>
360
+ <li><code><strong>GET /file/&lt;filename&gt;</strong></code> - 下载文件</li>
361
+ <li><code><strong>HEAD /file/&lt;filename&gt;</strong></code> - 检查文件是否存在</li>
362
+ <li><code><strong>DELETE /delete/&lt;filename&gt;</strong></code> - 删除文件(不支持目录)</li>
363
+ </ul>
364
+ </div>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ <footer>Powered by Hugging Face Buckets · 支持多级目录导航</footer>
369
+ </div>
370
+
371
+ <script>
372
+ (function() {
373
+ // DOM 元素
374
+ const fileInput = document.getElementById('fileInput');
375
+ const uploadBtn = document.getElementById('uploadBtn');
376
+ const fileLabel = document.getElementById('fileLabel');
377
+ const uploadDir = document.getElementById('uploadDir');
378
+ const currentDir = document.getElementById('currentDir');
379
+ const listBtn = document.getElementById('listBtn');
380
+ const goUpBtn = document.getElementById('goUpBtn');
381
+ const logDiv = document.getElementById('log');
382
+ const fileListDiv = document.getElementById('fileList');
383
+ const apiResult = document.getElementById('apiResult');
384
+ const apiListBtn = document.getElementById('apiListBtn');
385
+ const apiListDir = document.getElementById('apiListDir');
386
+ const apiGetBtn = document.getElementById('apiGetBtn');
387
+ const apiDeleteBtn = document.getElementById('apiDeleteBtn');
388
+ const apiFilename = document.getElementById('apiFilename');
389
+ const apiDeleteFilename = document.getElementById('apiDeleteFilename');
390
+ const apiFileInput = document.getElementById('apiFileInput');
391
+ const apiUploadDir = document.getElementById('apiUploadDir');
392
+ const apiUploadBtn = document.getElementById('apiUploadBtn');
393
+
394
+ // 辅助函数:添加日志
395
+ function addLog(msg, isErr = false) {
396
+ const p = document.createElement('p');
397
+ const ts = new Date().toLocaleTimeString();
398
+ p.innerHTML = '<span class="timestamp">[' + ts + ']</span> ' + (isErr ? '❌ ' : '') + msg;
399
+ if (isErr) p.style.color = '#f87171';
400
+ logDiv.appendChild(p);
401
+ logDiv.scrollTop = logDiv.scrollHeight;
402
+ }
403
+ function clearLog() { logDiv.innerHTML = ''; }
404
+
405
+ // 显示 API 结果
406
+ function showApiResult(data, isError = false) {
407
+ if (typeof data === 'object') {
408
+ apiResult.textContent = JSON.stringify(data, null, 2);
409
+ } else {
410
+ apiResult.textContent = data;
411
  }
412
+ if (isError) apiResult.style.color = '#f87171';
413
+ else apiResult.style.color = '#b9e6f0';
414
+ }
415
 
416
+ // 构建远程路径:目录 + 文件名
417
+ function buildRemotePath(dir, filename) {
418
+ dir = dir.trim();
419
+ if (!dir) return filename;
420
+ if (!dir.endsWith('/')) dir += '/';
421
+ if (dir.startsWith('/')) dir = dir.substring(1);
422
+ return dir + filename;
423
+ }
 
 
 
 
 
 
 
424
 
425
+ // 获取用于上传的文件
426
+ function getUploadFile() {
427
+ if (apiFileInput.files.length > 0) {
428
+ return apiFileInput.files[0];
429
+ } else if (fileInput.files.length > 0) {
430
+ return fileInput.files[0];
431
  }
432
+ return null;
433
+ }
434
 
435
+ // 加载文件列表
436
+ async function loadList(dir) {
437
+ dir = dir || '';
438
+ currentDir.value = dir; // 同步输入框
439
+ try {
440
+ fileListDiv.innerHTML = '<div class="empty-message">加载中...</div>';
441
+ let url = '/list';
442
+ if (dir) url += '?dir=' + encodeURIComponent(dir);
443
+ const res = await fetch(url);
444
+ if (!res.ok) {
445
+ const err = await res.text();
446
+ throw new Error(`HTTP ${res.status}: ${err}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  }
448
+ const items = await res.json(); // 数组,每个元素是路径字符串
449
+ if (items.length === 0) {
450
+ fileListDiv.innerHTML = '<div class="empty-message">📭 空目录</div>';
 
 
 
 
 
 
451
  } else {
452
+ // 根据路径是否以 / 结尾判断是目录还是文件
453
+ const html = items.map(path => {
454
+ const isDir = path.endsWith('/');
455
+ if (isDir) {
456
+ const dirName = path.slice(0, -1); // 去掉末尾斜杠,用于显示
457
+ return `
458
+ <div class="file-item">
459
+ <span class="directory-link" onclick="navigateTo('${path}')">
460
+ 📁 ${dirName}
461
+ </span>
462
+ <!-- 目录暂不提供删除按钮,可后续扩展 -->
463
+ </div>
464
+ `;
465
+ } else {
466
+ // 文件:显示下载链接和删除按钮
467
+ return `
468
+ <div class="file-item">
469
+ <a href="/file/${encodeURIComponent(path)}" target="_blank" class="file-link">
470
+ 📄 ${path}
471
+ </a>
472
+ <button class="button danger small" onclick="deleteFile('${path.replace(/'/g, "\\\\'")}')">删除</button>
473
+ </div>
474
+ `;
475
+ }
476
+ }).join('');
477
+ fileListDiv.innerHTML = html;
478
  }
479
+ } catch (err) {
480
+ fileListDiv.innerHTML = '<div class="empty-message">❌ 加载失败</div>';
481
+ addLog('列表加载错误: ' + err.message, true);
482
  }
483
+ }
484
 
485
+ // 导航到目录
486
+ window.navigateTo = function(dirPath) {
487
+ // dirPath 以 / 结尾,例如 "subdir/"
488
+ const dir = dirPath; // 直接使用,loadList 会处理
489
+ loadList(dir);
490
+ };
491
+
492
+ // 删除文件
493
+ window.deleteFile = async function(filename) {
494
+ if (!confirm(`确定删除 ${filename} 吗?`)) return;
495
+ try {
496
+ addLog('正在删除: ' + filename);
497
+ const res = await fetch('/delete/' + encodeURIComponent(filename), { method: 'DELETE' });
498
+ const text = await res.text();
499
+ if (!res.ok) throw new Error(`删除失败 (${res.status}): ${text}`);
500
+ addLog('✅ 删除成功: ' + filename);
501
+ // 刷新当前目录
502
+ loadList(currentDir.value);
503
+ } catch (err) {
504
+ addLog(err.message, true);
505
  }
506
+ };
507
+
508
+ // 返回上级目录
509
+ function goUp() {
510
+ let dir = currentDir.value.trim();
511
+ if (!dir) return; // 已在根目录
512
+ // 移除末尾的 /
513
+ dir = dir.endsWith('/') ? dir.slice(0, -1) : dir;
514
+ const parts = dir.split('/');
515
+ parts.pop(); // 去掉最后一级
516
+ const parent = parts.length ? parts.join('/') + '/' : '';
517
+ loadList(parent);
518
+ }
519
 
520
+ // 左侧文件选择显示
521
+ fileInput.addEventListener('change', function() {
522
+ if (fileInput.files.length > 0) {
523
+ fileLabel.textContent = '📄 ' + fileInput.files[0].name;
524
+ } else {
525
+ fileLabel.textContent = '📎 选择文件';
526
+ }
527
+ });
528
+
529
+ // 左侧上传按钮
530
+ uploadBtn.addEventListener('click', async () => {
531
+ const file = fileInput.files[0];
532
+ if (!file) { alert('请选择文件'); return; }
533
+
534
+ const dir = uploadDir.value;
535
+ const remotePath = buildRemotePath(dir, file.name);
536
+
537
+ clearLog();
538
+ addLog('开始上传到: ' + remotePath);
539
+ uploadBtn.disabled = true;
540
+
541
+ const formData = new FormData();
542
+ formData.append('file', file);
543
+ formData.append('dir', dir);
544
+
545
+ try {
546
+ const res = await fetch('/upload', { method: 'POST', body: formData });
547
+ const data = await res.json();
548
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
549
+ addLog('✅ 上传成功!路径: ' + data.filename);
550
+ fileInput.value = '';
551
+ fileLabel.textContent = '📎 选择文件';
552
+ uploadDir.value = '';
553
+ // 上传后刷新当前目录
554
+ loadList(currentDir.value);
555
+ } catch (err) {
556
+ addLog('❌ ' + err.message, true);
557
+ } finally {
558
+ uploadBtn.disabled = false;
559
+ }
560
+ });
561
+
562
+ // 列出文件按钮
563
+ listBtn.addEventListener('click', () => {
564
+ loadList(currentDir.value.trim());
565
+ });
566
+
567
+ // 返回上级按钮
568
+ goUpBtn.addEventListener('click', goUp);
569
+
570
+ // API 测试:上传文件
571
+ apiUploadBtn.addEventListener('click', async () => {
572
+ const file = getUploadFile();
573
+ if (!file) {
574
+ alert('请选择文件(在左侧或右侧上传区域选择)');
575
+ return;
576
  }
577
 
578
+ const dir = apiUploadDir.value;
579
+ const remotePath = buildRemotePath(dir, file.name);
580
+
581
+ const formData = new FormData();
582
+ formData.append('file', file);
583
+ formData.append('dir', dir);
584
+
585
+ try {
586
+ showApiResult('正在上传到 ' + remotePath + ' ...');
587
+ const res = await fetch('/upload', { method: 'POST', body: formData });
588
+ const data = await res.json();
589
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
590
+ showApiResult(data);
591
+ loadList(currentDir.value);
592
+ } catch (err) {
593
+ showApiResult(err.message, true);
594
+ }
595
+ });
596
+
597
+ // API 测试:列出文件
598
+ apiListBtn.addEventListener('click', async () => {
599
+ const dir = apiListDir.value.trim();
600
+ try {
601
+ showApiResult('正在请求 /list?dir=' + dir + ' ...');
602
+ let url = '/list';
603
+ if (dir) url += '?dir=' + encodeURIComponent(dir);
604
+ const res = await fetch(url);
605
+ const data = await res.json();
606
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
607
+ showApiResult(data);
608
+ // 同时更新左侧浏览目录为相同目录并刷新
609
+ loadList(dir);
610
+ } catch (err) {
611
+ showApiResult(err.message, true);
612
+ }
613
+ });
614
+
615
+ // API 测试:获取文件信息
616
+ apiGetBtn.addEventListener('click', async () => {
617
+ const filename = apiFilename.value.trim();
618
+ if (!filename) { alert('请输入文件名'); return; }
619
+ try {
620
+ showApiResult('正在检查 /file/' + filename + ' ...');
621
+ const res = await fetch('/file/' + encodeURIComponent(filename), { method: 'HEAD' });
622
+ if (res.ok) {
623
+ showApiResult({ status: 'OK', message: '文件存在,可下载' });
624
+ } else {
625
+ const text = await res.text();
626
+ throw new Error(`HTTP ${res.status}: ${text}`);
627
  }
628
+ } catch (err) {
629
+ showApiResult(err.message, true);
630
  }
631
+ });
632
+
633
+ // API 测试:删除文件
634
+ apiDeleteBtn.addEventListener('click', async () => {
635
+ const filename = apiDeleteFilename.value.trim();
636
+ if (!filename) { alert('请输入文件名'); return; }
637
+ if (!confirm(`确定通过 API 删除 ${filename} 吗?`)) return;
638
+ try {
639
+ showApiResult('正在删除 ' + filename + ' ...');
640
+ const res = await fetch('/delete/' + encodeURIComponent(filename), { method: 'DELETE' });
641
+ const text = await res.text();
642
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${text}`);
643
+ showApiResult({ success: true, message: '文件已删除' });
644
+ loadList(currentDir.value);
645
+ } catch (err) {
646
+ showApiResult(err.message, true);
 
 
 
 
647
  }
648
+ });
649
 
650
+ // 初始化:加载根目录
651
+ loadList('');
652
+ })();
653
+ </script>
654
+ </body>
655
+ </html>"""
656
+
657
+
658
+ @app.route('/')
659
+ def index():
660
+ return HTML_CONTENT
661
+
662
+
663
+ @app.route('/upload', methods=['POST'])
664
+ def upload():
665
+ if 'file' not in request.files:
666
+ return jsonify({'error': 'No file part'}), 400
667
+ file = request.files['file']
668
+ if file.filename == '':
669
+ return jsonify({'error': 'No selected file'}), 400
670
+
671
+ target_dir = request.form.get('dir', '').strip()
672
+ filename = file.filename
673
+ if target_dir:
674
+ # 规范化目录路径:去除前导 '/',确保末尾有 '/'
675
+ if target_dir.startswith('/'):
676
+ target_dir = target_dir[1:]
677
+ if not target_dir.endswith('/'):
678
+ target_dir += '/'
679
+ remote_path = target_dir + filename
680
+ else:
681
+ remote_path = filename
682
+
683
+ with tempfile.NamedTemporaryFile(delete=False) as tmp:
684
+ file.save(tmp.name)
685
+ try:
686
+ batch_bucket_files(
687
+ bucket_id=BUCKET_ID,
688
+ add=[(tmp.name, remote_path)],
689
+ token=HF_TOKEN
690
+ )
691
+ except Exception as e:
692
+ return jsonify({'error': str(e)}), 500
693
+ finally:
694
+ os.unlink(tmp.name)
695
+
696
+ return jsonify({'success': True, 'filename': remote_path})
697
+
698
+
699
+ @app.route('/list')
700
+ def list_files():
701
+ """
702
+ 列出 bucket 中指定目录下的第一层内容(文件和目录)。
703
+ 若未提供 dir 参数或 dir 为空,则列出根目录下的第一层内容。
704
+ 返回的列表元素为路径字符串,目录路径以 '/' 结尾,文件路径不以 '/' 结尾。
705
+ """
706
+ dir_param = request.args.get('dir', '').strip()
707
+ try:
708
+ # 规范化目录参数
709
+ if dir_param:
710
+ if dir_param.startswith('/'):
711
+ dir_param = dir_param[1:]
712
+ if not dir_param.endswith('/'):
713
+ dir_param += '/'
714
+ # 当 dir_param 为空时,prefix='' 表示根目录
715
+ items = list_bucket_tree(
716
+ bucket_id=BUCKET_ID,
717
+ prefix=dir_param,
718
+ recursive=False, # 不递归,只列出该目录下的直接内容
719
+ token=HF_TOKEN
720
+ )
721
+ # 直接返回所有条目的 path,不区分类型(前端通过是否以 / 结尾来判断目录)
722
+ paths = [item.path for item in items]
723
+ return jsonify(paths)
724
+ except Exception as e:
725
+ return jsonify({'error': str(e)}), 500
726
+
727
+
728
+ @app.route('/file/<path:filename>')
729
+ def get_file(filename):
730
+ try:
731
+ with tempfile.TemporaryDirectory() as tmpdir:
732
+ local_path = os.path.join(tmpdir, filename)
733
+ download_bucket_files(
734
+ bucket_id=BUCKET_ID,
735
+ files=[(filename, local_path)],
736
+ token=HF_TOKEN
737
+ )
738
+ return send_file(local_path, as_attachment=True, download_name=filename)
739
+ except Exception as e:
740
+ return jsonify({'error': str(e)}), 500
741
+
742
+
743
+ @app.route('/delete/<path:filename>', methods=['DELETE'])
744
+ def delete_file(filename):
745
+ try:
746
+ batch_bucket_files(
747
+ bucket_id=BUCKET_ID,
748
+ delete=[filename],
749
+ token=HF_TOKEN
750
+ )
751
+ return jsonify({'success': True})
752
+ except Exception as e:
753
+ return jsonify({'error': str(e)}), 500
754
+
755
+
756
+ if __name__ == '__main__':
757
+ app.run(host='0.0.0.0', port=7860)