diff --git a/docs/rebuild-spec/02-features.md b/docs/rebuild-spec/02-features.md index 540f3ef..bea9162 100644 --- a/docs/rebuild-spec/02-features.md +++ b/docs/rebuild-spec/02-features.md @@ -22,7 +22,7 @@ - [x] 右栏:热门帖、标签云、最新评论、最新用户、友链(可开关排序)— SSR 热门固定 + `aside_widgets`;Admin `/admin/settings` 侧栏组件编辑器 - [x] 登录用户右栏/侧边:签到与抽奖入口 — SSR 右栏条(钱包详情仍在 `/profile#wallet`) - [ ] 下拉刷新(移动端) -- [ ] 可选伪静态:`/post/123.html` 等形式(后缀后台可配) +- [x] 可选伪静态:`/post/123.html` 等形式(后缀后台可配)— SSR `/admin/settings` 伪静态;公开路径 301 规范 URL - [x] 404 页 --- @@ -187,7 +187,8 @@ - [x] 仪表盘:用户/帖/板块计数 + 待审帖/评 + 待处理举报 — SSR `/admin/dashboard` - [x] 敏感词:`forum_settings.filter_words` 读写 + 热更 — SSR `/admin/settings` -- [x] 基础限流(post/comment/register/login/window)— SSR;完整 Limits 字数等未迁 +- [x] 基础限流(post/comment/register/login/window)— SSR `/admin/settings` +- [x] 完整 Limits(字数 / 编辑窗 / 分页 / 签名头像 / Feed 样式 / 新标签)— SSR `/admin/settings/content-limits` - [x] SMTP 配置与测试信 — SSR `/admin/settings` 邮件区 - [x] 站点品牌文案:名称、标语、简介、keywords、Logo 字标、ICP — SSR `/admin/settings` - [x] Logo / Favicon / 默认 OG 图上传与清除 — SSR `/admin/settings/brand/upload|clear`;`
` favicon + og:image diff --git a/docs/rebuild-spec/06-pages-ux.md b/docs/rebuild-spec/06-pages-ux.md index 5c7d3b3..309e907 100644 --- a/docs/rebuild-spec/06-pages-ux.md +++ b/docs/rebuild-spec/06-pages-ux.md @@ -48,7 +48,7 @@ | `/admin/dashboard` | 概览计数 | 已迁 | | `/admin/boards` | 板块 CRUD | 已迁 | | `/admin/moderation` | 待审帖/评 通过/拒绝 | 已迁 | -| `/admin/settings` | 品牌(含 Logo/Favicon/OG 上传)+ 基础限流 + 敏感词 + SMTP + 备份 | 已迁 | +| `/admin/settings` | 品牌(含 Logo/Favicon/OG)+ 限流 + 内容限制 + 伪静态 + 敏感词 + SMTP + 备份 | 已迁 | | `/admin/friend-links` | 品牌友链、申请审核、入口开关 | 已迁 | | `/admin/pages` | 站点单页 CRUD / 发布 | 已迁 | | `/admin/reports` | 举报处理 | 已迁 | @@ -58,7 +58,7 @@ | `/admin/trash` | 帖回收站 | 已迁 | | `/admin/login` | 重定向前台登录 | 已迁 | -未迁(原 SPA):完整 Limits / OIDC·Gitea·存储热切换 Admin 等。 +未迁(原 SPA):OIDC·Gitea·存储热切换 Admin 等。 --- @@ -200,7 +200,7 @@ | Badges | 定义自动/限定徽章;颁发/收回限定 — SSR `/admin/badges` | | Media | 分类浏览;单删/批量删;同步索引 — SSR `/admin/media` | | Media | 分类浏览;批量删 | -| Settings | Tab:论坛限制、侧栏组件、伪静态、邮件、OIDC+客户端、Gitea、存储、品牌、敏感词、备份 — SSR `/admin/settings` 已迁品牌(含 Logo/Favicon/OG)/限流/侧栏/敏感词/邮件/SQLite 备份(OIDC/Gitea/存储/伪静态未迁) | +| Settings | 品牌(含 Logo/Favicon/OG)、限流、内容限制、伪静态、侧栏、敏感词、邮件、SQLite 备份 — SSR `/admin/settings`(OIDC/Gitea/存储热切换未迁) | --- diff --git a/docs/rebuild-spec/09-ssr-progress.md b/docs/rebuild-spec/09-ssr-progress.md index abe3a03..606867f 100644 --- a/docs/rebuild-spec/09-ssr-progress.md +++ b/docs/rebuild-spec/09-ssr-progress.md @@ -3,8 +3,7 @@ > **读者**:产品方 / 实现 AI > **分支**:`rebuild/gitea-ssr`(`main` = SPA 对照) > **验收清单**: [02-features.md](02-features.md) -> **架构**: [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) -> **维护约定**:每次「继续」开新刀前更新本文「当前指针」;功能勾选以 `02` 为准,本文管阶段与刀序。 +> **架构**: [08-gitea-ssr-architecture.md](08-gitea-ssr-architecture.md) --- @@ -12,15 +11,15 @@ | 项 | 值 | |----|-----| -| **上一刀** | P0:发评限流 + 品牌 Logo/Favicon/OG 上传与 head meta | -| **下一刀** | **P3 / 后置**(需点名:伪静态、完整 Limits、OIDC、S3 热切换、编辑器、嵌套评论等) | +| **上一刀** | 完整 Limits + 伪静态 Admin / 规范 URL | +| **下一刀** | **后置或体验**(需点名:OIDC、S3 热切换、编辑器、嵌套评论、Gitea…) | | **工作区** | 应干净 | --- ## 一句话状态 -核心论坛、Admin P1、§H 成长/防刷、P0 品牌图与发评限流 **已齐**。余量主要是体验打磨与后置产品化。 +核心论坛、Admin、§H、品牌图、发评限流、**完整 Limits 与伪静态**已齐。余量多为后置产品化与体验打磨。 --- @@ -28,37 +27,22 @@ | 域 | 状态 | |----|------| -| 五种帖 + Admin users/badges/media | 已迁 | -| §H Exp/等级/徽章/防刷 | 已迁 | -| 发评 `RateLimiter` comment | 已挂 | -| 品牌 Logo/Favicon/OG 上传 + favicon/og meta | 已迁 | +| Admin users / badges / media | 已迁 | +| §H 等级 / 徽章 / 防刷 | 已迁 | +| 品牌 Logo/Favicon/OG + 发评限流 | 已迁 | +| 内容 Limits + 伪静态 | 已迁 | --- -## 未完成分层 +## 未完成 -### P0 余量 +### P3 体验 -- 头像裁剪等边角 - -### P3 — 体验 / 编辑器 - -- 主题、侧栏折叠、虚拟滚动、`feed_list_style`、伪静态 Admin -- TipTap / Markdown 双模 -- 游客评论、评论嵌套树 -- 修订 diff、完整 Limits 字数 +- 主题、侧栏折叠、虚拟滚动、TipTap、游客评论、嵌套树、修订 diff ### 后置 -- K:Gitea `/projects` -- L:OIDC 产品化 Admin CRUD -- M:S3 热切换、WebP 全链路产品化 - ---- - -## 默认「继续」 - -需点名下一刀;未点名时优先小清理或明确产品偏好。 +- OIDC Admin CRUD、S3 热切换 UI、Gitea `/projects`、WebP 产品化 --- @@ -68,4 +52,3 @@ |------|--------| | 某功能做没做? | [02-features.md](02-features.md) | | 现在该干什么? | 本文「当前指针」 | -| 路由迁没迁? | [06-pages-ux.md](06-pages-ux.md) §1 | diff --git a/routers/web/admin.go b/routers/web/admin.go index 3950398..fdbbf16 100644 --- a/routers/web/admin.go +++ b/routers/web/admin.go @@ -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, "伪静态后缀无效(1–16 位字母数字)") + return + } + ctx.SetFlash("伪静态设置已保存") + ctx.Redirect("/admin/settings") +} + // AdminSettingsFilterWordsPost 敏感词 func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) { ctx := d.ctx(c) diff --git a/routers/web/boards.go b/routers/web/boards.go index 70719a0..1a2cd56 100644 --- a/routers/web/boards.go +++ b/routers/web/boards.go @@ -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, diff --git a/routers/web/chrome.go b/routers/web/chrome.go index 39f3167..eb15307 100644 --- a/routers/web/chrome.go +++ b/routers/web/chrome.go @@ -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 +} diff --git a/routers/web/compose.go b/routers/web/compose.go index 614c0a8..1af0709 100644 --- a/routers/web/compose.go +++ b/routers/web/compose.go @@ -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) diff --git a/routers/web/favorites.go b/routers/web/favorites.go index f64ac09..62d3683 100644 --- a/routers/web/favorites.go +++ b/routers/web/favorites.go @@ -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), }) } diff --git a/routers/web/home.go b/routers/web/home.go index e5dfa2d..8f3b12b 100644 --- a/routers/web/home.go +++ b/routers/web/home.go @@ -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"), }) diff --git a/routers/web/post.go b/routers/web/post.go index ca72194..3f92072 100644 --- a/routers/web/post.go +++ b/routers/web/post.go @@ -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), diff --git a/routers/web/user.go b/routers/web/user.go index c3a1f5e..d537979 100644 --- a/routers/web/user.go +++ b/routers/web/user.go @@ -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), }) diff --git a/services/settings.go b/services/settings.go index 2d922f6..33e60da 100644 --- a/services/settings.go +++ b/services/settings.go @@ -614,6 +614,73 @@ func (s *ForumSettingsService) UpdateRateLimits(post, comment, register, login, return nil } +// UpdateContentLimits 更新字数/编辑窗/分页/账号相关限制(不改 aside / 伪静态 / 限流) +func (s *ForumSettingsService) UpdateContentLimits( + postEditHours, commentEditMinutes, + titleMax, tagsMax, contentMax, commentMax, + searchMin, searchMax, pageSize, + passwordMin, avatarMB, signatureMax int, + openPostsNewTab, openLinksNewTab bool, + feedListStyle string, +) error { + updates := map[string]int{ + SettingPostEditWindowHours: postEditHours, + SettingCommentEditWindowMinutes: commentEditMinutes, + SettingPostTitleMax: titleMax, + SettingPostTagsMax: tagsMax, + SettingPostContentMax: contentMax, + SettingCommentMax: commentMax, + SettingSearchKeywordMin: searchMin, + SettingSearchKeywordMax: searchMax, + SettingPageSizeDefault: pageSize, + SettingPasswordMinLen: passwordMin, + SettingAvatarMaxMB: avatarMB, + SettingSignatureMax: signatureMax, + } + if searchMax > 0 && searchMin > searchMax { + return ErrInvalidSetting + } + for key, val := range updates { + if err := s.setInt(key, val); err != nil { + return err + } + } + boolUpdates := map[string]bool{ + SettingOpenPostsInNewTab: openPostsNewTab, + SettingOpenContentLinksInNewTab: openLinksNewTab, + } + for key, on := range boolUpdates { + v := "0" + if on { + v = "1" + } + if err := s.setString(key, v); err != nil { + return err + } + } + style, ok := NormalizeFeedListStyle(feedListStyle) + if !ok { + return ErrInvalidSetting + } + return s.setString(SettingFeedListStyle, style) +} + +// UpdatePermalinkSettings 更新伪静态开关与后缀 +func (s *ForumSettingsService) UpdatePermalinkSettings(enabled bool, ext string) error { + extNorm, ok := NormalizePermalinkExt(ext) + if !ok { + return ErrInvalidSetting + } + v := "0" + if enabled { + v = "1" + } + if err := s.setString(SettingPermalinkEnabled, v); err != nil { + return err + } + return s.setString(SettingPermalinkExt, extNorm) +} + func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error { updates := map[string]int{ SettingPostEditWindowHours: in.PostEditWindowHours, diff --git a/templates/admin/settings.tmpl b/templates/admin/settings.tmpl index 4074b2b..8f2ae63 100644 --- a/templates/admin/settings.tmpl +++ b/templates/admin/settings.tmpl @@ -94,6 +94,43 @@ +字数、编辑时间窗、分页与账号相关上限。
+ + +启用后公开路径形如 /post/123.{{.Limits.PermalinkExt}};无后缀请求会 301 到规范 URL。
控制首页与帖详情右栏可选块的显示与顺序(热门帖为固定块,不在此列)。友链页「侧栏显示」与此共用同一配置。