Initial commit

This commit is contained in:
2026-01-28 20:44:34 +08:00
commit 500e8b74a7
236 changed files with 29886 additions and 0 deletions

View File

@@ -0,0 +1,61 @@
package public
import (
"FileRelay/internal/config"
"FileRelay/internal/model"
"net/http"
"github.com/gin-gonic/gin"
)
type ConfigHandler struct{}
func NewConfigHandler() *ConfigHandler {
return &ConfigHandler{}
}
// PublicConfig 公开配置结构
type PublicConfig struct {
Site config.SiteConfig `json:"site"`
Security PublicSecurityConfig `json:"security"`
Upload config.UploadConfig `json:"upload"`
APIToken PublicAPITokenConfig `json:"api_token"`
Storage PublicStorageConfig `json:"storage"`
}
type PublicSecurityConfig struct {
PickupCodeLength int `json:"pickup_code_length"`
}
type PublicAPITokenConfig struct {
Enabled bool `json:"enabled"`
}
type PublicStorageConfig struct {
Type string `json:"type"`
}
// GetPublicConfig 获取非敏感配置
// @Summary 获取公共配置
// @Description 获取前端展示所需的非敏感配置数据
// @Tags Public
// @Produce json
// @Success 200 {object} model.Response{data=PublicConfig}
// @Router /api/config [get]
func (h *ConfigHandler) GetPublicConfig(c *gin.Context) {
pub := PublicConfig{
Site: config.GlobalConfig.Site,
Security: PublicSecurityConfig{
PickupCodeLength: config.GlobalConfig.Security.PickupCodeLength,
},
Upload: config.GlobalConfig.Upload,
APIToken: PublicAPITokenConfig{
Enabled: config.GlobalConfig.APIToken.Enabled,
},
Storage: PublicStorageConfig{
Type: config.GlobalConfig.Storage.Type,
},
}
c.JSON(http.StatusOK, model.SuccessResponse(pub))
}

View File

