diff --git a/README.md b/README.md index b70dc30..d32890e 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,15 @@ curl -F "file=@report.pdf" http://localhost:8080/upload ## 配置(命令行 flag) ```text --dir ./data/uploads 上传文件保存目录 --listen :8080 HTTP 监听地址 --ttl 24h 文件过期 TTL --scan 1h 清理扫描间隔 --quota 10737418240 总目录配额(字节,默认 10 GB;0 = 不限) --audit-dir ./data/audit 审计日志目录(每天一个 YYYY-MM-DD.log 文件;空字符串禁用) +-dir ./data/uploads 上传文件保存目录 +-listen :8080 HTTP 监听地址 +-ttl 24h 文件过期 TTL +-scan 1h 清理扫描间隔 +-quota 10737418240 总目录配额(字节,默认 10 GB;0 = 不限) +-audit-dir ./data/audit 审计日志目录(每天一个 YYYY-MM-DD.log 文件;空字符串禁用) +-file-viewer-cdn https://unpkg.com/@file-viewer/web-full@3.0.0 文件预览库 CDN 地址 +-file-viewer-cache ./data/file-viewer 文件预览库资源缓存目录(空字符串 = 禁用,全部 CDN 透传) +-file-viewer-ttl 0 缓存文件有效期(0 = 永久) ``` 示例:把 TTL 改成 1 小时、配额 1 GB: @@ -55,6 +58,7 @@ curl -F "file=@report.pdf" http://localhost:8080/upload | POST | `/download-zip` | 批量下载 (body: `{"files":[...]}`, 最多 100 项) | | POST | `/files-delete` | 批量删除 (body: `{"files":[...]}`, 最多 100 项) | | DELETE | `/files/:filename` | 删除文件 | +| GET | `/file-viewer/*filepath` | 文件预览库资源(CDN 缓存代理) | --- diff --git a/fileviewer.go b/fileviewer.go new file mode 100644 index 0000000..d8422b0 --- /dev/null +++ b/fileviewer.go @@ -0,0 +1,171 @@ +package main + +import ( + "flag" + "io" + "log" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// ----------------------------------------------------------------------------- +// file-viewer CDN 资源本地缓存代理 +// +// 前端 HTML 引用 /file-viewer/dist/flyfish-file-viewer-web-full.iife.js, 后端 +// 先读本地缓存, 命中直接返回; 未命中从 unpkg 拉, 边写盘边流回客户端, 后续请求 +// 全部走磁盘. vendor/ 资源同理按需拉取. +// +// 设计取舍: +// - 缓存键 = URL 相对路径 (如 dist/foo.js / vendor/libarchive/worker-bundle.js). +// 升级 library 版本需手动 -rm 缓存目录, 避免旧版资源污染. +// - 缓存目录不可写时降级为纯 CDN 透传, 不阻断预览功能. +// - 缓存关闭 (-file-viewer-cache="") 时完全走 CDN, 行为与改造前一致. +// ----------------------------------------------------------------------------- + +var ( + fileViewerCDN = flag.String("file-viewer-cdn", "https://unpkg.com/@file-viewer/web-full@3.0.0", "file-viewer CDN base URL") + fileViewerCacheDir = flag.String("file-viewer-cache", "./data/file-viewer", "file-viewer 资源缓存目录 (空字符串 = 禁用缓存, 全部 CDN 透传)") + fileViewerCacheTTL = flag.Duration("file-viewer-ttl", 0, "缓存文件最大有效期 (0 = 永久, 仅在文件已存在时刷新 Content-Type)") +) + +// 30 min 足够 330 KB 的 iife 与 vendor/ 下最大的 docx/pptx wasm (~50 MB) 在慢网下完成 +var fileViewerHTTPClient = &http.Client{Timeout: 30 * time.Minute} + +// fileViewerProxy 处理 GET /file-viewer/*filepath. +// 1. 校验路径防 ../ 越界 +// 2. 缓存命中 -> 直接读盘 +// 3. 缓存未命中 -> 拉上游, 边写盘边流回 +// 4. 缓存关闭 -> 直接透传到 CDN +func fileViewerProxy(c *gin.Context) { + raw := c.Param("filepath") + name, ok := validateFileViewerPath(raw) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) + return + } + + if *fileViewerCacheDir == "" { + proxyFileViewer(c, name) + return + } + + cachePath := filepath.Join(*fileViewerCacheDir, filepath.FromSlash(name)) + + // 命中: 直接读盘 (TTL > 0 且文件超过有效期则强制回源) + if info, err := os.Stat(cachePath); err == nil && !info.IsDir() { + if *fileViewerCacheTTL <= 0 || time.Since(info.ModTime()) < *fileViewerCacheTTL { + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.File(cachePath) + return + } + log.Printf("[file-viewer] cache expired, refetching %s", name) + } + + // 未命中: 拉上游 + 边写盘边流回 + fetchAndCache(c, cachePath, name) +} + +// validateFileViewerPath 校验并清理 fileviewer 子路径, 防 ../ 越界. +// gin 的 *filepath wildcard 包含前导 '/', 先 strip 再清理. +// 先按 '/' 切分检查 `..` 分量 (path.Clean 会折叠中间 .., 仅靠它不够), +// 再 path.Clean 兜底处理 // / ./ 等冗余. +func validateFileViewerPath(raw string) (string, bool) { + raw = strings.TrimPrefix(raw, "/") + if raw == "" || strings.ContainsRune(raw, 0) { + return "", false + } + for part := range strings.SplitSeq(raw, "/") { + if part == ".." { + return "", false + } + } + clean := path.Clean(raw) + if clean == "." || clean == "" || strings.HasPrefix(clean, "..") || strings.HasPrefix(clean, "/") { + return "", false + } + return clean, true +} + +// proxyFileViewer: 缓存关闭时直接透传到 CDN. +func proxyFileViewer(c *gin.Context, name string) { + upstream := upstreamURL(name) + resp, err := fileViewerHTTPClient.Get(upstream) + if err != nil { + log.Printf("[file-viewer] upstream fetch %s: %v", name, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream fetch failed"}) + return + } + defer resp.Body.Close() + + if ct := resp.Header.Get("Content-Type"); ct != "" { + c.Header("Content-Type", ct) + } + c.Status(resp.StatusCode) + if _, err := io.Copy(c.Writer, resp.Body); err != nil { + log.Printf("[file-viewer] stream %s: %v", name, err) + } +} + +// fetchAndCache: 从 CDN 拉取资源, 同时写盘 + 流回客户端. +func fetchAndCache(c *gin.Context, cachePath, name string) { + upstream := upstreamURL(name) + resp, err := fileViewerHTTPClient.Get(upstream) + if err != nil { + log.Printf("[file-viewer] upstream fetch %s: %v", name, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream fetch failed"}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("[file-viewer] upstream %s returned %d", name, resp.StatusCode) + c.JSON(http.StatusBadGateway, gin.H{"error": "upstream returned " + resp.Status}) + return + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + c.Header("Content-Type", ct) + } + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Status(http.StatusOK) + + // 目录创建失败 -> 降级透传, 不缓存 + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + log.Printf("[file-viewer] mkdir cache dir: %v", err) + _, _ = io.Copy(c.Writer, resp.Body) + return + } + f, err := os.Create(cachePath) + if err != nil { + log.Printf("[file-viewer] create cache file: %v", err) + _, _ = io.Copy(c.Writer, resp.Body) + return + } + + // 关键: MultiWriter 让客户端拿到的同时落盘, 用户感知零等待 + mw := io.MultiWriter(c.Writer, f) + written, copyErr := io.Copy(mw, resp.Body) + cErr := f.Close() + + if copyErr != nil || cErr != nil { + _ = os.Remove(cachePath) // 写一半的文件清理掉, 下次重新拉 + if copyErr != nil { + log.Printf("[file-viewer] copy %s: %v", name, copyErr) + } + if cErr != nil { + log.Printf("[file-viewer] close %s: %v", name, cErr) + } + return + } + log.Printf("[file-viewer] cached %s (%d bytes)", name, written) +} + +func upstreamURL(name string) string { + return strings.TrimRight(*fileViewerCDN, "/") + "/" + name +} \ No newline at end of file diff --git a/main.go b/main.go index f0bcbd3..7414081 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,9 @@ func main() { r.DELETE("/files/:filename", deleteHandler) r.GET("/files", listHandler) + // file-viewer CDN 资源本地缓存代理 (前端文件预览依赖) + r.GET("/file-viewer/*filepath", fileViewerProxy) + log.Printf("server listening on %s, uploads -> %s, ttl=%s", *listen, *uploadDir, *fileTTL) if err := r.Run(*listen); err != nil { log.Fatal(err) diff --git a/static/app.css b/static/app.css index 6412c2c..6327f11 100644 --- a/static/app.css +++ b/static/app.css @@ -357,3 +357,65 @@ transition: width 0.2s; } .total-label { font-size: 12px; color: var(--text-dim); white-space: nowrap; } + + /* Preview modal */ + .preview-modal { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + } + .preview-modal[hidden] { display: none; } + .preview-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(2px); + } + .preview-panel { + position: relative; + width: min(1100px, 92vw); + height: min(820px, 88vh); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.4); + display: flex; + flex-direction: column; + overflow: hidden; + } + .preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--panel-2); + } + .preview-title { + flex: 1; + min-width: 0; + color: var(--text); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .preview-actions { display: flex; gap: 6px; flex-shrink: 0; } + .preview-body { + flex: 1; + min-height: 0; + background: var(--bg); + overflow: auto; + } + .preview-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-dim); + font-size: 14px; + } diff --git a/static/app.js b/static/app.js index ab782d7..c4e9061 100644 --- a/static/app.js +++ b/static/app.js @@ -23,6 +23,12 @@ const batchBtn = $('batchBtn'); const batchCount = $('batchCount'); const batchDeleteBtn = $('batchDeleteBtn'); const batchDeleteCount= $('batchDeleteCount'); +const previewModal = $('previewModal'); +const previewBackdrop= $('previewBackdrop'); +const previewTitle = $('previewTitle'); +const previewBody = $('previewBody'); +const previewClose = $('previewClose'); +const previewDownload= $('previewDownload'); // pending[i] = { id, file, xhr, status: 'pending'|'uploading'|'done'|'error'|'cancelled', progress, error } let pending = []; @@ -377,12 +383,14 @@ function fileRow(f) {
+
`; div.querySelector('.file-check').addEventListener('change', e => onCheckChange(f.filename, e.target.checked)); + div.querySelector('[data-preview]').onclick = () => openPreview(f.filename, f.url); div.querySelector('[data-url]').onclick = () => copyToClipboard(location.origin + f.url); div.querySelector('[data-delete]').onclick = () => deleteFile(f.filename, div); return div; @@ -439,5 +447,58 @@ async function copyToClipboard(text) { } } +// --------------------------------------------------------------------------- +// Preview modal (依赖 CDN 加载的 FlyfishFileViewerWebFull 全局对象) +// --------------------------------------------------------------------------- +let previewViewer = null; + +function openPreview(filename, url) { + if (!window.FlyfishFileViewerWebFull) { + toast('预览组件尚未加载完成, 请稍后再试', 'error'); + return; + } + previewTitle.textContent = filename; + previewDownload.href = url; + previewDownload.download = filename; + previewBody.innerHTML = '
加载中...
'; + previewModal.hidden = false; + document.body.style.overflow = 'hidden'; + + // 卸载上一个实例, 防内存泄漏 + if (previewViewer && typeof previewViewer.destroy === 'function') { + try { previewViewer.destroy(); } catch {} + } + previewViewer = null; + + // mountViewer 接收 element 或 selector; 用容器 id 保证重入安全 + try { + previewViewer = window.FlyfishFileViewerWebFull.mountViewer(previewBody, { + url, + options: { + theme: 'dark', + toolbar: { position: 'bottom-right' }, + }, + }); + } catch (e) { + previewBody.innerHTML = `
预览失败: ${escapeHtml(e && e.message || 'unknown')}
`; + } +} + +function closePreview() { + previewModal.hidden = true; + document.body.style.overflow = ''; + if (previewViewer && typeof previewViewer.destroy === 'function') { + try { previewViewer.destroy(); } catch {} + } + previewViewer = null; + previewBody.innerHTML = ''; +} + +previewClose.addEventListener('click', closePreview); +previewBackdrop.addEventListener('click', closePreview); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && !previewModal.hidden) closePreview(); +}); + // formatRemain / isAlmostExpired / iconFor / formatSize / formatTime / escapeHtml // 见 helpers.js; 倒计时 setInterval 也在 helpers.js 中启动 diff --git a/static/index.html.tpl b/static/index.html.tpl index 6617ad9..0244430 100644 --- a/static/index.html.tpl +++ b/static/index.html.tpl @@ -62,6 +62,22 @@
+ + + +