feat: 完整内容 Limits 与伪静态 Admin

设置页可配字数/编辑窗/分页与伪静态后缀;公开帖/板/用户链接走规范路径并 301。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 04:12:43 +08:00
parent 118f967540
commit f0358e0f71
20 changed files with 277 additions and 63 deletions

View File

@@ -429,6 +429,7 @@ type adminSettingsData struct {
RateReg int
RateLogin int
RateWindow int
Limits services.ForumLimits
FilterWords string
FilterCount int
Mail services.MailConfig
@@ -465,6 +466,7 @@ func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
RateReg: lim.RateLimitRegister,
RateLogin: lim.RateLimitLogin,
RateWindow: lim.RateLimitWindowSec,
Limits: lim,
FilterWords: words,
FilterCount: services.CountFilterWords(words),
Mail: mail,
@@ -624,6 +626,51 @@ func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
ctx.Redirect("/admin/settings")
}
// AdminSettingsContentLimitsPost 字数与编辑窗等
func (d Deps) AdminSettingsContentLimitsPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
atoi := func(name string) int {
n, _ := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
return n
}
err := d.Settings.UpdateContentLimits(
atoi("post_edit_window_hours"), atoi("comment_edit_window_minutes"),
atoi("post_title_max"), atoi("post_tags_max"), atoi("post_content_max"), atoi("comment_max"),
atoi("search_keyword_min"), atoi("search_keyword_max"), atoi("page_size_default"),
atoi("password_min_len"), atoi("avatar_max_mb"), atoi("signature_max"),
c.PostForm("open_posts_in_new_tab") == "1" || c.PostForm("open_posts_in_new_tab") == "on",
c.PostForm("open_content_links_in_new_tab") == "1" || c.PostForm("open_content_links_in_new_tab") == "on",
strings.TrimSpace(c.PostForm("feed_list_style")),
)
if err != nil {
d.renderAdminSettings(ctx, "内容限制无效,请检查数值范围")
return
}
ctx.SetFlash("内容限制已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsPermalinkPost 伪静态
func (d Deps) AdminSettingsPermalinkPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderAdminSettings(ctx, "无效请求,请重试")
return
}
enabled := c.PostForm("permalink_enabled") == "1" || c.PostForm("permalink_enabled") == "on"
ext := strings.TrimSpace(c.PostForm("permalink_ext"))
if err := d.Settings.UpdatePermalinkSettings(enabled, ext); err != nil {
d.renderAdminSettings(ctx, "伪静态后缀无效116 位字母数字)")
return
}
ctx.SetFlash("伪静态设置已保存")
ctx.Redirect("/admin/settings")
}
// AdminSettingsFilterWordsPost 敏感词
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
ctx := d.ctx(c)

View File