@@ -0,0 +1,314 @@
package public
import (
"FileRelay/internal/api/middleware"
"FileRelay/internal/bootstrap"
"FileRelay/internal/config"
"FileRelay/internal/model"
"FileRelay/internal/service"
"FileRelay/internal/storage"
"archive/zip"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type PickupResponse struct {
Remark string `json:"remark"`
ExpireAt *time.Time `json:"expire_at"`
ExpireType string `json:"expire_type"`
DownloadCount int `json:"download_count"`
MaxDownloads int `json:"max_downloads"`
Type string `json:"type"`
Content string `json:"content,omitempty"`
Files []model.FileItem `json:"files,omitempty"`
}
type DownloadCountResponse struct {
DownloadCount int `json:"download_count"`
MaxDownloads int `json:"max_downloads"`
}
// DownloadBatch 批量下载文件 (ZIP)
// @Summary 批量下载文件
// @Description 根据取件码将批次内的所有文件打包为 ZIP 格式一次性下载。可选提供带 pickup scope 的 API Token。
// @Tags Public
// @Security APITokenAuth
// @Param pickup_code path string true "取件码"
// @Produce application/zip
// @Success 200 {file} file
// @Failure 404 {object} model.Response
// @Router /api/batches/{pickup_code}/download [get]
func (h *PickupHandler) DownloadBatch(c *gin.Context) {
code := c.Param("pickup_code")
batch, err := h.batchService.GetBatchByPickupCode(code)
if err != nil {
c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found or expired"))
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"batch_%s.zip\"", code))
c.Header("Content-Type", "application/zip")
zw := zip.NewWriter(c.Writer)
defer zw.Close()
for _, item := range batch.FileItems {
reader, err := storage.GlobalStorage.Open(c.Request.Context(), item.StoragePath)
if err != nil {
continue // Skip failed files
}
f, err := zw.Create(item.OriginalName)
if err != nil {
reader.Close()
continue
}
_, _ = io.Copy(f, reader)
reader.Close()
}
// 增加下载次数
if err := h.batchService.IncrementDownloadCount(batch.ID); err != nil {
slog.Error("Failed to increment download count", "batch_id", batch.ID, "error", err)
}
}
type PickupHandler struct {
batchService *service.BatchService
}
func NewPickupHandler() *PickupHandler {
return &PickupHandler{
batchService: service.NewBatchService(),
}
}
// Pickup 获取批次信息
// @Summary 获取批次信息
// @Description 根据取件码获取文件批次详细信息和文件列表。可选提供带 pickup scope 的 API Token。
// @Tags Public
// @Security APITokenAuth
// @Produce json
// @Param pickup_code path string true "取件码"
// @Success 200 {object} model.Response{data=PickupResponse}
// @Failure 404 {object} model.Response
// @Router /api/batches/{pickup_code} [get]
func (h *PickupHandler) Pickup(c *gin.Context) {
code := c.Param("pickup_code")
if code == "" {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "pickup code required"))
return
}
batch, err := h.batchService.GetBatchByPickupCode(code)
if err != nil {
middleware.RecordPickupFailure(c.ClientIP())
c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found or expired"))
return
}
if batch.Type == "text" {
if err := h.batchService.IncrementDownloadCount(batch.ID); err != nil {
slog.Error("Failed to increment download count for batch", "batch_id", batch.ID, "error", err)
} else {
batch.DownloadCount++
}
}
baseURL := getBaseURL(c)
for i := range batch.FileItems {
batch.FileItems[i].DownloadURL = fmt.Sprintf("%s/api/files/%s/%s", baseURL, batch.FileItems[i].ID, batch.FileItems[i].OriginalName)
}
c.JSON(http.StatusOK, model.SuccessResponse(PickupResponse{
Remark: batch.Remark,
ExpireAt: batch.ExpireAt,
ExpireType: batch.ExpireType,
DownloadCount: batch.DownloadCount,
MaxDownloads: batch.MaxDownloads,
Type: batch.Type,
Content: batch.Content,
Files: batch.FileItems,
}))
}
// GetDownloadCount 查询下载次数
// @Summary 查询下载次数
// @Description 根据取件码查询当前下载次数和最大允许下载次数。支持已过期的批次。
// @Tags Public
// @Produce json
// @Param pickup_code path string true "取件码"
// @Success 200 {object} model.Response{data=DownloadCountResponse}
// @Failure 400 {object} model.Response
// @Failure 404 {object} model.Response
// @Router /api/batches/{pickup_code}/count [get]
func (h *PickupHandler) GetDownloadCount(c *gin.Context) {
code := c.Param("pickup_code")
if code == "" {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "pickup code required"))
return
}
count, max, err := h.batchService.GetDownloadCountByPickupCode(code)
if err != nil {
middleware.RecordPickupFailure(c.ClientIP())
c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found"))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(DownloadCountResponse{
DownloadCount: count,
MaxDownloads: max,
}))
}
func getBaseURL(c *gin.Context) string {
// 优先使用配置中的 BaseURL
if config.GlobalConfig.Site.BaseURL != "" {
return strings.TrimSuffix(config.GlobalConfig.Site.BaseURL, "/")
}
// 自动检测逻辑
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
} else {
// 检查常用的代理协议头 (优先)
// 增加对用户提供的 :scheme (可能被某些代理转为普通 header) 的支持
// 增加对 X-Forwarded-Proto 可能存在的逗号分隔列表的处理
checkHeaders := []struct {
name string
values []string
}{
{"X-Forwarded-Proto", []string{"https"}},
{"X-Forwarded-Protocol", []string{"https"}},
{"X-Url-Scheme", []string{"https"}},
{"Front-End-Https", []string{"on", "https"}},
{"X-Forwarded-Ssl", []string{"on", "https"}},
{":scheme", []string{"https"}},
{"X-Scheme", []string{"https"}},
}
found := false
for _, h := range checkHeaders {
val := c.GetHeader(h.name)
if val == "" {
continue
}
// 处理可能的逗号分隔列表 (如 X-Forwarded-Proto: https, http)
firstVal := strings.TrimSpace(strings.ToLower(strings.Split(val, ",")[0]))
for _, target := range h.values {
if firstVal == target {
scheme = "https"
found = true
break
}
}
if found {
break
}
}
// 检查 Forwarded 头部 (RFC 7239)
if !found {
if forwarded := c.GetHeader("Forwarded"); forwarded != "" {
if strings.Contains(strings.ToLower(forwarded), "proto=https") {
scheme = "https"
found = true
}
}
}
// 启发式判断:如果上述头部都没有,但 Referer 是 https则认为也是 https
// 这在同域 API 请求时非常可靠
if !found {
if referer := c.GetHeader("Referer"); strings.HasPrefix(strings.ToLower(referer), "https://") {
scheme = "https"
}
}
}
host := c.Request.Host
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
// 处理可能的逗号分隔列表
host = strings.TrimSpace(strings.Split(forwardedHost, ",")[0])
}
return fmt.Sprintf("%s://%s", scheme, host)
}
// DownloadFile 下载单个文件
// @Summary 下载单个文件
// @Description 根据文件 ID 下载单个文件。支持直观的文件名结尾以方便下载工具识别。可选提供带 pickup scope 的 API Token。
// @Tags Public
// @Security APITokenAuth
// @Param file_id path string true "文件 ID (UUID)"
// @Param filename path string false "文件名"
// @Produce application/octet-stream
// @Success 200 {file} file
// @Failure 404 {object} model.Response
// @Failure 410 {object} model.Response
// @Router /api/files/{file_id}/{filename} [get]
// @Router /api/files/{file_id}/download [get]
func (h *PickupHandler) DownloadFile(c *gin.Context) {
fileID := c.Param("file_id")
var item model.FileItem
if err := bootstrap.DB.First(&item, "id = ?", fileID).Error; err != nil {
c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "file not found"))
return
}
var batch model.FileBatch
if err := bootstrap.DB.First(&batch, "id = ?", item.BatchID).Error; err != nil {
c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "batch not found"))
return
}
if h.batchService.IsExpired(&batch) {
h.batchService.MarkAsExpired(&batch)
// 按照需求,如果不存在(已在上面处理)或达到上限,返回 404
if batch.ExpireType == "download" && batch.DownloadCount >= batch.MaxDownloads {
c.JSON(http.StatusNotFound, model.ErrorResponse(model.CodeNotFound, "file not found or download limit reached"))
} else {
c.JSON(http.StatusGone, model.ErrorResponse(model.CodeGone, "batch expired"))
}
return
}
// 打开文件
reader, err := storage.GlobalStorage.Open(c.Request.Context(), item.StoragePath)
if err != nil {
c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, "failed to open file"))
return
}
defer reader.Close()
// 增加下载次数
if err := h.batchService.IncrementDownloadCount(batch.ID); err != nil {
// 记录错误但不中断下载过程
slog.Error("Failed to increment download count for batch", "batch_id", batch.ID, "error", err)
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", item.OriginalName))
c.Header("Content-Type", item.MimeType)
c.Header("Content-Length", strconv.FormatInt(item.Size, 10))
// 如果是 HEAD 请求,只返回 Header
if c.Request.Method == http.MethodHead {
return
}
if _, err := io.Copy(c.Writer, reader); err != nil {
slog.Error("Error during file download", "file_id", item.ID, "error", err)
}
}

