完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
213
backend/service/operations_temporary.go
Normal file
213
backend/service/operations_temporary.go
Normal file
@@ -0,0 +1,213 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user