同步包路径与路由,去掉 SPA 构建步骤,对齐 Gitea 式 LICENSE,并更新规格/规则与占位 SSR。 Co-authored-by: Cursor <cursoragent@cursor.com>
174 lines
4.0 KiB
Go
174 lines
4.0 KiB
Go
package services
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"golang.org/x/image/draw"
|
||
|
||
// 注册解码器
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
|
||
_ "golang.org/x/image/webp"
|
||
)
|
||
|
||
const (
|
||
// PostThumbMaxSide 正文预览图最长边(像素)
|
||
PostThumbMaxSide = 1280
|
||
)
|
||
|
||
var thumbLocks sync.Map // 同一原图并发生成时串行化
|
||
|
||
// ThumbURLFromUpload 将 /uploads/posts/xxx.webp 转为 /media/thumb/posts/xxx.webp
|
||
func ThumbURLFromUpload(uploadURL string) string {
|
||
u := strings.TrimSpace(uploadURL)
|
||
if u == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(u, "/media/thumb/") {
|
||
return u
|
||
}
|
||
if strings.HasPrefix(u, "/uploads/") {
|
||
return "/media/thumb/" + strings.TrimPrefix(u, "/uploads/")
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// WarmPostImageThumb 上传后预热缩略图(失败忽略,首次访问仍会生成)
|
||
func WarmPostImageThumb(uploadsRoot, relativePath string) {
|
||
_, _ = EnsureUploadThumb(uploadsRoot, relativePath)
|
||
}
|
||
|
||
// EnsureUploadThumb 确保缩略图存在,返回磁盘路径
|
||
// relativePath 形如 posts/1_123.webp(相对 uploads 根目录)
|
||
func EnsureUploadThumb(uploadsRoot, relativePath string) (string, error) {
|
||
rel, err := sanitizeUploadRel(relativePath)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
// 仅处理帖子正文图
|
||
if !strings.HasPrefix(rel, "posts/") {
|
||
return "", errors.New("仅支持帖子图片缩略图")
|
||
}
|
||
|
||
origPath := filepath.Join(uploadsRoot, filepath.FromSlash(rel))
|
||
if st, err := os.Stat(origPath); err != nil || st.IsDir() {
|
||
return "", errors.New("原图不存在")
|
||
}
|
||
|
||
thumbPath := filepath.Join(uploadsRoot, ".thumbs", filepath.FromSlash(rel)+".webp")
|
||
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
|
||
return thumbPath, nil
|
||
}
|
||
|
||
lockKey := rel
|
||
muIface, _ := thumbLocks.LoadOrStore(lockKey, &sync.Mutex{})
|
||
mu := muIface.(*sync.Mutex)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
// 双检
|
||
if fresh, err := thumbFresherThan(thumbPath, origPath); err == nil && fresh {
|
||
return thumbPath, nil
|
||
}
|
||
|
||
if err := generateWebPThumb(origPath, thumbPath, PostThumbMaxSide); err != nil {
|
||
return "", err
|
||
}
|
||
return thumbPath, nil
|
||
}
|
||
|
||
func thumbFresherThan(thumbPath, origPath string) (bool, error) {
|
||
ts, err := os.Stat(thumbPath)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
os_, err := os.Stat(origPath)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
return !ts.ModTime().Before(os_.ModTime()), nil
|
||
}
|
||
|
||
func sanitizeUploadRel(relativePath string) (string, error) {
|
||
rel := strings.TrimSpace(relativePath)
|
||
rel = strings.TrimPrefix(rel, "/")
|
||
rel = strings.ReplaceAll(rel, "\\", "/")
|
||
if rel == "" || strings.Contains(rel, "..") {
|
||
return "", errors.New("非法路径")
|
||
}
|
||
cleaned := filepath.Clean(filepath.FromSlash(rel))
|
||
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
||
return "", errors.New("非法路径")
|
||
}
|
||
return filepath.ToSlash(cleaned), nil
|
||
}
|
||
|
||
func generateWebPThumb(srcPath, dstPath string, maxSide int) error {
|
||
f, err := os.Open(srcPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
|
||
img, _, err := image.Decode(f)
|
||
if err != nil {
|
||
return fmt.Errorf("解码图片失败: %w", err)
|
||
}
|
||
|
||
out := resizeToMax(img, maxSide)
|
||
data, err := encodeWebPBytes(out, ThumbWebPQuality, UploadWebPMethod)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
tmp := fmt.Sprintf("%s.%d.tmp", dstPath, time.Now().UnixNano())
|
||
if err := os.WriteFile(tmp, data, 0644); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Rename(tmp, dstPath); err != nil {
|
||
_ = os.Remove(tmp)
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func resizeToMax(src image.Image, maxSide int) image.Image {
|
||
b := src.Bounds()
|
||
w, h := b.Dx(), b.Dy()
|
||
if w <= 0 || h <= 0 {
|
||
return src
|
||
}
|
||
if w <= maxSide && h <= maxSide {
|
||
return src
|
||
}
|
||
var nw, nh int
|
||
if w >= h {
|
||
nw = maxSide
|
||
nh = int(float64(h) * float64(maxSide) / float64(w))
|
||
} else {
|
||
nh = maxSide
|
||
nw = int(float64(w) * float64(maxSide) / float64(h))
|
||
}
|
||
if nw < 1 {
|
||
nw = 1
|
||
}
|
||
if nh < 1 {
|
||
nh = 1
|
||
}
|
||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||
draw.CatmullRom.Scale(dst, dst.Bounds(), src, b, draw.Over, nil)
|
||
return dst
|
||
}
|