chore: 去掉未上线前的旧兼容垫片,板块流收口到首页

鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 05:04:47 +08:00
parent 0e61a14ea3
commit 833bcd33a1
33 changed files with 715 additions and 548 deletions

View File

@@ -41,7 +41,7 @@ func (h *Handlers) AnnouncementDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"announcement": a})
}
// ===== 管理接口(RequireAdmin 兜底,前端不做权限判定) =====
// ===== 管理接口(RequireStaff + PermAnnouncements,前端不做权限判定) =====
// AdminListAnnouncements 全部公告(含草稿)
func (h *Handlers) AdminListAnnouncements(c *gin.Context) {

View File

@@ -57,34 +57,23 @@ func setAuthCookies(c *gin.Context, accessToken, refreshToken string, secure boo
})
}
// clearAuthCookies 清除所有认证 cookie。
// 除当前名称外,还需兼容清除:
// - 启用 __Host- 前缀之前的旧无前缀名称(Path=/)
// - 更早期 Path=/api/auth 的旧 refresh cookie
// clearAuthCookies 清除当前契约下的三枚认证 cookie(名称随 ConfigureCookieNames,Path=/)。
func clearAuthCookies(c *gin.Context, secure bool) {
cookies := []struct{ name, path string }{
{service.CookieName, "/"},
{service.RefreshCookieName, "/"},
{service.CSRFCookieName, "/"},
cookies := []struct {
name string
httpOnly bool
}{
{service.CookieName, true},
{service.RefreshCookieName, true},
{service.CSRFCookieName, false},
}
// 当前生产名称带 __Host- 前缀时,旧无前缀 cookie 仍残留在浏览器中
if service.CookieName != "j13_token" {
cookies = append(cookies,
struct{ name, path string }{"j13_token", "/"},
struct{ name, path string }{"j13_refresh", "/"},
struct{ name, path string }{"j13_csrf", "/"},
)
}
// 更早期的 refresh cookie 只挂在 /api/auth 下
cookies = append(cookies, struct{ name, path string }{"j13_refresh", "/api/auth"})
for _, ck := range cookies {
http.SetCookie(c.Writer, &http.Cookie{
Name: ck.name,
Value: "",
Path: ck.path,
Path: "/",
MaxAge: -1,
HttpOnly: true,
HttpOnly: ck.httpOnly,
Secure: secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Unix(0, 0),

View File

@@ -1,9 +1,13 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// Boards 获取板块列表
@@ -15,3 +19,43 @@ func (h *Handlers) Boards(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
}
// BoardSidebar 首页选中板块后的右栏聚合数据
func (h *Handlers) BoardSidebar(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"})
return
}
data, err := h.Board.Sidebar(uint(id))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 热门榜点赞态 + 登录用户签到
if claims := middleware.CurrentUser(c); claims != nil {
ids := make([]uint, 0, len(data.Hot))
for _, p := range data.Hot {
ids = append(ids, p.ID)
}
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
for i := range data.Hot {
data.Hot[i].Liked = likedMap[data.Hot[i].ID]
}
if status, err := h.Checkin.Status(claims.ID); err == nil {
data.Checkin = status
}
}
c.JSON(http.StatusOK, gin.H{
"board": data.Board,
"moderators": data.Moderators,
"stats": data.Stats,
"hot": data.Hot,
"active_users": data.ActiveUsers,
"checkin": data.Checkin,
})
}

View File

@@ -25,18 +25,13 @@ func parseWSChatRoom(room string) (uint, bool) {
// 实时总线 WebSocket 入口:GET /api/ws
//
// 鉴权:与 REST 一致,优先读 HttpOnly 的 j13_token cookie(浏览器握手自动携带),
// 回退 Authorization 头;ParseToken + ValidateClaims 实时校验 token_version 与封禁。
// 鉴权:与 REST 一致,读 HttpOnly 的 j13_token cookie(浏览器握手自动携带);
// ParseToken + ValidateClaims 实时校验 token_version 与封禁。
// WebSocket 握手是 GET 请求,天然不受 CSRF 约束,但必须严格校验 Origin,
// 防止跨站页面在用户浏览器中发起握手。
func (h *Handlers) RealtimeWS(c *gin.Context) {
tokenStr, err := c.Cookie(service.CookieName)
if err != nil || tokenStr == "" {
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
tokenStr = strings.TrimPrefix(auth, "Bearer ")
}
}
if tokenStr == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "未登录"})
return
}
@@ -105,17 +100,17 @@ func (h *Handlers) RealtimeWS(c *gin.Context) {
})
}
// checkWSOrigin 校验握手 Origin:
// - 无 Origin(非浏览器/native 客户端)放行
// - dev:允许 localhost / 127.0.0.1 任意端口(前端 :3000 直连后端 :3001)
// - 生产:Origin 主机必须与请求 Host 同源
// checkWSOrigin 校验握手 Origin(仅服务浏览器前端):
// - 必须带 Origin(浏览器 WS 握手总会带;拒绝空 Origin 的脚本/非浏览器探测)
// - dev:允许 localhost / 127.0.0.1 任意端口(页面 :3000 直连后端 :3001,端口不同但 cookie 仍会带上)
// - 生产:Origin 的 host 必须与请求 Host 完全一致(需反向代理把页面与 /api/ws 挂到同一 Host)
func (h *Handlers) checkWSOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
return false
}
u, err := url.Parse(origin)
if err != nil || u.Scheme != "http" && u.Scheme != "https" {
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return false
}
if h.Cfg.DevMode {

View File

@@ -1,91 +0,0 @@
package handler
import (
"encoding/xml"
"fmt"
"net/http"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/gin-gonic/gin"
)
const siteBaseURL = "http://localhost:3000"
// RobotsTxt robots.txt
func (h *Handlers) RobotsTxt(c *gin.Context) {
content := fmt.Sprintf(`User-agent: *
Allow: /
Sitemap: %s/sitemap.xml
`, siteBaseURL)
c.String(http.StatusOK, content)
}
// SitemapXML 生成 sitemap
func (h *Handlers) SitemapXML(c *gin.Context) {
type urlEntry struct {
Loc string `xml:"loc"`
Lastmod string `xml:"lastmod"`
Changefreq string `xml:"changefreq"`
Priority string `xml:"priority"`
}
type urlset struct {
XMLName xml.Name `xml:"urlset"`
Xmlns string `xml:"xmlns,attr"`
URLs []urlEntry `xml:"url"`
}
now := time.Now().Format("2006-01-02")
entries := []urlEntry{
{Loc: siteBaseURL + "/", Lastmod: now, Changefreq: "daily", Priority: "1.0"},
}
// 板块页
var boards []model.Board
model.DB.Find(&boards)
for _, b := range boards {
entries = append(entries, urlEntry{
Loc: fmt.Sprintf("%s/board/%d", siteBaseURL, b.ID),
Lastmod: now,
Changefreq: "daily",
Priority: "0.8",
})
}
// 帖子详情页
var posts []model.Post
model.DB.Where("status = ?", model.ContentStatusPublished).
Order("created_at DESC").Limit(1000).Find(&posts)
for _, p := range posts {
entries = append(entries, urlEntry{
Loc: fmt.Sprintf("%s/post/%d", siteBaseURL, p.ID),
Lastmod: p.UpdatedAt.Format("2006-01-02"),
Changefreq: "weekly",
Priority: "0.6",
})
}
// 站点公告
var anns []model.Announcement
model.DB.Where("published = ?", true).Order("created_at DESC").Limit(200).Find(&anns)
for _, a := range anns {
entries = append(entries, urlEntry{
Loc: fmt.Sprintf("%s/announcement/%d", siteBaseURL, a.ID),
Lastmod: a.UpdatedAt.Format("2006-01-02"),
Changefreq: "weekly",
Priority: "0.5",
})
}
us := urlset{
Xmlns: "http://www.sitemaps.org/schemas/sitemap/0.9",
URLs: entries,
}
output, err := xml.MarshalIndent(us, "", " ")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.Header("Content-Type", "application/xml")
c.String(http.StatusOK, xml.Header+string(output))
}