245 lines
6.5 KiB
Go
245 lines
6.5 KiB
Go
// 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"))
|
||
}
|