Files
jiang13-bbs/backend/service/operations_temporary.go

214 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/sha256"
"database/sql"
"database/sql/driver"
"encoding/binary"
"errors"
"github.com/freefire/jiang13-bbs/model"
"os"
"path/filepath"
"strings"
"time"
)
func temporaryLockKey(path string) int64 {
key := filepath.ToSlash(filepath.Clean(path))
for _, root := range []string{"/uploads/", "/private/"} {
if i := strings.LastIndex(key, root); i >= 0 {
key = key[i+1:]
break
}
}
s := sha256.Sum256([]byte("temporary:" + key))
return int64(binary.BigEndian.Uint64(s[:8]))
}
func (o *Operations) lockTemporary(path string, try bool) (*sql.Conn, bool, error) {
db, e := o.db.DB()
if e != nil {
return nil, false, e
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
c, e := db.Conn(ctx)
if e != nil {
return nil, false, e
}
if try {
var ok bool
e = c.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", temporaryLockKey(path)).Scan(&ok)
if e != nil || !ok {
c.Close()
return nil, false, e
}
} else {
if _, e = c.ExecContext(ctx, "SELECT pg_advisory_lock($1)", temporaryLockKey(path)); e != nil {
c.Close()
return nil, false, e
}
}
return c, true, nil
}
func unlockTemporary(c *sql.Conn, path string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, e := c.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", temporaryLockKey(path))
if e != nil {
_ = c.Raw(func(any) error { return driver.ErrBadConn })
}
_ = c.Close()
}
// The database session lock lasts for the complete upload, including a stalled writer.
// A crashed process releases it automatically; cleaners can never unlink an active upload.
func (o *Operations) BeginTemporary(path string) (func(), error) {
rel, e := filepath.Rel(o.cfg.DataDir, path)
if e != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
return nil, errors.New("临时文件目录无效")
}
c, _, e := o.lockTemporary(path, false)
if e != nil {
return nil, e
}
row := model.TemporaryUpload{ID: counterKey(filepath.ToSlash(rel)), RelativePath: filepath.ToSlash(rel)}
if e = o.db.Create(&row).Error; e != nil {
unlockTemporary(c, path)
return nil, e
}
return func() {
if _, e := os.Lstat(path); os.IsNotExist(e) {
o.db.Delete(&model.TemporaryUpload{}, "id = ?", row.ID)
}
unlockTemporary(c, path)
}, nil
}
func (o *Operations) temporaryPath(row model.TemporaryUpload) (string, error) {
rel := filepath.FromSlash(row.RelativePath)
if filepath.IsAbs(rel) || strings.HasPrefix(filepath.Clean(rel), "..") || !strings.HasSuffix(rel, ".partial") {
return "", errors.New("临时路径不在允许范围")
}
path := filepath.Join(o.cfg.DataDir, rel)
parent := filepath.ToSlash(filepath.Dir(rel))
if parent != "uploads/images" && parent != "uploads/backgrounds" && parent != "private/files" {
return "", errors.New("目录不在允许范围")
}
root, e := filepath.EvalSymlinks(o.cfg.DataDir)
if e != nil {
return "", e
}
actual, e := filepath.EvalSymlinks(path)
if e != nil {
return "", e
}
within, e := filepath.Rel(root, actual)
if e != nil || strings.HasPrefix(within, "..") || filepath.IsAbs(within) {
return "", errors.New("拒绝目录外文件")
}
info, e := os.Lstat(path)
if e != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return "", errors.New("拒绝非普通文件")
}
return path, nil
}
type TemporaryCandidate struct {
ID string `json:"id"`
Size int64 `json:"size"`
Modified time.Time `json:"modified"`
}
func (o *Operations) temporaryCandidates() ([]TemporaryCandidate, error) {
m, e := o.Maintenance()
if e != nil {
return nil, e
}
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
var rows []model.TemporaryUpload
if e = o.db.Where("created_at < ?", cutoff).Order("created_at").Limit(100).Find(&rows).Error; e != nil {
return nil, e
}
out := []TemporaryCandidate{}
for _, row := range rows {
path, e := o.temporaryPath(row)
if e != nil {
continue
}
c, ok, e := o.lockTemporary(path, true)
if e != nil {
return nil, e
}
if !ok {
continue
}
info, e := os.Stat(path)
if e == nil && info.ModTime().Before(cutoff) {
out = append(out, TemporaryCandidate{row.ID, info.Size(), info.ModTime()})
}
unlockTemporary(c, path)
}
return out, nil
}
func (o *Operations) CleanTemporary(ids []string) (map[string]any, error) {
if len(ids) == 0 || len(ids) > 100 {
return nil, errors.New("每次请选择扫描出的 1–100 个临时文件")
}
m, e := o.Maintenance()
if e != nil {
return nil, e
}
cutoff := time.Now().Add(-time.Duration(m.TempDays) * 24 * time.Hour)
removed, skipped, failed := 0, 0, 0
for _, id := range ids {
var row model.TemporaryUpload
if o.db.First(&row, "id = ? AND created_at < ?", id, cutoff).Error != nil {
skipped++
continue
}
path, e := o.temporaryPath(row)
if e != nil {
skipped++
continue
}
c, ok, e := o.lockTemporary(path, true)
if e != nil {
failed++
continue
}
if !ok {
skipped++
continue
}
info, e := os.Stat(path)
if e != nil || !info.ModTime().Before(cutoff) {
unlockTemporary(c, path)
skipped++
continue
}
// Raw Markdown references are checked again while holding the upload lock.
referenced := false
for _, table := range []string{"posts", "comments"} {
var n int64
e = o.db.Table(table).Where("content LIKE ?", "%"+filepath.Base(path)+"%").Count(&n).Error
if e != nil || n > 0 {
referenced = true
break
}
}
if referenced {
unlockTemporary(c, path)
skipped++
continue
}
if e = os.Remove(path); e != nil {
failed++
} else {
o.db.Delete(&model.TemporaryUpload{}, "id = ?", id)
removed++
}
unlockTemporary(c, path)
}
return map[string]any{"message": "临时文件清理完成", "removed": removed, "skipped": skipped, "failed": failed}, nil
}