@@ -10,6 +10,7 @@ import (
type boardsItemView struct {
ID uint
Name string
Href string
Description string
Icon string
ColorIndex int
@@ -32,6 +33,7 @@ func (d Deps) renderBoards(ctx *webctx.Context) {
chrome := d.chrome(ctx, "板块 · "+brand.Name, "", "")
list, _ := d.Board.ListWithStats()
items := make([]boardsItemView, 0, len(list))
pl := d.permalink()
for _, b := range list {
color := b.ColorIndex
if color < 0 {
@@ -40,6 +42,7 @@ func (d Deps) renderBoards(ctx *webctx.Context) {
items = append(items, boardsItemView{
ID: b.ID,
Name: b.Name,
Href: pl.BoardPath(b.ID),
Description: b.Description,
Icon: b.Icon,
ColorIndex: color,

View File

@@ -1,6 +1,7 @@
package web
import (
"net/http"
"strings"
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
@@ -44,12 +45,14 @@ type SitePageLink struct {
type BoardView struct {
ID uint
Name string
Href string
}
// RightAsideHotPost 右栏热门帖
type RightAsideHotPost struct {
ID uint
Title string
Href string
}
// RightAsideTag 右栏标签
@@ -61,6 +64,7 @@ type RightAsideTag struct {
// RightAsideComment 右栏最新评论
type RightAsideComment struct {
PostID uint
PostHref string
Author string
Excerpt string
PostTitle string
@@ -70,6 +74,7 @@ type RightAsideComment struct {
type RightAsideUser struct {
ID uint
Nickname string
Href string
}
// RightAsideFriendLink 右栏友链
@@ -111,6 +116,8 @@ type PageChrome struct {
LogoURL string
FaviconURL string
OGImageURL string
PermalinkSuffix string // 如 ".html";未启用为空
OpenPostsInNewTab bool
LoggedIn bool
IsAdmin bool
ViewerName string
@@ -151,8 +158,9 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
}
boards, _ := d.Board.List()
bv := make([]BoardView, 0, len(boards))
pl := d.permalink()
for _, b := range boards {
bv = append(bv, BoardView{ID: b.ID, Name: b.Name})
bv = append(bv, BoardView{ID: b.ID, Name: b.Name, Href: pl.BoardPath(b.ID)})
}
var unread int64
if ctx.IsSigned() && d.Message != nil {
@@ -176,6 +184,8 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
LogoURL: strings.TrimSpace(brand.Logo),
FaviconURL: strings.TrimSpace(brand.Favicon),
OGImageURL: strings.TrimSpace(brand.DefaultShareImage()),
PermalinkSuffix: d.Settings.Permalink().Suffix(),
OpenPostsInNewTab: d.Settings.OpenPostsInNewTab(),
LoggedIn: ctx.IsSigned(),
IsAdmin: ctx.IsAdmin(),
ViewerName: name,
@@ -242,8 +252,9 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
}
if d.Post != nil {
if items, err := d.Post.HotPosts(rightAsideHotLimit); err == nil {
pl := d.permalink()
for _, it := range items {
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title})
out.HotPosts = append(out.HotPosts, RightAsideHotPost{ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID)})
}
}
}
@@ -264,9 +275,10 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
case services.AsideWidgetRecentComments:
if d.Comment != nil {
if list, err := d.Comment.ListRecentPublic(rightAsideCommentLimit); err == nil {
pl := d.permalink()
for _, c := range list {
block.Comments = append(block.Comments, RightAsideComment{
PostID: c.PostID, Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
PostID: c.PostID, PostHref: pl.PostPath(c.PostID), Author: c.Author, Excerpt: c.Excerpt, PostTitle: c.PostTitle,
})
}
}
@@ -274,8 +286,9 @@ func (d Deps) loadRightAside(ctx *webctx.Context, brand services.SiteBranding) R
case services.AsideWidgetRecentUsers:
if d.User != nil {
if list, err := d.User.ListRecentRegistered(rightAsideUserLimit); err == nil {
pl := d.permalink()
for _, u := range list {
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname})
block.Users = append(block.Users, RightAsideUser{ID: u.ID, Nickname: u.Nickname, Href: pl.UserPath(u.ID)})
}
}
}
@@ -311,3 +324,39 @@ func stripIDParam(raw, permalinkExt string) string {
raw = strings.TrimSuffix(raw, ".htm")
return raw
}
func (d Deps) permalink() services.PermalinkConfig {
if d.Settings == nil {
return services.PermalinkConfig{}
}
return d.Settings.Permalink()
}
func (d Deps) postHref(id uint) string {
return d.permalink().PostPath(id)
}
func (d Deps) boardHref(id uint) string {
return d.permalink().BoardPath(id)
}
func (d Deps) userHref(id uint) string {
return d.permalink().UserPath(id)
}
// redirectIfNotCanonical 伪静态开启时把无后缀/错误后缀 301 到规范路径
func (d Deps) redirectIfNotCanonical(c *gin.Context, match services.PermalinkMatch) bool {
if !match.OK || !d.permalink().Enabled {
return false
}
path := c.Request.URL.Path
if !match.NeedsCanonicalRedirect(path) {
return false
}
loc := match.Canonical
if q := c.Request.URL.RawQuery; q != "" {
loc = loc + "?" + q
}
c.Redirect(http.StatusMovedPermanently, loc)
return true
}

View File

@@ -106,7 +106,7 @@ func (d Deps) ComposePost(c *gin.Context) {
} else {
ctx.SetFlash("发帖成功")
}
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
ctx.Redirect(d.postHref(post.ID))
}
// PostEditGet 编辑帖
@@ -152,7 +152,7 @@ func (d Deps) PostEditPost(c *gin.Context) {
return
}
ctx.SetFlash("已保存")
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
ctx.Redirect(d.postHref(post.ID))
}
// ComposeUpload 帖图上传JSON供 compose 页 fetch

View File

@@ -11,6 +11,7 @@ import (
type favItem struct {
PostID uint
Title string
Href string
AuthorName string
BoardName string
CreatedLabel string
@@ -43,6 +44,7 @@ func (d Deps) FavoritesGet(c *gin.Context) {
return
}
items := make([]favItem, 0, len(favs))
pl := d.permalink()
for _, f := range favs {
title := f.Post.Title
author := strings.TrimSpace(f.Post.User.Nickname)
@@ -54,7 +56,7 @@ func (d Deps) FavoritesGet(c *gin.Context) {
board = f.Post.Board.Name
}
items = append(items, favItem{
PostID: f.PostID, Title: title, AuthorName: author, BoardName: board,
PostID: f.PostID, Title: title, Href: pl.PostPath(f.PostID), AuthorName: author, BoardName: board,
CreatedLabel: formatTime(f.CreatedAt),
})
}

View File

@@ -78,6 +78,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
admin.POST("/settings/brand/upload", deps.AdminSettingsBrandUploadPost)
admin.POST("/settings/brand/clear", deps.AdminSettingsBrandClearPost)
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
admin.POST("/settings/content-limits", deps.AdminSettingsContentLimitsPost)
admin.POST("/settings/permalink", deps.AdminSettingsPermalinkPost)
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
admin.POST("/settings/mail", deps.AdminSettingsMailPost)
admin.POST("/settings/mail/test", deps.AdminSettingsMailTestPost)
@@ -165,6 +167,7 @@ type HomePageData struct {
type PostListItem struct {
ID uint
Title string
Href string
AuthorName string
BoardName string
Pinned bool
@@ -176,22 +179,22 @@ type PostListItem struct {
// FormAction 搜索表单提交路径(当前 Feed
func (d HomePageData) FormAction() string {
if d.ActiveBoard > 0 {
return fmt.Sprintf("/board/%d", d.ActiveBoard)
return fmt.Sprintf("/board/%d%s", d.ActiveBoard, d.PermalinkSuffix)
}
return "/"
}
// SortHref 排序链接,保留搜索参数
func (d HomePageData) SortHref(sort string) string {
return buildFeedURL(d.ActiveBoard, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly)
return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, sort, 0, d.Keyword, d.Tag, d.Author, d.TitleOnly)
}
// PageHref 分页链接,保留搜索与排序
func (d HomePageData) PageHref(page int) string {
return buildFeedURL(d.ActiveBoard, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly)
return buildFeedURL(d.ActiveBoard, d.PermalinkSuffix, d.Sort, page, d.Keyword, d.Tag, d.Author, d.TitleOnly)
}
func buildFeedURL(boardID uint, sort string, page int, keyword, tag, author string, titleOnly bool) string {
func buildFeedURL(boardID uint, permalinkSuffix, sort string, page int, keyword, tag, author string, titleOnly bool) string {
q := url.Values{}
if sort != "" && sort != "latest" {
q.Set("sort", sort)
@@ -213,7 +216,7 @@ func buildFeedURL(boardID uint, sort string, page int, keyword, tag, author stri
}
path := "/"
if boardID > 0 {
path = fmt.Sprintf("/board/%d", boardID)
path = fmt.Sprintf("/board/%d%s", boardID, permalinkSuffix)
}
if enc := q.Encode(); enc != "" {
return path + "?" + enc
@@ -243,6 +246,12 @@ func (d Deps) Home(c *gin.Context) {
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
boardID = uint(n)
}
if boardID > 0 {
match := d.permalink().MatchBoardPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
}
}
chrome := d.chrome(ctx, "", "", "home/feed")
@@ -309,6 +318,7 @@ func (d Deps) Home(c *gin.Context) {
}
posts := make([]PostListItem, 0, len(items))
pl := d.permalink()
for _, it := range items {
authorName := strings.TrimSpace(it.User.Nickname)
if authorName == "" {
@@ -319,7 +329,7 @@ func (d Deps) Home(c *gin.Context) {
bname = it.Board.Name
}
posts = append(posts, PostListItem{
ID: it.ID, Title: it.Title, AuthorName: authorName, BoardName: bname,
ID: it.ID, Title: it.Title, Href: pl.PostPath(it.ID), AuthorName: authorName, BoardName: bname,
Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
})

View File

@@ -18,11 +18,14 @@ import (
type PostPageData struct {
PageChrome
PostID uint
PostHref string
PostPath string
PostTitle string
AuthorName string
AuthorID uint
AuthorHref string
BoardID uint
BoardHref string
BoardName string
Pinned bool // 展示用:全局或版内置顶
GlobalPinned bool
@@ -132,6 +135,10 @@ func (d Deps) PostView(c *gin.Context) {
d.render404(ctx)
return
}
match := d.permalink().MatchPostPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
post, err := d.Post.FindByID(uint(id))
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
d.render404(ctx)
@@ -260,9 +267,11 @@ func (d Deps) PostView(c *gin.Context) {
ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
PostHref: d.postHref(post.ID),
PostPath: url.QueryEscape(d.postHref(post.ID)),
PostTitle: post.Title, AuthorName: author, AuthorID: post.UserID,
BoardID: post.BoardID, BoardName: boardName,
AuthorHref: d.userHref(post.UserID),
BoardID: post.BoardID, BoardHref: d.boardHref(post.BoardID), BoardName: boardName,
Pinned: post.Pinned || post.BoardPinned, GlobalPinned: post.Pinned, BoardPinned: post.BoardPinned,
Featured: post.Featured, EditLocked: post.EditLocked,
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),

View File

@@ -42,7 +42,8 @@ type userPublicData struct {
// UserPublic 公开用户主页(不含邮箱)
func (d Deps) UserPublic(c *gin.Context) {
ctx := d.ctx(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
d.render404(ctx)
return
@@ -52,6 +53,10 @@ func (d Deps) UserPublic(c *gin.Context) {
d.render404(ctx)
return
}
match := d.permalink().MatchUserPath(c.Request.URL.Path)
if d.redirectIfNotCanonical(c, match) {
return
}
st, _ := d.User.ActivityStats(user.ID)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
@@ -67,13 +72,14 @@ func (d Deps) UserPublic(c *gin.Context) {
ViewerIsAdmin: ctx.IsAdmin(),
})
items := make([]PostListItem, 0, len(posts))
pl := d.permalink()
for _, p := range posts {
board := ""
if p.Board.ID > 0 {
board = p.Board.Name
}
items = append(items, PostListItem{
ID: p.ID, Title: p.Title, AuthorName: displayName(user),
ID: p.ID, Title: p.Title, Href: pl.PostPath(p.ID), AuthorName: displayName(user),
BoardName: board, Pinned: p.Pinned || p.BoardPinned, Featured: p.Featured,
CreatedLabel: formatTime(p.CreatedAt),
})