feat: secure pickup flow and add deployment
Release / deploy (push) Successful in 4m1s

This commit is contained in:
loveuer
2026-09-10 02:44:35 -07:00
parent fa4357a3f2
commit 8ca8341d7c
8 changed files with 276 additions and 34 deletions
+99 -12
View File
@@ -30,6 +30,12 @@ const sessionCookie = "ufile_session"
var usernamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{3,32}$`)
var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`)
var errFileUnavailable = errors.New("文件不存在或已失效")
var errPickupCodeRequired = errors.New("请输入取件码")
var errPickupCodeWrong = errors.New("取件码错误")
var errFileCheckFailed = errors.New("校验文件失败")
const publicFileOrCodeError = "文件不存在或取件码错误"
type App struct {
db *gorm.DB
@@ -37,6 +43,7 @@ type App struct {
web fs.FS
uploadMu sync.Mutex
downloadMu sync.Mutex
pickupMu sync.Mutex
now func() time.Time
}
@@ -88,6 +95,7 @@ func (a *App) router() *gin.Engine {
r.POST("/api/auth/logout", a.auth(false), a.logout)
r.GET("/api/auth/me", a.auth(false), a.me)
r.PUT("/api/auth/password", a.auth(false), a.changePassword)
r.GET("/api/pickup/:hash", a.verifyPickup)
r.POST("/api/files", a.auth(true), a.uploadFile)
r.GET("/api/users", a.auth(true), a.manageUsers, a.listUsers)
r.POST("/api/users", a.auth(true), a.manageUsers, a.createUser)
@@ -407,18 +415,9 @@ func (a *App) uploadFile(c *gin.Context) {
}
func (a *App) downloadFile(c *gin.Context) {
var file SharedFile
if err := a.db.Where("hash = ? AND ready = ?", c.Param("hash"), true).First(&file).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) {
a.removeFile(file)
c.JSON(http.StatusNotFound, gin.H{"error": "文件不存在或已失效"})
return
}
if file.PickupCodeHash != "" && bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(c.Query("code"))) != nil {
c.JSON(http.StatusForbidden, gin.H{"error": "取件码错误"})
file, err := a.resolvePublicFile(c.Param("hash"), c.Query("code"))
if err != nil {
writePublicFileError(c, err)
return
}
handle, err := os.Open(filepath.Join(a.filesDir, file.Hash))
@@ -459,6 +458,94 @@ func (a *App) downloadFile(c *gin.Context) {
_, _ = io.Copy(c.Writer, handle)
}
func (a *App) verifyPickup(c *gin.Context) {
if _, err := a.resolvePublicFile(c.Param("hash"), c.Query("code")); err != nil {
writePublicFileError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"valid": true})
}
func (a *App) resolvePublicFile(hash, pickupCode string) (SharedFile, error) {
var file SharedFile
if err := a.db.Where("hash = ? AND ready = ?", hash, true).First(&file).Error; err != nil {
return SharedFile{}, errFileUnavailable
}
if file.ExpiresAt != nil && !file.ExpiresAt.After(a.now()) {
a.removeFile(file)
return SharedFile{}, errFileUnavailable
}
if file.MaxDownloads != nil && file.DownloadCount >= *file.MaxDownloads {
a.removeFile(file)
return SharedFile{}, errFileUnavailable
}
if _, err := os.Stat(filepath.Join(a.filesDir, file.Hash)); err != nil {
a.db.Delete(&file)
return SharedFile{}, errFileUnavailable
}
if file.PickupCodeHash == "" {
return file, nil
}
if pickupCode == "" {
return SharedFile{}, a.recordPickupFailure(file, errPickupCodeRequired)
}
if bcrypt.CompareHashAndPassword([]byte(file.PickupCodeHash), []byte(pickupCode)) != nil {
return SharedFile{}, a.recordPickupFailure(file, errPickupCodeWrong)
}
if err := a.clearPickupFailures(file); err != nil {
return SharedFile{}, err
}
return file, nil
}
func (a *App) recordPickupFailure(file SharedFile, failure error) error {
a.pickupMu.Lock()
defer a.pickupMu.Unlock()
result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", gorm.Expr("failed_code_attempts + 1"))
if result.Error != nil {
return errFileCheckFailed
}
if result.RowsAffected == 0 {
return errFileUnavailable
}
var latest SharedFile
if err := a.db.Select("id", "hash", "failed_code_attempts").First(&latest, file.ID).Error; err != nil {
return errFileUnavailable
}
if latest.FailedCodeAttempts < 5 {
return failure
}
a.removeFile(file)
return errFileUnavailable
}
func (a *App) clearPickupFailures(file SharedFile) error {
if file.FailedCodeAttempts == 0 {
return nil
}
a.pickupMu.Lock()
defer a.pickupMu.Unlock()
result := a.db.Model(&SharedFile{}).Where("id = ?", file.ID).UpdateColumn("failed_code_attempts", 0)
if result.Error != nil {
return errFileCheckFailed
}
if result.RowsAffected == 0 {
return errFileUnavailable
}
return nil
}
func writePublicFileError(c *gin.Context, err error) {
switch {
case errors.Is(err, errFileUnavailable), errors.Is(err, errPickupCodeWrong):
c.JSON(http.StatusNotFound, gin.H{"error": publicFileOrCodeError})
case errors.Is(err, errPickupCodeRequired):
c.JSON(http.StatusForbidden, gin.H{"error": errPickupCodeRequired.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": errFileCheckFailed.Error()})
}
}
func (a *App) consumeDownload(file SharedFile) (allowed, exhausted bool, err error) {
a.downloadMu.Lock()
defer a.downloadMu.Unlock()