fonchain-fiee/pkg/service/file/file.go

568 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package file
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"fonchain-fiee/api/files"
"fonchain-fiee/pkg/service"
"fonchain-fiee/pkg/service/bundle/common"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func Raw(ctx *gin.Context) {
r := ctx.Request
w := ctx.Writer
w.Header().Add("Content-Security-Policy", `script-src 'none';`)
w.Header().Set("Cache-Control", "private")
rs, err := newGrpcReaderSeeker(getUserSpacePath(ctx), ctx.Param("path"))
if err != nil {
service.Error(ctx, errors.New(common.FileListFailed))
return
}
if r.URL.Query().Get("inline") == "true" {
w.Header().Set("Content-Disposition", "inline")
} else {
// As per RFC6266 section 4.3
w.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+rs.FileName)
}
http.ServeContent(ctx.Writer, r, rs.FileName, time.Now(), rs)
}
func List(ctx *gin.Context) {
path := ctx.DefaultQuery("path", "/")
sortBy := ctx.DefaultQuery("sortBy", "name")
sortAsc, _ := strconv.ParseBool(ctx.DefaultQuery("sortAsc", "true"))
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(ctx.DefaultQuery("pageSize", "100000"))
fileName := ctx.DefaultQuery("fileName", "")
resp, err := service.FilesProvider.List(ctx, &files.FileListReq{
Path: path,
UserSpacePath: getUserSpacePath(ctx),
Sorting: &files.Sorting{
By: sortBy,
Asc: sortAsc,
},
Page: int32(page),
PageSize: int32(pageSize),
FileName: fileName,
})
if err != nil {
service.Error(ctx, errors.New(common.FileListFailed))
return
}
service.Success(ctx, resp)
}
func Usage(ctx *gin.Context) {
path := ctx.DefaultQuery("path", "/")
resp, err := service.FilesProvider.Usage(ctx, &files.UsageReq{
Path: path,
UserSpacePath: getUserSpacePath(ctx),
})
if err != nil {
service.Error(ctx, errors.New(common.FileUsageFailed))
return
}
service.Success(ctx, resp)
}
func Info(ctx *gin.Context) {
resp, err := service.FilesProvider.Info(ctx, &files.FileInfoReq{
Path: ctx.DefaultQuery("path", "/"),
UserSpacePath: getUserSpacePath(ctx),
})
if err != nil {
service.Error(ctx, errors.New(common.GetFileInfoFailed))
return
}
service.Success(ctx, resp)
}
func Create(ctx *gin.Context) {
var req files.CreateReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
req.UserSpacePath = getUserSpacePath(ctx)
resp, err := service.FilesProvider.Create(ctx, &req)
if err != nil {
service.Error(ctx, errors.New(common.CreateFileFailed))
return
}
service.Success(ctx, resp)
}
// 文件夹路径保护函数
func isProtectedVideoPath(path string) bool {
path = strings.TrimSuffix(path, "/")
if path == "/fiee" || path == "/fiee/video" || path == "/fiee/video/old" {
return true
}
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 3 || parts[0] != "fiee" || parts[1] != "video" {
return false
}
// /fiee/video/<year> — 任意年
if len(parts) == 3 {
year, err := strconv.Atoi(parts[2])
return err == nil && year >= 1000 && year <= 9999
}
// /fiee/video/<year>/<year>-<month> — 任意月
if len(parts) == 4 {
year, errYear := strconv.Atoi(parts[2])
if errYear != nil || year < 1000 || year > 9999 {
return false
}
monthPart := parts[3]
idx := strings.Index(monthPart, "-")
if idx <= 0 {
return false
}
y, _ := strconv.Atoi(monthPart[:idx])
m, _ := strconv.Atoi(monthPart[idx+1:])
return y == year && m >= 1 && m <= 12
}
// /fiee/video/<year>/<year>-<month>/<year>-<month>-<day> — 任意日
if len(parts) == 5 {
year, errYear := strconv.Atoi(parts[2])
if errYear != nil || year < 1000 || year > 9999 {
return false
}
monthPart := parts[3]
idx := strings.Index(monthPart, "-")
if idx <= 0 {
return false
}
y, _ := strconv.Atoi(monthPart[:idx])
m, _ := strconv.Atoi(monthPart[idx+1:])
if y != year || m < 1 || m > 12 {
return false
}
dayPart := parts[4] // <year>-<month>-<day>
daySegs := strings.Split(dayPart, "-")
if len(daySegs) != 3 {
return false
}
dy, _ := strconv.Atoi(daySegs[0])
dm, _ := strconv.Atoi(daySegs[1])
dd, errDay := strconv.Atoi(daySegs[2])
return errDay == nil && dy == year && dm == m && dd >= 1 && dd <= 31
}
return false
}
func Delete(ctx *gin.Context) {
path := ctx.DefaultQuery("path", "/")
if isProtectedVideoPath(path) {
service.Error(ctx, errors.New("无法删除该目录"))
return
}
resp, err := service.FilesProvider.Delete(ctx, &files.DeleteReq{
Path: ctx.DefaultQuery("path", "/"),
UserSpacePath: getUserSpacePath(ctx),
})
if err != nil {
service.Error(ctx, errors.New(common.DeleteFileFailed))
return
}
service.Success(ctx, resp)
}
func Search(ctx *gin.Context) {
resp, err := service.FilesProvider.Search(ctx, &files.SearchReq{
UserSpacePath: getUserSpacePath(ctx),
Path: ctx.Query("path"),
Query: ctx.Query("query"),
})
if err != nil {
service.Error(ctx, errors.New(common.SearchFileFailed))
return
}
service.Success(ctx, resp)
}
func Upload(ctx *gin.Context) {
path, ok := ctx.GetQuery("path")
if !ok {
service.Error(ctx, errors.New("缺失参数路径"))
return
}
b, err := io.ReadAll(ctx.Request.Body)
if !ok {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
resp, err := service.FilesProvider.Upload(ctx, &files.UploadReq{
Path: path,
UserSpacePath: getUserSpacePath(ctx),
Content: b,
})
if err != nil {
service.Error(ctx, errors.New(common.UploadFileFailed))
return
}
service.Success(ctx, resp)
}
func TusCreate(ctx *gin.Context) {
var req files.TusCreateReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
req.UserSpacePath = getUserSpacePath(ctx)
resp, err := service.FilesProvider.TusCreate(ctx, &req)
if err != nil {
service.Error(ctx, errors.New(common.TusCreateFailed))
return
}
service.Success(ctx, resp)
}
func TusUpload(ctx *gin.Context) {
path, ok := ctx.GetQuery("path")
if !ok {
service.Error(ctx, errors.New("文件路径缺失"))
return
}
uploadOffset, err := getUploadOffset(ctx.Request)
if err != nil {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
mf, err := ctx.MultipartForm()
if err != nil {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
f, err := mf.File["file"][0].Open()
if err != nil {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
defer f.Close()
b, err := io.ReadAll(f)
// b, err := io.ReadAll(ctx.Request.Body)
if !ok {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
if uploadOffset == 0 && len(b) > 0 {
if strings.HasSuffix(strings.ToLower(path), ".mp4") {
if err != nil {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
if !isLikelyMP4(b) {
service.Error(ctx, errors.New(".mp4机器码校验错误,该文件可能不是标准的.mp4文件"))
//删除对应空文件
_, err = service.FilesProvider.Delete(ctx, &files.DeleteReq{
Path: ctx.DefaultQuery("path", "/"),
UserSpacePath: getUserSpacePath(ctx),
})
if err != nil {
service.Error(ctx, errors.New(common.DeleteFileFailed))
return
}
return
}
}
}
resp, err := service.FilesProvider.TusUpload(ctx, &files.TusUploadReq{
Path: path,
UploadOffset: uploadOffset,
Content: b,
UserSpacePath: getUserSpacePath(ctx),
})
if err != nil {
service.Error(ctx, errors.New(common.TusUploadFailed))
return
}
ctx.Writer.Header().Set("Upload-Offset", strconv.FormatInt(resp.UploadOffset, 10))
service.Success(ctx, nil)
}
func TusUploadMP4(ctx *gin.Context) {
//读取传入的本地路径文件
var req files.TusCreateReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
userPath := req.UserSpacePath
req.UserSpacePath = getUserSpacePath(ctx)
fileInfo, err := os.Stat(userPath)
if err != nil {
service.Error(ctx, errors.New(common.GetFileInfoFailed))
return
}
if fileInfo.IsDir() {
service.Error(ctx, errors.New("localPath 不能是目录"))
return
}
totalSize := fileInfo.Size()
if totalSize <= 0 {
service.Error(ctx, errors.New("文件为空"))
return
}
//如果是.mp4文件则判断文件大小
if strings.HasSuffix(strings.ToLower(req.Path), ".mp4") && fileInfo.Size() > 1024*1024 {
//大于1mb的文件进行分块上传
f, err := os.Open(userPath)
if err != nil {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
defer f.Close()
const chunkSize int64 = 2*1024*1024 - 100
for offset := int64(0); offset < totalSize; {
end := offset + chunkSize
if end > totalSize {
end = totalSize
}
n := end - offset
buf := make([]byte, n)
// 用 ReadAt 读指定偏移的分片
readN, readErr := f.ReadAt(buf, offset)
if readErr != nil && readErr != io.EOF {
service.Error(ctx, errors.New(common.ERROR_OPEN_FILE))
return
}
if int64(readN) != n {
service.Error(ctx, errors.New("读取分片长度不一致"))
return
}
// 4) 首包 MP4 机器码校验offset==0
if offset == 0 {
if !isLikelyMP4(buf) {
service.Error(ctx, errors.New("mp4机器码校验失败,该文件可能不是标准 mp4"))
return
}
}
// 5) TusUpload 写入当前分片
_, err = service.FilesProvider.TusUpload(ctx, &files.TusUploadReq{
Path: req.Path,
UploadOffset: offset,
Content: buf,
UserSpacePath: req.UserSpacePath,
})
if err != nil {
service.Error(ctx, errors.New(common.TusUploadFailed))
return
}
offset = end
}
service.Success(ctx, gin.H{
"destPath": req.Path,
"localPath": userPath,
"uploadLength": totalSize,
})
}
}
func Preview(ctx *gin.Context) {
var size int
size, err := strconv.Atoi(ctx.Param("size"))
if err != nil {
size = 1
}
resp, err := service.FilesProvider.Preview(ctx, &files.PreviewReq{
Path: ctx.Param("path"),
UserSpacePath: getUserSpacePath(ctx),
Size: uint32(size),
})
if err != nil {
service.Error(ctx, errors.New(common.PreviewFileFailed))
return
}
ctx.Writer.Header().Set("Cache-Control", "private")
http.ServeContent(ctx.Writer, ctx.Request, resp.FileName, time.UnixMilli(resp.ModTime), bytes.NewReader(resp.Content))
}
func Action(ctx *gin.Context) {
var req files.ActionReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
req.UserSpacePath = getUserSpacePath(ctx)
resp, err := service.FilesProvider.Action(ctx, &req)
if err != nil {
service.Error(ctx, errors.New(common.ActionFailed))
return
}
service.Success(ctx, resp)
}
func DirDownload(ctx *gin.Context) {
path := ctx.Query("path")
fileList := strings.Split(ctx.Query("files"), ",")
algo := ctx.Query("algo")
stream, err := service.FilesProvider.DirDownload(ctx, &files.DirDownloadReq{
Algo: algo,
Files: fileList,
Path: path,
UserSpacePath: getUserSpacePath(ctx),
})
if err != nil {
service.Error(ctx, errors.New(common.DirDownloadFailed))
return
}
header, err := stream.Header()
if err != nil {
service.Error(ctx, errors.New(common.DirDownloadFailed))
return
}
ctx.Writer.Header().Set("Content-Disposition", "attachment; filename*=utf-8''"+url.PathEscape(header.Get("filename")[0]))
for {
recvMsg, err := stream.Recv()
if err != nil {
break
}
ctx.Writer.Write(recvMsg.Content)
}
}
func getUploadOffset(r *http.Request) (int64, error) {
uploadOffset, err := strconv.ParseInt(r.Header.Get("Upload-Offset"), 10, 64)
if err != nil {
return 0, errors.New(common.InvalidUploadOffset)
}
return uploadOffset, nil
}
func getUserSpacePath(ctx *gin.Context) string {
// user := login.GetUserInfoFromC(ctx)
// return strconv.Itoa(int(user.ID))
return ""
}
func SecurityScan(ctx *gin.Context) {
var req files.SecurityScanReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
go func() {
// 使用独立的 context避免原请求 context 被取消
scanCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
// 执行安全扫描
_, err := service.FilesProvider.SecurityScan(scanCtx, &req)
if err != nil {
// 记录错误日志
zap.L().Error("安全扫描失败",
zap.String("url", req.Url),
zap.String("fileName", req.FileName),
zap.Error(err))
} else {
zap.L().Info("安全扫描完成",
zap.String("url", req.Url),
zap.String("fileName", req.FileName))
}
}()
service.Success(ctx, gin.H{
"message": "安全扫描任务已提交,正在后台处理",
})
}
func UpdateFileSecurityStatus(ctx *gin.Context) {
var req files.UpdateFileSecurityStatusReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
resp, err := service.FilesProvider.UpdateFileSecurityStatus(ctx, &req)
if err != nil {
service.Error(ctx, err)
return
}
service.Success(ctx, resp)
}
func ManualAnti(ctx *gin.Context) {
var req files.ManualAntiReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
go func() {
resp, err := service.FilesProvider.ManualAnti(ctx, &req)
if err != nil {
service.Error(ctx, err)
return
}
service.Success(ctx, resp)
}()
service.Success(ctx, gin.H{
"message": "手动反制任务已提交,正在后台处理",
})
}
func GetFileSecurityStatus(ctx *gin.Context) {
var req files.GetFileSecurityStatusReq
if err := ctx.ShouldBindJSON(&req); err != nil {
service.Error(ctx, err)
return
}
resp, err := service.FilesProvider.GetFileSecurityStatus(ctx, &req)
if err != nil {
service.Error(ctx, err)
return
}
service.Success(ctx, resp)
}
func isLikelyMP4(b []byte) bool {
fmt.Println("开始效验机器码")
if len(b) < 16 {
fmt.Println("len(b) < 16", len(b))
return false
}
limit := 2048
if len(b) < limit {
limit = len(b)
}
ftyp := []byte("ftyp")
idx := bytes.Index(b[:limit], ftyp)
if idx < 4 { // idx-4 处需要是 size
return false
}
if idx+12 > len(b) { // ftyp(4) + major_brand(4) + minor_version(4) 需要至少覆盖到 major
return false
}
// box size: b[idx-4 : idx] (big-endian)
size := binary.BigEndian.Uint32(b[idx-4 : idx])
if size < 16 {
return false
}
major := string(b[idx+4 : idx+8]) // major_brand
switch major {
case "isom", "iso2", "iso6", "mp41", "mp42", "avc1", "dash":
return true
default:
return false
}
}