feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -0,0 +1,282 @@
package service
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/freefire/jiang13-bbs/config"
webpenc "github.com/gen2brain/webp"
xwebp "golang.org/x/image/webp"
)
// solidImg 生成渐变纯色测试图
func solidImg(w, h int) *image.RGBA {
src := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
}
}
return src
}
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
t.Helper()
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
f, err := os.Create(abs)
if err != nil {
t.Fatalf("create: %v", err)
}
defer f.Close()
if err := encode(f); err != nil {
t.Fatalf("encode: %v", err)
}
}
func encodeJPEG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
}
}
func encodePNG(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return png.Encode(w, img)
}
}
func encodeWebP(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
}
}
func encodeGIF(img image.Image) func(io.Writer) error {
return func(w io.Writer) error {
return gif.Encode(w, img, nil)
}
}
// decodeVariant 解码返回的 WebP 变体
func decodeVariant(t *testing.T, data []byte) image.Image {
t.Helper()
img, err := xwebp.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode variant webp: %v", err)
}
return img
}
func TestIsVariantWidth(t *testing.T) {
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
t.Fatal("whitelist endpoints should be valid")
}
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
t.Fatal("non-whitelist width should be invalid")
}
}
func TestVariantRejectsInvalidInput(t *testing.T) {
s, _ := newUploadTestService(t)
cases := []struct {
name string
url string
w int
}{
{"非法尺寸", "/uploads/images/a.webp", 100},
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
{"非白名单目录", "/uploads/secret/a.webp", 96},
{"非图片扩展名", "/uploads/images/a.txt", 96},
{"外部地址", "https://evil.com/a.webp", 96},
{"空地址", "", 96},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
t.Fatal("want error, got nil")
}
})
}
}
func TestVariantMissingFile(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
t.Fatal("missing source should error")
}
}
func TestVariantPassthroughSmallImage(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/small.webp"
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
data, m, err := s.Variant(url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp mime, got %s", m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
if !bytes.Equal(data, src) {
t.Fatal("small image should be returned byte-for-byte")
}
}
func TestVariantResizesByWidthLandscape(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/wide.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
data, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 384 || b.Dy() != 192 {
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantResizesByWidthPortrait(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/tall.png"
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
data, _, err := s.Variant(url, 256)
if err != nil {
t.Fatalf("variant: %v", err)
}
b := decodeVariant(t, data).Bounds()
if b.Dx() != 256 || b.Dy() != 512 {
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
}
}
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
s, dir := newUploadTestService(t)
cases := []struct {
name string
url string
mime string
body []byte
}{
{"gif", "/uploads/images/a.gif", "image/gif", nil},
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.body != nil {
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
_, err := w.Write(tc.body)
return err
})
} else {
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
}
data, m, err := s.Variant(tc.url, 96)
if err != nil {
t.Fatalf("variant: %v", err)
}
if m != tc.mime {
t.Fatalf("want %s, got %s", tc.mime, m)
}
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
if !bytes.Equal(data, src) {
t.Fatalf("%s should pass through unchanged", tc.name)
}
})
}
}
func TestVariantCachedAndReused(t *testing.T) {
s, dir := newUploadTestService(t)
url := "/uploads/images/cache.jpg"
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
first, m, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("first variant: %v", err)
}
if m != "image/webp" {
t.Fatalf("want webp, got %s", m)
}
cache := s.variantCacheFile(url, 384)
if info, err := os.Stat(cache); err != nil || info.IsDir() {
t.Fatalf("cache file should exist: %v", err)
}
second, _, err := s.Variant(url, 384)
if err != nil {
t.Fatalf("second variant: %v", err)
}
if !bytes.Equal(first, second) {
t.Fatal("cached variant should be byte-identical")
}
}
func TestVariantCacheFreshness(t *testing.T) {
_, dir := newUploadTestService(t)
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
t.Fatal(err)
}
// 远程对象:modTime 零值,存在即新鲜
if !variantCacheFresh(cache, time.Time{}) {
t.Fatal("remote cache should be fresh when file exists")
}
// 本地源:mtime 新于缓存 → 不新鲜
future := time.Now().Add(1 * time.Hour)
if variantCacheFresh(cache, future) {
t.Fatal("cache should be stale vs newer source mtime")
}
}
func TestVariantRemoteRejectsBadID(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
t.Fatal("id with slash should be rejected")
}
}
func TestVariantRemoteUnavailable(t *testing.T) {
s, _ := newUploadTestService(t)
s.ops = NewOperations(s.db, &config.Config{})
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
t.Fatal("missing remote object should error")
}
}
func TestVariantRemoteWithoutOps(t *testing.T) {
s, _ := newUploadTestService(t)
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
t.Fatal("remote url without ops wired should error")
}
}