- 新增 /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 条/分钟
(站主豁免,且只计成功写入)
193 lines
6.4 KiB
Go
193 lines
6.4 KiB
Go
// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go——
|
||
// 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同:
|
||
// GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"time"
|
||
|
||
"oneblog/internal/auth"
|
||
"oneblog/internal/httpx"
|
||
"oneblog/internal/model"
|
||
"oneblog/internal/ratelimit"
|
||
)
|
||
|
||
// authProviders 列出已配置的登录方式。
|
||
// redirect 型前端直接跳 /api/auth/{id}/login;widget 型(Telegram)
|
||
// 前端内联官方脚本,需要 bot 用户名。
|
||
func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
|
||
providers := []map[string]any{}
|
||
if a.GH.Enabled() {
|
||
providers = append(providers, map[string]any{
|
||
"id": "github", "label": "GitHub", "kind": "redirect",
|
||
})
|
||
}
|
||
if a.GG.Enabled() {
|
||
providers = append(providers, map[string]any{
|
||
"id": "google", "label": "Google", "kind": "redirect",
|
||
})
|
||
}
|
||
if a.TG.Enabled() {
|
||
providers = append(providers, map[string]any{
|
||
"id": "telegram", "label": "Telegram", "kind": "widget", "login": a.TG.Bot,
|
||
})
|
||
}
|
||
httpx.OK(w, map[string]any{"providers": providers})
|
||
}
|
||
|
||
// issueReaderCookie 登录成功后的公共收尾:发读者会话 + 决定跳回去的地址
|
||
func (a *API) issueReaderCookie(w http.ResponseWriter, r *http.Request, readerID int64) string {
|
||
token, _ := a.ReaderSessions.Issue(readerID)
|
||
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
|
||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
|
||
// 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根
|
||
back := a.Cfg.SiteURL
|
||
if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
|
||
if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
|
||
back = u.Scheme + "://" + u.Host
|
||
}
|
||
}
|
||
http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
|
||
return back
|
||
}
|
||
|
||
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
|
||
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
|
||
if !a.GG.Enabled() {
|
||
httpx.NotFound(w)
|
||
return
|
||
}
|
||
state := randHex(16)
|
||
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
|
||
HttpOnly: true, MaxAge: 600})
|
||
if ref := r.Referer(); ref != "" {
|
||
if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
|
||
http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
|
||
Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
|
||
}
|
||
}
|
||
http.Redirect(w, r, a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state), http.StatusFound)
|
||
}
|
||
|
||
// googleCallback 用 code 换身份:Google 用户 → upsert 读者 → 发会话
|
||
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
|
||
if !a.GG.Enabled() {
|
||
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
|
||
}
|
||
// handle 优先用已验证邮箱(可读),否则退回 sub(Google 的稳定唯一 id)
|
||
handle := u.Sub
|
||
if u.EmailVerified && u.Email != "" {
|
||
handle = u.Email
|
||
}
|
||
name := u.Name
|
||
if name == "" {
|
||
name = handle
|
||
}
|
||
reader, err := a.Store.UpsertReader(model.Reader{
|
||
Provider: "google", Handle: handle, Name: name,
|
||
AvatarURL: u.Picture,
|
||
})
|
||
if err != nil {
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound)
|
||
}
|
||
|
||
// telegramAuth 校验 Login Widget 回传的签名资料并登录。
|
||
// 前端把 widget 的 user 对象原样 POST 过来(见 reader.js 的 oneTelegramAuth)。
|
||
func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
|
||
if !a.TG.Enabled() {
|
||
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)
|
||
if err != nil {
|
||
httpx.BadRequest(w, "invalid body")
|
||
return
|
||
}
|
||
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
|
||
}
|
||
var in auth.TelegramUser
|
||
if err := json.Unmarshal(body, &in); err != nil || in.IDInt() == 0 {
|
||
httpx.BadRequest(w, "invalid body")
|
||
return
|
||
}
|
||
handle := in.Username
|
||
if handle == "" {
|
||
// 没有公开 username 的用户用数字 id,保证 provider+handle 稳定唯一
|
||
handle = strconv.FormatInt(in.IDInt(), 10)
|
||
}
|
||
reader, err := a.Store.UpsertReader(model.Reader{
|
||
Provider: "telegram", Handle: handle, Name: in.DisplayName(),
|
||
AvatarURL: in.PhotoURL,
|
||
URL: tgProfileURL(in.Username),
|
||
})
|
||
if err != nil {
|
||
httpx.ServerError(w, err)
|
||
return
|
||
}
|
||
// 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见
|
||
token, _ := a.ReaderSessions.Issue(reader.ID)
|
||
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
|
||
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
|
||
httpx.OK(w, map[string]any{"user": map[string]any{
|
||
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
|
||
"avatar_url": reader.AvatarURL, "url": reader.URL,
|
||
"provider": reader.Provider, "is_owner": false, "banned": reader.Banned,
|
||
}})
|
||
}
|
||
|
||
func tgProfileURL(username string) string {
|
||
if username == "" {
|
||
return ""
|
||
}
|
||
return "https://t.me/" + username
|
||
}
|