完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View 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
}