214 lines
5.8 KiB
Go
214 lines
5.8 KiB
Go
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
|
||
}
|