278 lines
7.4 KiB
Go
278 lines
7.4 KiB
Go
package controller
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"seeyon-filesystem/config"
|
|
"seeyon-filesystem/middleware"
|
|
"seeyon-filesystem/model"
|
|
"seeyon-filesystem/storage"
|
|
"seeyon-filesystem/utils"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// FileAccessController 文件访问控制器
|
|
// 统一处理文件下载/预览, 支持中转和预签名两种模式
|
|
type FileAccessController struct{}
|
|
|
|
func NewFileAccessController() *FileAccessController {
|
|
return &FileAccessController{}
|
|
}
|
|
|
|
// Access 统一文件访问入口
|
|
// GET /api/file/:id/access
|
|
// 根据存储策略自动选择: 中转(proxy) / 预签名(presigned) / CDN
|
|
func (c *FileAccessController) Access(ctx *gin.Context) {
|
|
ownerID := middleware.GetCurrentUserID(ctx)
|
|
fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
|
if err != nil {
|
|
utils.BadRequest(ctx, "无效的文件ID")
|
|
return
|
|
}
|
|
|
|
file, err := getFileByID(fileID)
|
|
if err != nil {
|
|
utils.NotFound(ctx, "文件不存在")
|
|
return
|
|
}
|
|
if file.OwnerID != ownerID {
|
|
utils.Forbidden(ctx, "无权访问")
|
|
return
|
|
}
|
|
|
|
c.serveFile(ctx, file)
|
|
}
|
|
|
|
// AccessByShare 通过分享访问文件(不检查归属)
|
|
// GET /api/s/:code/access
|
|
func (c *FileAccessController) AccessByShare(ctx *gin.Context) {
|
|
code := ctx.Param("code")
|
|
|
|
// 验证分享
|
|
share, err := getShareByCode(code)
|
|
if err != nil {
|
|
utils.NotFound(ctx, "分享不存在或已失效")
|
|
return
|
|
}
|
|
|
|
// 检查密码
|
|
if share.Password != "" {
|
|
password := ctx.Query("password")
|
|
if !verifySharePassword(share, password) {
|
|
utils.Unauthorized(ctx, "需要密码")
|
|
return
|
|
}
|
|
}
|
|
|
|
// 检查过期
|
|
if share.ExpireAt != nil && share.ExpireAt.Before(time.Now()) {
|
|
utils.NotFound(ctx, "分享已过期")
|
|
return
|
|
}
|
|
|
|
file, err := getFileByID(share.FileID)
|
|
if err != nil {
|
|
utils.NotFound(ctx, "文件不存在")
|
|
return
|
|
}
|
|
|
|
c.serveFile(ctx, file)
|
|
}
|
|
|
|
// serveFile 根据存储策略配置的访问模式提供文件
|
|
func (c *FileAccessController) serveFile(ctx *gin.Context, file *model.File) {
|
|
// 获取存储策略
|
|
policy := getStoragePolicy(file.StoragePolicyID)
|
|
|
|
// 获取存储引擎
|
|
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
|
if err != nil {
|
|
utils.ServerError(ctx, "存储引擎不可用")
|
|
return
|
|
}
|
|
|
|
accessMode := policy.Config.AccessMode
|
|
|
|
switch accessMode {
|
|
case "presigned":
|
|
// 预签名模式: 返回URL, 客户端直连MinIO
|
|
c.servePresigned(ctx, file, engine, policy)
|
|
case "cdn":
|
|
// CDN模式: 返回CDN链接
|
|
c.serveCDN(ctx, file, engine)
|
|
default:
|
|
// 中转模式(默认): 服务端拉取文件流转发给客户端
|
|
c.serveProxy(ctx, file, engine, policy)
|
|
}
|
|
}
|
|
|
|
// serveProxy 中转模式: 服务端从存储拉取文件, 转发给客户端
|
|
func (c *FileAccessController) serveProxy(ctx *gin.Context, file *model.File, engine storage.Engine, policy *model.StoragePolicy) {
|
|
reader, err := engine.Download(file.StorageKey)
|
|
if err != nil {
|
|
utils.ServerError(ctx, "读取文件失败: "+err.Error())
|
|
return
|
|
}
|
|
defer reader.Close()
|
|
|
|
// 设置响应头
|
|
ctx.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", file.Name))
|
|
ctx.Header("Content-Type", file.MimeType)
|
|
ctx.Header("Content-Length", strconv.FormatInt(file.Size, 10))
|
|
ctx.Header("Cache-Control", "private, max-age=3600")
|
|
|
|
// X-Sendfile模式: 返回路径让Nginx直接发送文件
|
|
if policy.Config.UseXSendfile && policy.Config.XSendfilePath != "" {
|
|
// Nginx会拦截这个头, 直接从本地路径发送文件
|
|
localPath := fmt.Sprintf("%s/%s", policy.Config.XSendfilePath, file.StorageKey)
|
|
ctx.Header("X-Accel-Redirect", localPath)
|
|
ctx.Status(200)
|
|
return
|
|
}
|
|
|
|
// 普通中转: 流式传输
|
|
ctx.Status(200)
|
|
io.Copy(ctx.Writer, reader)
|
|
}
|
|
|
|
// servePresigned 预签名模式: 返回MinIO预签名URL
|
|
func (c *FileAccessController) servePresigned(ctx *gin.Context, file *model.File, engine storage.Engine, policy *model.StoragePolicy) {
|
|
minioEngine, ok := engine.(*storage.MinIOEngine)
|
|
if !ok {
|
|
// 非MinIO引擎, 回退到中转模式
|
|
c.serveProxy(ctx, file, engine, policy)
|
|
return
|
|
}
|
|
|
|
presignedURL, err := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
|
|
if err != nil {
|
|
utils.ServerError(ctx, "生成预签名URL失败")
|
|
return
|
|
}
|
|
|
|
// 替换为Nginx公网地址
|
|
if policy.Config.NginxEndpoint != "" {
|
|
presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.Config.NginxEndpoint)
|
|
}
|
|
|
|
// 返回302重定向到预签名URL
|
|
ctx.Redirect(http.StatusFound, presignedURL)
|
|
}
|
|
|
|
// serveCDN CDN模式: 返回CDN链接
|
|
func (c *FileAccessController) serveCDN(ctx *gin.Context, file *model.File, engine storage.Engine) {
|
|
if localEngine, ok := engine.(*storage.LocalEngine); ok {
|
|
cdnURL := localEngine.GetURL(file.StorageKey)
|
|
ctx.Redirect(http.StatusFound, cdnURL)
|
|
return
|
|
}
|
|
// 回退到中转
|
|
c.serveProxy(ctx, file, engine, &model.StoragePolicy{})
|
|
}
|
|
|
|
// GetAccessURL 获取文件访问URL(不重定向, 返回JSON)
|
|
// GET /api/file/:id/access-url
|
|
func (c *FileAccessController) GetAccessURL(ctx *gin.Context) {
|
|
ownerID := middleware.GetCurrentUserID(ctx)
|
|
fileID, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
|
if err != nil {
|
|
utils.BadRequest(ctx, "无效的文件ID")
|
|
return
|
|
}
|
|
|
|
file, err := getFileByID(fileID)
|
|
if err != nil {
|
|
utils.NotFound(ctx, "文件不存在")
|
|
return
|
|
}
|
|
if file.OwnerID != ownerID {
|
|
utils.Forbidden(ctx, "无权访问")
|
|
return
|
|
}
|
|
|
|
policy := getStoragePolicy(file.StoragePolicyID)
|
|
engine, err := storage.GetEngineByPolicyID(file.StoragePolicyID)
|
|
if err != nil {
|
|
utils.ServerError(ctx, "存储引擎不可用")
|
|
return
|
|
}
|
|
|
|
result := gin.H{
|
|
"mode": policy.Config.AccessMode,
|
|
"file_name": file.Name,
|
|
"file_size": file.Size,
|
|
"content_type": file.MimeType,
|
|
}
|
|
|
|
switch policy.Config.AccessMode {
|
|
case "presigned":
|
|
if minioEngine, ok := engine.(*storage.MinIOEngine); ok {
|
|
presignedURL, _ := minioEngine.GetPresignedURL(file.StorageKey, 24*time.Hour)
|
|
if policy.Config.NginxEndpoint != "" {
|
|
presignedURL = replaceMinIOEndpoint(presignedURL, minioEngine.Endpoint, policy.Config.NginxEndpoint)
|
|
}
|
|
result["url"] = presignedURL
|
|
}
|
|
case "cdn":
|
|
if localEngine, ok := engine.(*storage.LocalEngine); ok {
|
|
result["url"] = localEngine.GetURL(file.StorageKey)
|
|
}
|
|
default:
|
|
result["url"] = fmt.Sprintf("/api/file/%d/access", fileID)
|
|
result["mode"] = "proxy"
|
|
}
|
|
|
|
utils.Success(ctx, result)
|
|
}
|
|
|
|
// ========== 辅助函数 ==========
|
|
|
|
func getFileByID(id uint64) (*model.File, error) {
|
|
var file model.File
|
|
if err := config.DB.First(&file, id).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &file, nil
|
|
}
|
|
|
|
func getShareByCode(code string) (*model.Share, error) {
|
|
var share model.Share
|
|
if err := config.DB.Where("share_code = ? AND status = 1", code).First(&share).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return &share, nil
|
|
}
|
|
|
|
func verifySharePassword(share *model.Share, password string) bool {
|
|
return utils.CheckPassword(password, share.Password)
|
|
}
|
|
|
|
func getStoragePolicy(policyID uint64) *model.StoragePolicy {
|
|
var policy model.StoragePolicy
|
|
if policyID > 0 {
|
|
config.DB.First(&policy, policyID)
|
|
}
|
|
if policy.Config.AccessMode == "" {
|
|
policy.Config.AccessMode = "proxy"
|
|
}
|
|
return &policy
|
|
}
|
|
|
|
// replaceMinIOEndpoint 替换预签名URL中的MinIO地址为Nginx公网地址
|
|
func replaceMinIOEndpoint(url, minioEndpoint, nginxEndpoint string) string {
|
|
old := "http://" + minioEndpoint
|
|
if url[:len(old)] == old {
|
|
return nginxEndpoint + url[len(old):]
|
|
}
|
|
old = "https://" + minioEndpoint
|
|
if len(url) > len(old) && url[:len(old)] == old {
|
|
return nginxEndpoint + url[len(old):]
|
|
}
|
|
return url
|
|
}
|