- 新增 /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 条/分钟
(站主豁免,且只计成功写入)
97 lines
2.8 KiB
Go
97 lines
2.8 KiB
Go
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)
|
|
}
|
|
}
|