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 }