// web UI 的文件管理和磁盘空间 package main import ( "encoding/json" "errors" "net/http" "net/url" "os" "path/filepath" "runtime" "sort" "strings" "sync" ) // 文件管理里的一个文件 type fileEntry struct { Name string `json:"name"` // 文件名 Dir string `json:"dir"` // record 录播,merge 成片 Size int64 `json:"size"` // 字节 ModTime int64 `json:"modTime"` // 修改时间,Unix 秒 InUse bool `json:"inUse"` // 正在写入(录制分段或合并成片),不允许删除 } // 正在被写入的文件集合,避免用户在 web UI 里删掉还在录制的文件 var activeFiles struct { sync.Mutex m map[string]struct{} } // 路径比较用的统一形式:先转绝对路径,Windows 下再统一大小写 // 录制侧拼出来的路径可能是相对的(-record .),文件管理接口拼的是绝对路径, // 不先归一的话「正在写入」的判断会对不上 func normalizePath(path string) string { if abs, err := filepath.Abs(path); err == nil { path = abs } path = filepath.Clean(path) if runtime.GOOS == "windows" { path = strings.ToLower(path) } return path } // 标记文件开始/结束写入 func markFileActive(path string, active bool) { if path == "" { return } key := normalizePath(path) activeFiles.Lock() defer activeFiles.Unlock() if activeFiles.m == nil { activeFiles.m = make(map[string]struct{}) } if active { activeFiles.m[key] = struct{}{} } else { delete(activeFiles.m, key) } } // 文件是否正在被写入 func isFileActive(path string) bool { key := normalizePath(path) activeFiles.Lock() defer activeFiles.Unlock() _, ok := activeFiles.m[key] return ok } // 文件管理里 dir 参数对应的文件夹 func fileDir(kind string) string { if kind == "merge" { return mergeSaveDir() } return recordSaveDir() } // 只允许文件名本身,防止用相对路径跳出录音文件夹 func resolveFile(kind, name string) (string, error) { if name == "" { return "", errors.New("缺少文件名") } // 不管哪个平台都拒绝路径分隔符,Windows 下 Base 会认反斜杠,Linux 下不会 if name == "." || name == ".." || name != filepath.Base(filepath.FromSlash(name)) || strings.ContainsAny(name, `/\`) { return "", errors.New("文件名不合法") } dir, err := filepath.Abs(fileDir(kind)) if err != nil { return "", err } full := filepath.Join(dir, name) rel, err := filepath.Rel(dir, full) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", errors.New("文件名不合法") } return full, nil } // 列出文件夹里的文件(不含子文件夹) func listFiles(dir string) []fileEntry { entries, err := os.ReadDir(dir) if err != nil { return nil } out := make([]fileEntry, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } info, err := e.Info() if err != nil { continue } out = append(out, fileEntry{ Name: e.Name(), Size: info.Size(), ModTime: info.ModTime().Unix(), InUse: isFileActive(filepath.Join(dir, e.Name())), }) } return out } // 浏览器能直接播放的格式 func playableFile(name string) bool { switch strings.ToLower(filepath.Ext(name)) { case ".mp4", ".m4v", ".webm", ".ogv": return true } return false } // 显示用的绝对路径,取不到就返回原值 func absPath(path string) string { if abs, err := filepath.Abs(path); err == nil { return abs } return path } // 列出录播和成片文件夹里的文件、目录位置和磁盘剩余空间 func filesHandler(w http.ResponseWriter, r *http.Request) { recordDir, mergeDir := recordSaveDir(), mergeSaveDir() files := make([]fileEntry, 0, 64) for _, f := range listFiles(recordDir) { f.Dir = "record" files = append(files, f) } if normalizePath(mergeDir) != normalizePath(recordDir) { for _, f := range listFiles(mergeDir) { f.Dir = "merge" files = append(files, f) } } // 最近修改的排前面 sort.Slice(files, func(i, j int) bool { if files[i].ModTime != files[j].ModTime { return files[i].ModTime > files[j].ModTime } return files[i].Name < files[j].Name }) free, total, err := diskSpace(saveRootDir()) if err != nil { lPrintWarnf("获取磁盘剩余空间失败:%v", err) } resp := struct { RecordDir string `json:"recordDir"` MergeDir string `json:"mergeDir"` Free uint64 `json:"free"` Total uint64 `json:"total"` Files []fileEntry `json:"files"` }{absPath(recordDir), absPath(mergeDir), free, total, files} w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(resp); err != nil { lPrintErrf("输出文件列表失败:%v", err) } } // 下载文件,?inline=1 时让浏览器直接播放(只对浏览器支持的格式生效) func fileDownloadHandler(w http.ResponseWriter, r *http.Request) { kind := r.URL.Query().Get("dir") name := r.URL.Query().Get("name") full, err := resolveFile(kind, name) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } info, err := os.Stat(full) if err != nil || info.IsDir() { http.Error(w, "文件不存在", http.StatusNotFound) return } if r.URL.Query().Get("inline") == "1" && playableFile(name) { // ServeFile 会用文件扩展名推测 Content-Type,这里不额外设置 } else { // 中文文件名要用 RFC 5987 的写法,否则部分浏览器会乱码 w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(filepath.Base(full))) } http.ServeFile(w, r, full) } // 删除文件 func fileRemoveHandler(w http.ResponseWriter, r *http.Request) { var req struct { Dir string `json:"dir"` Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) return } full, err := resolveFile(req.Dir, req.Name) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if isFileActive(full) { http.Error(w, "这个文件正在录制或合并中,不能删除", http.StatusConflict) return } info, err := os.Stat(full) if err != nil || info.IsDir() { http.Error(w, "文件不存在", http.StatusNotFound) return } if err := os.Remove(full); err != nil { http.Error(w, "删除失败:"+err.Error(), http.StatusInternalServerError) return } lPrintf("通过 web UI 删除了文件 %s(%d 字节)", full, info.Size()) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte("true")) }