init
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
// 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"))
|
||||
}
|
||||
Reference in New Issue
Block a user