缩略图懒生成 + 评论登录限流:时间线不再拉原图
- 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后
落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却
一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent,
补上视频拖动进度条所需的 Range 支持
- 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文,
并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为
{settings, uploads_public_base},让前端识别哪些直链属于自家存储
- 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变),
新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟
(站主豁免,且只计成功写入)
This commit is contained in:
@@ -138,7 +138,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
key := sourceKey(r)
|
||||
if a.limiter().blocked(key) {
|
||||
if a.limiter().Blocked(key) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
|
||||
return
|
||||
}
|
||||
@@ -150,11 +150,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
||||
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
|
||||
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
|
||||
if !userOK || !passOK {
|
||||
a.limiter().fail(key)
|
||||
a.limiter().Add(key)
|
||||
httpx.Unauthorized(w)
|
||||
return
|
||||
}
|
||||
a.limiter().reset(key)
|
||||
a.limiter().Reset(key)
|
||||
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/ratelimit"
|
||||
)
|
||||
|
||||
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
|
||||
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
// 实现挪到了 internal/ratelimit(读者登录/评论写入共用),这里保留
|
||||
// 原有调用面与常量。
|
||||
type loginLimiter = ratelimit.Window
|
||||
|
||||
const (
|
||||
maxLoginFails = 10
|
||||
@@ -20,55 +19,10 @@ const (
|
||||
)
|
||||
|
||||
func newLoginLimiter() *loginLimiter {
|
||||
return &loginLimiter{hits: make(map[string][]time.Time)}
|
||||
return ratelimit.New(maxLoginFails, loginWindow)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= maxLoginFails
|
||||
}
|
||||
|
||||
func (l *loginLimiter) fail(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
l.hits[key] = append(l.recent(key, now), now)
|
||||
}
|
||||
|
||||
func (l *loginLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
// recent 返回窗口内的失败时间;调用方必须持有 l.mu。
|
||||
func (l *loginLimiter) recent(key string, now time.Time) []time.Time {
|
||||
hs := l.hits[key]
|
||||
cut := now.Add(-loginWindow)
|
||||
i := 0
|
||||
for ; i < len(hs); i++ {
|
||||
if hs[i].After(cut) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if i > 0 {
|
||||
hs = hs[i:]
|
||||
l.hits[key] = hs
|
||||
}
|
||||
if len(hs) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
func sourceKey(r *http.Request) string {
|
||||
// 只信连接层地址;X-Forwarded-For 可被伪造,不作为限速键。
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
func sourceKey(r *http.Request) string { return ratelimit.SourceKey(r) }
|
||||
|
||||
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
|
||||
func isTLS(r *http.Request) bool {
|
||||
|
||||
@@ -16,14 +16,19 @@ import (
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
Store *store.Store
|
||||
Cfg *config.Config
|
||||
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
|
||||
// Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。
|
||||
// 为 nil 时 /uploads/thumb/ 路由直接回原图。
|
||||
Thumbs *thumbs.Store
|
||||
// 评论区读者会话与 GitHub OAuth(main.go 装配)
|
||||
ReaderSessions *auth.ReaderSessions
|
||||
GH auth.GitHub
|
||||
@@ -35,9 +40,26 @@ type API struct {
|
||||
// 其余登录方式(main.go 装配,未配置的自动不开放)
|
||||
GG auth.Google
|
||||
TG auth.Telegram
|
||||
|
||||
// 限流(Routes 里惰性初始化):读者登录失败按 IP 计、评论写入按读者计。
|
||||
// 登录入口此前裸奔——OAuth 跳转本身难刷,但 state 校验失败、
|
||||
// Telegram 伪造签名这类恶意请求需要一个兜底。
|
||||
authFails *ratelimit.Window
|
||||
commentNew *ratelimit.Window
|
||||
}
|
||||
|
||||
const (
|
||||
maxAuthFails = 20 // 窗口内允许的登录失败(含伪造回调)
|
||||
authFailWindow = 10 * time.Minute
|
||||
maxComments = 5 // 每个读者每窗口最多发几条
|
||||
commentWindow = time.Minute
|
||||
)
|
||||
|
||||
func (a *API) Routes() http.Handler {
|
||||
if a.authFails == nil {
|
||||
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
|
||||
a.commentNew = ratelimit.New(maxComments, commentWindow)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := a.Store.Ping(r.Context()); err != nil {
|
||||
@@ -81,7 +103,8 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
httpx.OK(w, st)
|
||||
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
|
||||
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
|
||||
}
|
||||
|
||||
func listOptions(r *http.Request, defSize int) store.ListOptions {
|
||||
@@ -341,11 +364,17 @@ func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。
|
||||
// key 含内容哈希:内容不变则 URL 不变,可永久缓存。
|
||||
w.Header().Set("Content-Type", f.Mime)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
w.Header().Set("ETag", `"`+f.SHA256+`"`)
|
||||
// key 含内容哈希:内容不变则 URL 不变,可永久缓存
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
if rs, ok := rc.(io.ReadSeeker); ok {
|
||||
http.ServeContent(w, r, f.Name, time.Time{}, rs)
|
||||
return
|
||||
}
|
||||
// 非本地存储拿不到 Seeker 时退回流式拷贝
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/config"
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
|
||||
func newTestAPI(t *testing.T) (*API, http.Handler) {
|
||||
t.Helper()
|
||||
d, err := db.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
st, err := store.New(d)
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
a := &API{
|
||||
Store: st,
|
||||
Cfg: &config.Config{SiteURL: "http://localhost:8080"},
|
||||
ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour),
|
||||
TG: auth.Telegram{Bot: "testbot", Token: "123:abc"},
|
||||
}
|
||||
settings, err := st.GetSettings()
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
settings.CommentsEnabled = true
|
||||
if err := st.UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("enable comments: %v", err)
|
||||
}
|
||||
return a, a.Routes()
|
||||
}
|
||||
|
||||
// Telegram 伪造签名反复重试应触发 IP 限速
|
||||
func TestTelegramAuthRateLimited(t *testing.T) {
|
||||
_, h := newTestAPI(t)
|
||||
body := `{"id":1,"first_name":"x","hash":"deadbeef"}`
|
||||
var rec *httptest.ResponseRecorder
|
||||
for i := 0; i < maxAuthFails; i++ {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("attempt %d: got %d, want 403", i+1, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("after %d failures: got %d, want 429", maxAuthFails, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 评论写入按读者限速:第 maxComments+1 条被拒
|
||||
func TestCommentRateLimited(t *testing.T) {
|
||||
a, h := newTestAPI(t)
|
||||
p, err := a.Store.Create(model.PostInput{Kind: model.KindLong, Title: "t", Slug: "t",
|
||||
ContentMd: "x", Status: model.StatusPublished})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "u1", Name: "u1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok, _ := a.ReaderSessions.Issue(rd.ID)
|
||||
post := func(i int) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"post_id": p.ID, "body_md": "好"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/comments", strings.NewReader(string(body)))
|
||||
req.AddCookie(&http.Cookie{Name: auth.ReaderCookie, Value: tok})
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
for i := 0; i < maxComments; i++ {
|
||||
if rec := post(i); rec.Code != http.StatusCreated {
|
||||
t.Fatalf("comment %d: got %d %s", i+1, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if rec := post(maxComments); rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("over limit: got %d, want 429", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
"oneblog/internal/render"
|
||||
"oneblog/internal/store"
|
||||
)
|
||||
@@ -121,18 +122,26 @@ func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.authFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
ck, err := r.Cookie(oauthStateCook)
|
||||
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
|
||||
a.authFails.Add(ip)
|
||||
httpx.BadRequest(w, "state 不匹配,请重新登录")
|
||||
return
|
||||
}
|
||||
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github")
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
u, err := a.GH.FetchUser(r.Context(), gh)
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -206,6 +215,15 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
|
||||
return
|
||||
}
|
||||
// 写入限速:每读者每分钟最多 maxComments 条(站主豁免,批量回复不该被卡)
|
||||
var rateKey string
|
||||
if !isOwner {
|
||||
rateKey = strconv.FormatInt(reader.ID, 10)
|
||||
if a.commentNew.Blocked(rateKey) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "发得太快了,休息一分钟再试")
|
||||
return
|
||||
}
|
||||
}
|
||||
st, err := a.Store.GetSettings()
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
@@ -267,6 +285,9 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
if rateKey != "" {
|
||||
a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额
|
||||
}
|
||||
httpx.Created(w, c)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"oneblog/internal/auth"
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/ratelimit"
|
||||
)
|
||||
|
||||
// authProviders 列出已配置的登录方式。
|
||||
@@ -79,18 +80,26 @@ func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.authFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
ck, err := r.Cookie(oauthStateCook)
|
||||
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
|
||||
a.authFails.Add(ip)
|
||||
httpx.BadRequest(w, "state 不匹配,请重新登录")
|
||||
return
|
||||
}
|
||||
accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google")
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
u, err := a.GG.FetchUser(r.Context(), accessToken)
|
||||
if err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
@@ -121,6 +130,12 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
// widget 回传是纯表单 POST,签名可被伪造重放——失败计数最必要的一路
|
||||
ip := ratelimit.SourceKey(r)
|
||||
if a.authFails.Blocked(ip) {
|
||||
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
|
||||
return
|
||||
}
|
||||
// 原样读 body:验签必须用收到的全部字段(官方规则),
|
||||
// 身份字段再单独解一次
|
||||
body, err := io.ReadAll(r.Body)
|
||||
@@ -130,10 +145,12 @@ func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(body, &fields); err != nil || len(fields) == 0 {
|
||||
a.authFails.Add(ip)
|
||||
httpx.BadRequest(w, "invalid body")
|
||||
return
|
||||
}
|
||||
if err := a.TG.VerifyMap(fields); err != nil {
|
||||
a.authFails.Add(ip)
|
||||
httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// 缩略图懒生成:GET /uploads/thumb/{key}?w=960
|
||||
//
|
||||
// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache),
|
||||
// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内
|
||||
// 一律 302 回原图:前端 <img> 拿 302 是无感的。
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"oneblog/internal/httpx"
|
||||
"oneblog/internal/model"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbDefaultW = 960
|
||||
thumbMinW = 64
|
||||
thumbMaxW = 1600
|
||||
)
|
||||
|
||||
func (a *API) ThumbHandler() http.Handler {
|
||||
return http.HandlerFunc(a.thumb)
|
||||
}
|
||||
|
||||
func (a *API) thumb(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Thumbs == nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
key := strings.TrimPrefix(r.URL.Path, "/uploads/thumb/")
|
||||
if key == "" || strings.Contains(key, "..") {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
width := thumbDefaultW
|
||||
if s := r.URL.Query().Get("w"); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n >= thumbMinW && n <= thumbMaxW {
|
||||
width = n
|
||||
}
|
||||
}
|
||||
|
||||
f, err := a.Store.GetFileByKey(key)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpx.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !thumbs.Supported(f.Mime) || f.SHA256 == "" {
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
|
||||
// 快路径:缓存命中直接供(锁外)
|
||||
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
|
||||
a.serveThumbFile(w, r, p, f)
|
||||
return
|
||||
}
|
||||
// 冷却期:近期失败过,不再尝试
|
||||
if a.Thumbs.FailedRecently(f.SHA256) {
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
|
||||
// 慢路径:同 key+宽度并发只生成一次,后来者等锁后读缓存
|
||||
lk := a.Thumbs.Lock(f.SHA256, width)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
|
||||
a.serveThumbFile(w, r, p, f)
|
||||
return
|
||||
}
|
||||
|
||||
rc, _, err := a.Blobs.Open(r.Context(), f.Key)
|
||||
if err != nil {
|
||||
a.Thumbs.MarkFailed(f.SHA256)
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
src, err := thumbs.ReadAllLimited(rc, thumbs.MaxSrcBytes)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
a.Thumbs.MarkFailed(f.SHA256)
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
out, outMime, _, err := thumbs.Generate(src, f.Mime, width)
|
||||
if err != nil {
|
||||
a.Thumbs.MarkFailed(f.SHA256)
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
p, err := a.Thumbs.Put(f.SHA256, width, out, outMime)
|
||||
if err != nil {
|
||||
// 写缓存失败不拖累本次响应:产物就在内存里
|
||||
a.serveThumbBytes(w, outMime, out, f)
|
||||
return
|
||||
}
|
||||
a.serveThumbFile(w, r, p, f)
|
||||
}
|
||||
|
||||
// thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。
|
||||
func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) {
|
||||
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
|
||||
}
|
||||
|
||||
// serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。
|
||||
func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string, f model.File) {
|
||||
fp, err := os.Open(path)
|
||||
if err != nil {
|
||||
a.thumbFallback(w, r, f)
|
||||
return
|
||||
}
|
||||
defer fp.Close()
|
||||
fi, err := fp.Stat()
|
||||
if err != nil {
|
||||
httpx.NotFound(w)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
http.ServeContent(w, r, path, fi.ModTime(), fp)
|
||||
}
|
||||
|
||||
func (a *API) serveThumbBytes(w http.ResponseWriter, mime string, data []byte, f model.File) {
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.Header().Set("ETag", `"`+f.SHA256+`"`)
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package ratelimit 是一个极小的按来源 IP 滑动窗口限速器,
|
||||
// 后台登录与读者登录 / 评论写入共用同一实现。
|
||||
// 只计失败/写入这类「不该高频」的事件;键取连接层地址,
|
||||
// X-Forwarded-For 可伪造,不作为限速键。
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Window struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
max int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func New(max int, window time.Duration) *Window {
|
||||
return &Window{hits: make(map[string][]time.Time), max: max, window: window}
|
||||
}
|
||||
|
||||
// Blocked 报告 key 在窗口内是否已达上限。
|
||||
func (l *Window) Blocked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= l.max
|
||||
}
|
||||
|
||||
// Add 记录一次事件。
|
||||
func (l *Window) Add(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
l.hits[key] = append(l.recent(key, now), now)
|
||||
}
|
||||
|
||||
// Reset 清空 key 的计数(如登录成功后)。
|
||||
func (l *Window) Reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
// recent 返回窗口内的事件时间;调用方必须持有 l.mu。
|
||||
func (l *Window) recent(key string, now time.Time) []time.Time {
|
||||
hs := l.hits[key]
|
||||
cut := now.Add(-l.window)
|
||||
i := 0
|
||||
for ; i < len(hs); i++ {
|
||||
if hs[i].After(cut) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if i > 0 {
|
||||
hs = hs[i:]
|
||||
l.hits[key] = hs
|
||||
}
|
||||
if len(hs) == 0 {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
return hs
|
||||
}
|
||||
|
||||
// SourceKey 取连接层来源地址(IPv6 去掉端口)。
|
||||
func SourceKey(r *http.Request) string {
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// Package thumbs 懒生成上传图片的缩略图并落盘缓存(memos 模式):
|
||||
// 首次请求时从存储端读一次原图,缩放编码后进缓存,此后不再碰原文件;
|
||||
// 生成失败写 .failed 标记,1 小时内不反复重试。
|
||||
package thumbs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxSrcBytes 超过此大小的原图不生成(防止大图拖垮请求),调用方回原图。
|
||||
MaxSrcBytes = 25 << 20 // 25MB
|
||||
// maxDim 单边像素上限,超过视为畸形图(解压炸弹防线)。
|
||||
maxDim = 10000
|
||||
// JPEGQuality 转码输出质量。
|
||||
JPEGQuality = 82
|
||||
// failedTTL 失败后的重试冷却。
|
||||
failedTTL = time.Hour
|
||||
)
|
||||
|
||||
// Supported 报告该 mime 是否走缩略图管线(标准库能完整解码的静图)。
|
||||
func Supported(mime string) bool {
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png", "image/gif":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Generate 把原图字节缩放到宽 w(只缩不放)。已够小的原生格式图原样直通
|
||||
// (返回 passThrough=true,调用方直接落盘 src),其余统一转 JPEG——
|
||||
// 缩略图不需要 alpha,透明区铺白底。
|
||||
func Generate(src []byte, mime string, w int) (out []byte, outMime string, passThrough bool, err error) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(src))
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
|
||||
return nil, "", false, fmt.Errorf("bad dimensions %dx%d", cfg.Width, cfg.Height)
|
||||
}
|
||||
if cfg.Width <= w && Supported(mime) {
|
||||
return src, mime, true, nil
|
||||
}
|
||||
|
||||
var img image.Image
|
||||
switch mime {
|
||||
case "image/jpeg":
|
||||
img, err = jpeg.Decode(bytes.NewReader(src))
|
||||
case "image/png":
|
||||
img, err = png.Decode(bytes.NewReader(src))
|
||||
case "image/gif":
|
||||
img, err = gif.Decode(bytes.NewReader(src)) // 首帧,缩略图不做动画
|
||||
default:
|
||||
return nil, "", false, fmt.Errorf("unsupported mime %q", mime)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", false, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, Resize(img, w), &jpeg.Options{Quality: JPEGQuality}); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg", false, nil
|
||||
}
|
||||
|
||||
// Resize 盒均值(box-average)缩放到宽 nw(等比,只缩不放)。
|
||||
// 非预乘 RGBA 按 alpha 加权平均,透明像素不计入颜色、铺白底。纯标准库。
|
||||
func Resize(src image.Image, nw int) *image.RGBA {
|
||||
b := src.Bounds()
|
||||
ow, oh := b.Dx(), b.Dy()
|
||||
if nw <= 0 || nw >= ow {
|
||||
nw = ow
|
||||
}
|
||||
nh := (oh*nw + ow/2) / ow
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
flat := flatten(src, b)
|
||||
|
||||
for y := 0; y < nh; y++ {
|
||||
y0 := y * oh / nh
|
||||
y1 := (y+1)*oh/nh + 1
|
||||
if y1 > oh {
|
||||
y1 = oh
|
||||
}
|
||||
if y1 <= y0 {
|
||||
y1 = y0 + 1
|
||||
}
|
||||
for x := 0; x < nw; x++ {
|
||||
x0 := x * ow / nw
|
||||
x1 := (x+1)*ow/nw + 1
|
||||
if x1 > ow {
|
||||
x1 = ow
|
||||
}
|
||||
if x1 <= x0 {
|
||||
x1 = x0 + 1
|
||||
}
|
||||
var r, g, bl, a, n uint64
|
||||
for sy := y0; sy < y1; sy++ {
|
||||
row := sy * ow * 4
|
||||
for sx := x0; sx < x1; sx++ {
|
||||
i := row + sx*4
|
||||
al := uint64(flat[i+3])
|
||||
r += uint64(flat[i]) * al
|
||||
g += uint64(flat[i+1]) * al
|
||||
bl += uint64(flat[i+2]) * al
|
||||
a += al
|
||||
n++
|
||||
}
|
||||
}
|
||||
d := y*nw*4 + x*4
|
||||
if a == 0 || n == 0 {
|
||||
dst.Pix[d], dst.Pix[d+1], dst.Pix[d+2], dst.Pix[d+3] = 255, 255, 255, 255
|
||||
continue
|
||||
}
|
||||
dst.Pix[d] = uint8(r / a)
|
||||
dst.Pix[d+1] = uint8(g / a)
|
||||
dst.Pix[d+2] = uint8(bl / a)
|
||||
dst.Pix[d+3] = uint8(a / n)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// flatten 把任意 image.Image 转成 w*h 扁平非预乘 RGBA 字节。
|
||||
func flatten(src image.Image, b image.Rectangle) []byte {
|
||||
w, h := b.Dx(), b.Dy()
|
||||
out := make([]byte, w*h*4)
|
||||
i := 0
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl, a := src.At(x, y).RGBA() // 0..0xffff,预乘
|
||||
pa := a >> 8
|
||||
if pa == 0 {
|
||||
out[i], out[i+1], out[i+2], out[i+3] = 0, 0, 0, 0
|
||||
} else {
|
||||
out[i] = uint8(min255((r >> 8) * 0xff / pa))
|
||||
out[i+1] = uint8(min255((g >> 8) * 0xff / pa))
|
||||
out[i+2] = uint8(min255((bl >> 8) * 0xff / pa))
|
||||
out[i+3] = uint8(pa)
|
||||
}
|
||||
i += 4
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func min255(v uint32) uint32 {
|
||||
if v > 0xff {
|
||||
return 0xff
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Store 是缩略图磁盘缓存:
|
||||
//
|
||||
// {Dir}/{sha[:2]}/{sha[:32]}-{w}.jpg 成功产物(直通可能是 .png/.gif)
|
||||
// {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记
|
||||
//
|
||||
// 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。
|
||||
type Store struct {
|
||||
Dir string
|
||||
mu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(dir string) *Store {
|
||||
return &Store{Dir: dir, locks: make(map[string]*sync.Mutex)}
|
||||
}
|
||||
|
||||
func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) }
|
||||
|
||||
func (s *Store) jpegPath(sha string, w int) string {
|
||||
return filepath.Join(s.dir(sha), fmt.Sprintf("%s-%d.jpg", sha[:32], w))
|
||||
}
|
||||
|
||||
func (s *Store) failedPath(sha string) string {
|
||||
return filepath.Join(s.dir(sha), sha[:32]+".failed")
|
||||
}
|
||||
|
||||
// FindCached 返回已存在的缓存文件(jpg 优先,其次直通的 png/gif),没有则空串。
|
||||
func (s *Store) FindCached(sha string, w int) string {
|
||||
base := strings.TrimSuffix(s.jpegPath(sha, w), ".jpg")
|
||||
for _, ext := range []string{".jpg", ".png", ".gif"} {
|
||||
if fi, err := os.Stat(base + ext); err == nil && !fi.IsDir() {
|
||||
return base + ext
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Put 原子写入缓存文件(内容直通时保留原生格式),成功则清掉失败标记。
|
||||
func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) {
|
||||
p := s.jpegPath(sha, w)
|
||||
if mime != "image/jpeg" {
|
||||
p = strings.TrimSuffix(p, ".jpg") + extFor(mime)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
_ = os.Remove(s.failedPath(sha))
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// FailedRecently 报告是否在冷却期(近期生成失败过)。
|
||||
func (s *Store) FailedRecently(sha string) bool {
|
||||
fi, err := os.Stat(s.failedPath(sha))
|
||||
return err == nil && time.Since(fi.ModTime()) < failedTTL
|
||||
}
|
||||
|
||||
// MarkFailed 落失败标记。
|
||||
func (s *Store) MarkFailed(sha string) {
|
||||
if err := os.MkdirAll(s.dir(sha), 0o755); err == nil {
|
||||
_ = os.WriteFile(s.failedPath(sha), []byte("thumbs: generation failed\n"), 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
// Lock 取该(哈希, 宽度)的互斥锁:并发首请求只生成一次,其余等待后读缓存。
|
||||
func (s *Store) Lock(sha string, w int) *sync.Mutex {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if len(s.locks) > lockCacheMax {
|
||||
clear(s.locks) // 粗粒度回收:正在持有的锁不受影响(调用方拿着指针),
|
||||
} // 清掉的只是历史条目,最坏情况是同一图并发重生成一次
|
||||
k := fmt.Sprintf("%s-%d", sha[:32], w)
|
||||
l, ok := s.locks[k]
|
||||
if !ok {
|
||||
l = &sync.Mutex{}
|
||||
s.locks[k] = l
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。
|
||||
const lockCacheMax = 4096
|
||||
|
||||
func extFor(mime string) string {
|
||||
switch mime {
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
}
|
||||
return ".jpg"
|
||||
}
|
||||
|
||||
// ReadAllLimited 读至多 max 字节,超限报错(防止把 25MB+ 原图吞进内存)。
|
||||
func ReadAllLimited(r io.Reader, max int64) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > max {
|
||||
return nil, fmt.Errorf("source exceeds %d bytes", max)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package thumbs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makePNG(t *testing.T, w, h int, transparent bool) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
// 左红右绿两半,方便验证盒均值没把通道混错
|
||||
c := color.RGBA{255, 0, 0, 255}
|
||||
if x >= w/2 {
|
||||
c = color.RGBA{0, 255, 0, 255}
|
||||
}
|
||||
if transparent {
|
||||
c = color.RGBA{0, 0, 0, 0}
|
||||
}
|
||||
img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func TestGenerateDownscale(t *testing.T) {
|
||||
src := makePNG(t, 800, 400, false)
|
||||
out, mime, pass, err := Generate(src, "image/png", 400)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pass {
|
||||
t.Fatal("800px 图在 w=400 不应直通")
|
||||
}
|
||||
if mime != "image/jpeg" {
|
||||
t.Fatalf("mime=%s", mime)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Width != 400 || cfg.Height != 200 {
|
||||
t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height)
|
||||
}
|
||||
img, err := jpeg.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量)
|
||||
r, g, _, _ := img.At(100, 100).RGBA()
|
||||
if r>>8 < 200 || g>>8 > 60 {
|
||||
t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8)
|
||||
}
|
||||
r, g, _, _ = img.At(300, 100).RGBA()
|
||||
if g>>8 < 200 || r>>8 > 60 {
|
||||
t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePassThrough(t *testing.T) {
|
||||
src := makePNG(t, 300, 150, false)
|
||||
out, mime, pass, err := Generate(src, "image/png", 960)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pass || mime != "image/png" || !bytes.Equal(out, src) {
|
||||
t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRejectsGarbage(t *testing.T) {
|
||||
if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil {
|
||||
t.Fatal("垃圾字节应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenAlphaUnpremultiply(t *testing.T) {
|
||||
// 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255
|
||||
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
|
||||
img.Set(0, 0, color.NRGBA{255, 0, 0, 128})
|
||||
flat := flatten(img, img.Bounds())
|
||||
if flat[0] < 240 || flat[3] != 128 {
|
||||
t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3])
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -23,6 +23,7 @@ import (
|
||||
"oneblog/internal/db"
|
||||
"oneblog/internal/storage"
|
||||
"oneblog/internal/store"
|
||||
"oneblog/internal/thumbs"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -65,11 +66,14 @@ func main() {
|
||||
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
|
||||
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
|
||||
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
|
||||
// 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存)
|
||||
thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache"))
|
||||
|
||||
public := &api.API{
|
||||
Store: st,
|
||||
Cfg: cfg,
|
||||
Blobs: blobs,
|
||||
Thumbs: thumbCache,
|
||||
ReaderSessions: readerSessions,
|
||||
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret},
|
||||
GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret},
|
||||
@@ -78,11 +82,12 @@ func main() {
|
||||
}
|
||||
adminAPI := admin.NewAPI(st, cfg, adminSessions)
|
||||
adminAPI.Blobs = blobs
|
||||
adminAPI.Blobs = blobs
|
||||
|
||||
root := http.NewServeMux()
|
||||
root.Handle("/api/admin/", adminAPI.Routes())
|
||||
public.Mount(root)
|
||||
// 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式
|
||||
root.Handle("/uploads/thumb/", public.ThumbHandler())
|
||||
root.Handle("/uploads/", public.UploadsHandler())
|
||||
root.Handle("/", spaHandler(cfg.WebDist))
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { site } from '../site'
|
||||
import { openLightbox } from '../lightbox'
|
||||
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
|
||||
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -20,10 +20,11 @@ function openPost() {
|
||||
router.push(`/post/${props.post.slug}`)
|
||||
}
|
||||
|
||||
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
|
||||
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。
|
||||
// 正文里的 <img> 换缩略图并补懒加载(详情仍是原图)。
|
||||
const body = computed(() => {
|
||||
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
|
||||
return sanitizeHtml(props.post.content_html)
|
||||
return sanitizeHtml(thumbifyHtml(props.post.content_html))
|
||||
})
|
||||
|
||||
// 长文没写摘要时,从正文里截一段纯文本
|
||||
@@ -33,6 +34,9 @@ const summaryText = computed(() => {
|
||||
})
|
||||
|
||||
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
|
||||
|
||||
// 配图缩略图宽度:单图全宽格取宽一些,多图小格 480 足够(2x 屏也清晰)
|
||||
const gridW = computed(() => (props.post.images && props.post.images.length === 1 ? 720 : 480))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,7 +62,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
<template v-else>
|
||||
<div class="summary">{{ summaryText }}</div>
|
||||
<div v-if="post.cover_url" class="imgs cover">
|
||||
<img :src="post.cover_url" alt="" loading="lazy" decoding="async" />
|
||||
<img :src="thumbURL(post.cover_url, 640)" alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,7 +75,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
||||
<img
|
||||
v-for="(u, i) in post.images.slice(0, 4)"
|
||||
:key="i"
|
||||
:src="u"
|
||||
:src="thumbURL(u, gridW)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { reactive, watch } from 'vue'
|
||||
import { publicApi } from './api'
|
||||
import { setThumbBase } from './utils'
|
||||
|
||||
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
|
||||
const DARK_SKIN = 'ink'
|
||||
@@ -152,8 +153,11 @@ if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
|
||||
export async function loadSite() {
|
||||
try {
|
||||
const data = await publicApi.site()
|
||||
Object.assign(site, data)
|
||||
const resp = await publicApi.site()
|
||||
// 响应结构:{ settings: {…}, uploads_public_base }
|
||||
const { uploads_public_base, settings } = resp || {}
|
||||
setThumbBase(uploads_public_base)
|
||||
Object.assign(site, settings)
|
||||
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
|
||||
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
|
||||
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { openLightbox } from '../../lightbox'
|
||||
import { thumbURL, thumbifyHtml } from '../../utils'
|
||||
|
||||
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
|
||||
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
|
||||
@@ -35,7 +36,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
|
||||
<!-- 长文:卡片 -->
|
||||
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
|
||||
<div v-if="p.cover_url" class="cov">
|
||||
<img :src="p.cover_url" alt="" loading="lazy">
|
||||
<img :src="thumbURL(p.cover_url, 640)" alt="" loading="lazy">
|
||||
</div>
|
||||
<div class="body">
|
||||
<h2>{{ p.title }}</h2>
|
||||
@@ -54,7 +55,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
|
||||
|
||||
<!-- 短文:纸条,正文直接铺开 -->
|
||||
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
|
||||
<div class="body" v-html="p.content_html" />
|
||||
<div class="body" v-html="thumbifyHtml(p.content_html)" />
|
||||
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
|
||||
<div
|
||||
v-if="p.images && p.images.length"
|
||||
@@ -64,7 +65,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
|
||||
<img
|
||||
v-for="(u, i) in p.images.slice(0, 4)"
|
||||
:key="i"
|
||||
:src="u"
|
||||
:src="thumbURL(u, p.images.length === 1 ? 720 : 480)"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
|
||||
@@ -4,6 +4,7 @@ import YohakuHero from './YohakuHero.vue'
|
||||
import YohakuToolbar from './YohakuToolbar.vue'
|
||||
import YohakuFeed from './YohakuFeed.vue'
|
||||
import YohakuPager from './YohakuPager.vue'
|
||||
import { thumbURL } from '../../utils'
|
||||
|
||||
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
|
||||
// hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。
|
||||
@@ -81,7 +82,7 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
|
||||
to="/projects"
|
||||
>
|
||||
<div v-if="p.cover_url" class="cov">
|
||||
<img :src="p.cover_url" alt="" loading="lazy">
|
||||
<img :src="thumbURL(p.cover_url, 480)" alt="" loading="lazy">
|
||||
<span v-if="p.status" class="st">{{ p.status }}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
|
||||
@@ -6,6 +6,40 @@ export function sanitizeHtml(html) {
|
||||
return DOMPurify.sanitize(html || '')
|
||||
}
|
||||
|
||||
// ---------- 缩略图 ----------
|
||||
// 时间线只展示小图:把我们存储上的图片直链改写为 /uploads/thumb/{key}?w=,
|
||||
// 后端懒生成并永久缓存。识别规则:以 uploads_public_base 开头的直链,
|
||||
// 或同源的 /uploads/ 相对路径(本地存储兜底)。外链(图床/头像)原样返回。
|
||||
// base 由 site.js 拉到站点设置后经 setThumbBase 注入(不走 import 避免循环依赖)。
|
||||
const IMG_EXT = /\.(jpe?g|png|gif)([?#].*)?$/i
|
||||
let thumbBase = ''
|
||||
|
||||
export function setThumbBase(base) {
|
||||
thumbBase = (base || '').replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export function thumbURL(u, w = 480) {
|
||||
if (!u || typeof u !== 'string') return u
|
||||
let key = ''
|
||||
if (thumbBase && u.startsWith(thumbBase + '/')) key = u.slice(thumbBase.length + 1)
|
||||
else if (u.startsWith('/uploads/') && !u.startsWith('/uploads/thumb/')) key = u.slice('/uploads/'.length)
|
||||
else return u
|
||||
if (!IMG_EXT.test(key)) return u
|
||||
return '/uploads/thumb/' + key + '?w=' + w
|
||||
}
|
||||
|
||||
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
|
||||
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
|
||||
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
|
||||
export function thumbifyHtml(html, w = 480) {
|
||||
if (!html) return html
|
||||
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
|
||||
let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"')
|
||||
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
|
||||
return out
|
||||
})
|
||||
}
|
||||
|
||||
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
|
||||
const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
|
||||
@@ -16,6 +16,8 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': { target, changeOrigin: true },
|
||||
// 上传文件与缩略图路由都在后端(/uploads/、/uploads/thumb/)
|
||||
'/uploads': { target, changeOrigin: true },
|
||||
'/rss.xml': { target, changeOrigin: true },
|
||||
'/feed': { target, changeOrigin: true },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user