View File

@@ -0,0 +1,175 @@
package public
import (
"FileRelay/internal/config"
"FileRelay/internal/model"
"FileRelay/internal/service"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type UploadHandler struct {
uploadService *service.UploadService
}
func NewUploadHandler() *UploadHandler {
return &UploadHandler{
uploadService: service.NewUploadService(),
}
}
type UploadResponse struct {
PickupCode string `json:"pickup_code"`
ExpireAt *time.Time `json:"expire_at"`
BatchID string `json:"batch_id"`
}
// Upload 上传文件并生成取件码
// @Summary 上传文件
// @Description 上传一个或多个文件并创建一个提取批次。如果配置了 require_token则必须提供带 upload scope 的 API Token。
// @Tags Public
// @Accept multipart/form-data
// @Produce json
// @Security APITokenAuth
// @Param files formData file true "文件列表"
// @Param remark formData string false "备注"
// @Param expire_type formData string false "过期类型 (time/download/permanent)"
// @Param expire_days formData int false "过期天数 (针对 time 类型)"
// @Param max_downloads formData int false "最大下载次数 (针对 download 类型)"
// @Success 200 {object} model.Response{data=UploadResponse}
// @Failure 400 {object} model.Response
// @Failure 500 {object} model.Response
// @Router /api/batches [post]
func (h *UploadHandler) Upload(c *gin.Context) {
form, err := c.MultipartForm()
if err != nil {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "invalid form"))
return
}
files := form.File["files"]
if len(files) == 0 {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "no files uploaded"))
return
}
if len(files) > config.GlobalConfig.Upload.MaxBatchFiles {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, "too many files"))
return
}
// 校验单个文件大小
maxSize := config.GlobalConfig.Upload.MaxFileSizeMB * 1024 * 1024
for _, file := range files {
if file.Size > maxSize {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, fmt.Sprintf("文件 %s 超过最大限制 (%dMB)", file.Filename, config.GlobalConfig.Upload.MaxFileSizeMB)))
return
}
}
remark := c.PostForm("remark")
expireType := c.PostForm("expire_type") // time / download / permanent
if expireType == "" {
expireType = "time"
}
var expireValue interface{}
switch expireType {
case "time":
days, _ := strconv.Atoi(c.PostForm("expire_days"))
if days <= 0 {
days = config.GlobalConfig.Upload.MaxRetentionDays
}
expireValue = days
case "download":
max, _ := strconv.Atoi(c.PostForm("max_downloads"))
if max <= 0 {
max = 1
}
expireValue = max
}
batch, err := h.uploadService.CreateBatch(c.Request.Context(), files, remark, expireType, expireValue)
if err != nil {
slog.Error("Upload handler failed to create batch", "error", err)
c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(UploadResponse{
PickupCode: batch.PickupCode,
ExpireAt: batch.ExpireAt,
BatchID: batch.ID,
}))
}
type UploadTextRequest struct {
Content string `json:"content" binding:"required" example:"这是一段长文本内容..."`
Remark string `json:"remark" example:"文本备注"`
ExpireType string `json:"expire_type" example:"time"`
ExpireDays int `json:"expire_days" example:"7"`
MaxDownloads int `json:"max_downloads" example:"5"`
}
// UploadText 发送长文本并生成取件码
// @Summary 发送长文本
// @Description 中转一段长文本内容并创建一个提取批次。如果配置了 require_token则必须提供带 upload scope 的 API Token。
// @Tags Public
// @Accept json
// @Produce json
// @Security APITokenAuth
// @Param request body UploadTextRequest true "文本内容及配置"
// @Success 200 {object} model.Response{data=UploadResponse}
// @Failure 400 {object} model.Response
// @Failure 500 {object} model.Response
// @Router /api/batches/text [post]
func (h *UploadHandler) UploadText(c *gin.Context) {
var req UploadTextRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, err.Error()))
return
}
// 校验文本长度
maxSize := config.GlobalConfig.Upload.MaxFileSizeMB * 1024 * 1024
if int64(len(req.Content)) > maxSize {
c.JSON(http.StatusBadRequest, model.ErrorResponse(model.CodeBadRequest, fmt.Sprintf("文本内容超过最大限制 (%dMB)", config.GlobalConfig.Upload.MaxFileSizeMB)))
return
}
if req.ExpireType == "" {
req.ExpireType = "time"
}
var expireValue interface{}
switch req.ExpireType {
case "time":
if req.ExpireDays <= 0 {
req.ExpireDays = config.GlobalConfig.Upload.MaxRetentionDays
}
expireValue = req.ExpireDays
case "download":
if req.MaxDownloads <= 0 {
req.MaxDownloads = 1
}
expireValue = req.MaxDownloads
}
batch, err := h.uploadService.CreateTextBatch(c.Request.Context(), req.Content, req.Remark, req.ExpireType, expireValue)
if err != nil {
slog.Error("Upload handler failed to create text batch", "error", err)
c.JSON(http.StatusInternalServerError, model.ErrorResponse(model.CodeInternalError, err.Error()))
return
}
c.JSON(http.StatusOK, model.SuccessResponse(UploadResponse{
PickupCode: batch.PickupCode,
ExpireAt: batch.ExpireAt,
BatchID: batch.ID,
}))
}