This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user