417 lines
13 KiB
Go
417 lines
13 KiB
Go
package handlers
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.mongodb.org/mongo-driver/v2/bson"
|
||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||
|
||
"yh_web/server/config"
|
||
"yh_web/server/models"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// getUploadDir 上传根目录:容器内通过 UPLOAD_DIR 挂载到独立可写路径(如 /uploads),避免 /app 只读
|
||
func getUploadDir() string {
|
||
if d := os.Getenv("UPLOAD_DIR"); d != "" {
|
||
return d
|
||
}
|
||
return "uploads"
|
||
}
|
||
|
||
// pathPrefix 站点下相对路径前缀,用于多级目录
|
||
func pathPrefix(siteID string) string {
|
||
return "sites/" + siteID + "/"
|
||
}
|
||
|
||
// ListSiteAssets 站点功能模块/上传文件列表;query path 为当前目录相对路径(空为根);downloadable=1 时返回该站点下所有可下载文件(供首页编辑选择)
|
||
func ListSiteAssets(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
onlyDownloadable := c.Query("downloadable") == "1" || c.Query("downloadable") == "true"
|
||
if onlyDownloadable {
|
||
listDownloadableAssets(c, siteID)
|
||
return
|
||
}
|
||
path := c.Query("path")
|
||
prefix := pathPrefix(siteID)
|
||
if path != "" {
|
||
prefix = prefix + path
|
||
if prefix[len(prefix)-1] != '/' {
|
||
prefix += "/"
|
||
}
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
|
||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||
filter := bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix) + "[^/]+$"}}
|
||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||
cursor, err := coll.Find(ctx, filter, opts)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
defer cursor.Close(ctx)
|
||
|
||
var list []models.SiteAsset
|
||
if err = cursor.All(ctx, &list); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
total, _ := coll.CountDocuments(ctx, filter)
|
||
subDirs := listSubDirs(c, siteID, path)
|
||
c.JSON(http.StatusOK, gin.H{"list": list, "total": total, "sub_dirs": subDirs})
|
||
}
|
||
|
||
func listDownloadableAssets(c *gin.Context, siteID string) {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||
filter := bson.M{"site_id": siteID, "downloadable": true}
|
||
opts := options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}})
|
||
cursor, err := coll.Find(ctx, filter, opts)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
defer cursor.Close(ctx)
|
||
var list []models.SiteAsset
|
||
if err = cursor.All(ctx, &list); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"list": list, "total": len(list)})
|
||
}
|
||
|
||
// ListDownloadableAssets 仅返回可下载文件列表(供首页编辑选择,仅需 homepage:edit 权限)
|
||
func ListDownloadableAssets(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
listDownloadableAssets(c, siteID)
|
||
}
|
||
|
||
func listSubDirs(c *gin.Context, siteID, currentPath string) []string {
|
||
prefix := pathPrefix(siteID)
|
||
if currentPath != "" {
|
||
prefix = prefix + currentPath
|
||
if prefix[len(prefix)-1] != '/' {
|
||
prefix += "/"
|
||
}
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||
cursor, err := coll.Find(ctx, bson.M{"site_id": siteID, "file_path": bson.M{"$regex": "^" + regexp.QuoteMeta(prefix)}})
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
defer cursor.Close(ctx)
|
||
var docs []struct {
|
||
FilePath string `bson:"file_path"`
|
||
}
|
||
_ = cursor.All(ctx, &docs)
|
||
seen := make(map[string]bool)
|
||
for _, d := range docs {
|
||
rel := strings.TrimPrefix(d.FilePath, prefix)
|
||
if rel == "" || rel == d.FilePath {
|
||
continue
|
||
}
|
||
parts := strings.SplitN(rel, "/", 2)
|
||
if len(parts) > 0 && parts[0] != "" {
|
||
seen[parts[0]] = true
|
||
}
|
||
}
|
||
// 再扫描物理目录
|
||
baseDir := filepath.Join(getUploadDir(), filepath.FromSlash(prefix))
|
||
entries, _ := os.ReadDir(baseDir)
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
seen[e.Name()] = true
|
||
}
|
||
}
|
||
names := make([]string, 0, len(seen))
|
||
for n := range seen {
|
||
names = append(names, n)
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
func promotionMimeType(ext string) string {
|
||
switch strings.ToLower(ext) {
|
||
case ".mov":
|
||
return "video/quicktime"
|
||
case ".mp4":
|
||
return "video/mp4"
|
||
case ".webm":
|
||
return "video/webm"
|
||
case ".jpg", ".jpeg":
|
||
return "image/jpeg"
|
||
case ".png":
|
||
return "image/png"
|
||
case ".gif":
|
||
return "image/gif"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
// ServePromotionMedia 公开读取站点 uploads/sites/{site_id}/promotion/ 下文件(与后台上传到 promotion/… 目录对应)
|
||
func ServePromotionMedia(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
raw := strings.TrimPrefix(c.Param("filepath"), "/")
|
||
if siteID == "" || raw == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
rel := filepath.ToSlash(filepath.Clean(raw))
|
||
if rel == "." || strings.HasPrefix(rel, "../") || strings.Contains(rel, "/../") {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效路径"})
|
||
return
|
||
}
|
||
baseDir := filepath.Join(getUploadDir(), "sites", siteID, "promotion")
|
||
fullPath := filepath.Join(baseDir, filepath.FromSlash(rel))
|
||
relBack, err := filepath.Rel(baseDir, fullPath)
|
||
if err != nil || strings.HasPrefix(relBack, "..") {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"})
|
||
return
|
||
}
|
||
fi, err := os.Stat(fullPath)
|
||
if err != nil || fi.IsDir() {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||
return
|
||
}
|
||
ext := filepath.Ext(fullPath)
|
||
ct := promotionMimeType(ext)
|
||
if ct == "" {
|
||
ct = "application/octet-stream"
|
||
}
|
||
c.Header("Content-Type", ct)
|
||
c.Header("Cache-Control", "public, max-age=86400")
|
||
c.File(fullPath)
|
||
}
|
||
|
||
// UploadSiteAsset 上传功能模块/文件;form 可选:folder(当前目录相对路径)、downloadable(true/false)、preserve_filename(true 时保持原名并覆盖同路径旧文件,用于首页推广视频固定 URL)
|
||
func UploadSiteAsset(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
|
||
file, err := c.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要上传的文件"})
|
||
return
|
||
}
|
||
|
||
folder := strings.TrimSpace(c.PostForm("folder"))
|
||
downloadable := c.PostForm("downloadable") == "true" || c.PostForm("downloadable") == "1"
|
||
preserve := c.PostForm("preserve_filename") == "true" || c.PostForm("preserve_filename") == "1"
|
||
|
||
name := file.Filename
|
||
ext := filepath.Ext(name)
|
||
nameNoExt := strings.TrimSuffix(name, ext)
|
||
var saveName string
|
||
if preserve {
|
||
saveName = filepath.Base(name)
|
||
if saveName == "." || saveName == string(filepath.Separator) || saveName == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的文件名"})
|
||
return
|
||
}
|
||
} else {
|
||
if len(ext) == 0 {
|
||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405")
|
||
} else {
|
||
saveName = nameNoExt + "_" + time.Now().Format("20060102150405") + ext
|
||
}
|
||
}
|
||
|
||
folderClean := ""
|
||
if folder != "" {
|
||
folderClean = filepath.ToSlash(filepath.Clean(folder))
|
||
if strings.HasPrefix(folderClean, "../") || strings.Contains(folderClean, "/../") {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
||
return
|
||
}
|
||
}
|
||
|
||
var relPath string
|
||
if folderClean != "" {
|
||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, folderClean, saveName))
|
||
} else {
|
||
relPath = filepath.ToSlash(filepath.Join("sites", siteID, saveName))
|
||
}
|
||
destPath := filepath.Join(getUploadDir(), filepath.FromSlash(relPath))
|
||
|
||
if preserve {
|
||
ctxDel, cancelDel := context.WithTimeout(context.Background(), 8*time.Second)
|
||
defer cancelDel()
|
||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||
_, _ = coll.DeleteMany(ctxDel, bson.M{"site_id": siteID, "file_path": relPath})
|
||
_ = os.Remove(destPath)
|
||
}
|
||
|
||
baseDir := filepath.Dir(destPath)
|
||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||
return
|
||
}
|
||
|
||
if err := c.SaveUploadedFile(file, destPath); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
doc := models.SiteAsset{
|
||
SiteID: siteID,
|
||
Name: file.Filename,
|
||
FilePath: relPath,
|
||
Size: file.Size,
|
||
ContentType: file.Header.Get("Content-Type"),
|
||
Downloadable: downloadable,
|
||
CreatedAt: time.Now().Format(time.RFC3339),
|
||
}
|
||
res, err := config.GetDB(config.DBName).Collection("site_assets").InsertOne(ctx, bson.M{
|
||
"site_id": doc.SiteID,
|
||
"name": doc.Name,
|
||
"file_path": doc.FilePath,
|
||
"size": doc.Size,
|
||
"content_type": doc.ContentType,
|
||
"downloadable": doc.Downloadable,
|
||
"created_at": doc.CreatedAt,
|
||
})
|
||
if err != nil {
|
||
os.Remove(destPath)
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"id": res.InsertedID, "file_path": doc.FilePath, "message": "上传成功"})
|
||
// promotion 目录下 .mov 由服务端异步转 .mp4 并更新本条资源(需容器/主机安装 ffmpeg)
|
||
ScheduleTranscodeAfterUpload(siteID, relPath, destPath, res.InsertedID)
|
||
}
|
||
|
||
// DeleteSiteAsset 删除站点资源
|
||
func DeleteSiteAsset(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
idStr := c.Param("asset_id")
|
||
if siteID == "" || idStr == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
|
||
oid, err := bson.ObjectIDFromHex(idStr)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
|
||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||
var asset models.SiteAsset
|
||
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||
return
|
||
}
|
||
|
||
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
|
||
os.Remove(fullPath)
|
||
|
||
_, err = coll.DeleteOne(ctx, bson.M{"_id": oid})
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
||
}
|
||
|
||
// CreateSiteFolderInput 创建目录
|
||
type CreateSiteFolderInput struct {
|
||
Path string `json:"path" binding:"required"`
|
||
}
|
||
|
||
// CreateSiteFolder 在站点下创建多级目录
|
||
func CreateSiteFolder(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
if siteID == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请提供 site_id"})
|
||
return
|
||
}
|
||
var input CreateSiteFolderInput
|
||
if err := c.ShouldBindJSON(&input); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写目录路径"})
|
||
return
|
||
}
|
||
clean := filepath.Clean(input.Path)
|
||
if clean == "." || clean == ".." || strings.HasPrefix(clean, "..") {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的目录路径"})
|
||
return
|
||
}
|
||
baseDir := filepath.Join(getUploadDir(), "sites", siteID, clean)
|
||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建目录失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "创建成功", "path": filepath.ToSlash(clean)})
|
||
}
|
||
|
||
// DownloadSiteAsset 前台公开下载:仅当资源标记为可下载时返回文件(供首页等使用)
|
||
func DownloadSiteAsset(c *gin.Context) {
|
||
siteID := c.Param("site_id")
|
||
assetIDStr := c.Param("asset_id")
|
||
if siteID == "" || assetIDStr == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
oid, err := bson.ObjectIDFromHex(assetIDStr)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
coll := config.GetDB(config.DBName).Collection("site_assets")
|
||
var asset models.SiteAsset
|
||
err = coll.FindOne(ctx, bson.M{"_id": oid, "site_id": siteID}).Decode(&asset)
|
||
if err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "资源不存在"})
|
||
return
|
||
}
|
||
if !asset.Downloadable {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "该资源不可下载"})
|
||
return
|
||
}
|
||
fullPath := filepath.Join(getUploadDir(), filepath.FromSlash(asset.FilePath))
|
||
if _, err := os.Stat(fullPath); err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在"})
|
||
return
|
||
}
|
||
c.Header("Content-Disposition", "attachment; filename=\""+asset.Name+"\"")
|
||
if asset.ContentType != "" {
|
||
c.Header("Content-Type", asset.ContentType)
|
||
}
|
||
c.File(fullPath)
|
||
}
|