feat: opaque session、安装/发帖 SSR 与最小 Admin 后台
浏览器登录改为 DB sessions(可吊销);敏感词与 OIDC PEM 入 settings; 落地安装向导、注册发帖与 /admin 仪表盘/板块/审核/设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -758,21 +758,9 @@ func (h *Handlers) APIProjects(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
|
||||
// APIAdminUpdateGiteaSettings Gitea 同步已后置
|
||||
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
|
||||
var req services.GiteaSyncConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Gitea 同步设置已保存",
|
||||
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||
})
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"})
|
||||
}
|
||||
|
||||
// APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换
|
||||
@@ -796,22 +784,9 @@ func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminSyncGitea 立即同步 Gitea 公开仓库
|
||||
// APIAdminSyncGitea Gitea 同步已后置
|
||||
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
|
||||
if h.Gitea == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": services.ErrGiteaNotConfigured.Error()})
|
||||
return
|
||||
}
|
||||
n, err := h.Gitea.SyncRepos()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n),
|
||||
"count": n,
|
||||
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||
})
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"error": "Gitea 仓库同步已后置,本版本不可用"})
|
||||
}
|
||||
|
||||
// APIAdminListOAuthClients 列出 OAuth 应用
|
||||
|
||||
@@ -42,8 +42,11 @@ type Handlers struct {
|
||||
FriendLinkApply *services.FriendLinkApplyService
|
||||
}
|
||||
|
||||
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
|
||||
c.SetCookie(auth.CookieName, token, int(services.TokenExpire.Seconds()), "/", "", false, true)
|
||||
func (h *Handlers) setAuthCookie(c *gin.Context, sessionID string) {
|
||||
if sessionID == "" {
|
||||
return
|
||||
}
|
||||
auth.SetSessionCookie(c, sessionID)
|
||||
}
|
||||
|
||||
func (h *Handlers) currentUserID(c *gin.Context) uint {
|
||||
@@ -126,13 +129,12 @@ func (h *Handlers) APICaptcha(c *gin.Context) {
|
||||
|
||||
// APIRegisterConfig 注册页所需公开配置
|
||||
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||
userCount := h.Auth.UserCount()
|
||||
mailReady := h.Settings.MailReady()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"is_first_user": userCount == 0,
|
||||
"is_first_user": false, // 已废弃:管理员仅由 /install 创建
|
||||
"mail_ready": mailReady,
|
||||
"require_email_code": mailReady,
|
||||
"register_open": userCount == 0 || mailReady,
|
||||
"register_open": mailReady,
|
||||
"email_code_len": services.EmailCodeLen,
|
||||
})
|
||||
}
|
||||
@@ -259,7 +261,7 @@ func (h *Handlers) APIRegister(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
h.setAuthCookie(c, token)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
|
||||
}
|
||||
@@ -273,7 +275,7 @@ func (h *Handlers) APILogin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/seo"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var (
|
||||
seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
|
||||
)
|
||||
|
||||
const (
|
||||
seoDescMax = 160
|
||||
seoPrerenderMax = 4000
|
||||
seoSitemapLimit = 5000
|
||||
)
|
||||
const seoSitemapLimit = 5000
|
||||
|
||||
// RobotsTxt 搜索引擎抓取规则
|
||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
@@ -34,14 +19,10 @@ func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
b.WriteString("Allow: /\n")
|
||||
b.WriteString("Disallow: /api/\n")
|
||||
b.WriteString("Disallow: /admin\n")
|
||||
b.WriteString("Disallow: /compose\n")
|
||||
b.WriteString("Disallow: /install\n")
|
||||
b.WriteString("Disallow: /login\n")
|
||||
b.WriteString("Disallow: /register\n")
|
||||
b.WriteString("Disallow: /profile\n")
|
||||
b.WriteString("Disallow: /favorites\n")
|
||||
b.WriteString("Disallow: /compose\n")
|
||||
b.WriteString("Disallow: /oauth/\n")
|
||||
b.WriteString("Disallow: /media/\n")
|
||||
b.WriteString("Disallow: /*/edit\n")
|
||||
if base != "" {
|
||||
b.WriteString("\nSitemap: ")
|
||||
b.WriteString(base)
|
||||
@@ -50,7 +31,7 @@ func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// SitemapXML 公开页面站点地图
|
||||
// SitemapXML 公开页面站点地图(与 SSR 同源路径)
|
||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
if base == "" {
|
||||
@@ -62,8 +43,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
permalink := h.Settings.Permalink()
|
||||
urls := []services.SitemapURL{
|
||||
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
|
||||
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
|
||||
{Loc: base + "/links", LastMod: now, ChangeFreq: "weekly", Priority: "0.6"},
|
||||
}
|
||||
|
||||
if boards, err := h.Board.List(); err == nil {
|
||||
@@ -92,38 +71,11 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if users, e2 := h.User.ListSitemap(seoSitemapLimit); e2 == nil {
|
||||
for _, u := range users {
|
||||
urls = append(urls, services.SitemapURL{
|
||||
Loc: base + permalink.UserPath(u.ID),
|
||||
LastMod: u.UpdatedAt.UTC(),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.5",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if pages, e3 := h.SitePage.ListSitemap(seoSitemapLimit); e3 == nil {
|
||||
for _, p := range pages {
|
||||
lm := p.UpdatedAt
|
||||
if lm.IsZero() {
|
||||
lm = p.CreatedAt
|
||||
}
|
||||
urls = append(urls, services.SitemapURL{
|
||||
Loc: base + permalink.PagePath(p.Slug),
|
||||
LastMod: lm.UTC(),
|
||||
ChangeFreq: "monthly",
|
||||
Priority: "0.5",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
|
||||
for _, u := range urls {
|
||||
b.WriteString("<url>")
|
||||
b.WriteString("<loc>")
|
||||
b.WriteString("<url><loc>")
|
||||
b.WriteString(xmlEscape(u.Loc))
|
||||
b.WriteString("</loc>")
|
||||
if !u.LastMod.IsZero() {
|
||||
@@ -147,382 +99,6 @@ func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// ServePublicSPA 公开页入口:
|
||||
// - 普通用户:干净 SPA + <head> meta(无正文预渲染,避免刷新闪屏)
|
||||
// - 搜索/社交爬虫:服务端 HTML(动态渲染)
|
||||
// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301
|
||||
func (h *Handlers) ServePublicSPA(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
brand := h.Settings.SiteBranding()
|
||||
base := h.publicBaseURL(c)
|
||||
siteName := strings.TrimSpace(brand.Name)
|
||||
if siteName == "" {
|
||||
siteName = "姜十三论坛"
|
||||
}
|
||||
defaultImage := services.AbsoluteURL(base, brand.DefaultShareImage())
|
||||
siteKeywords := brand.MetaKeywords()
|
||||
permalink := h.Settings.Permalink()
|
||||
|
||||
// 旧版 /?board=id → 规范板块路径
|
||||
if path == "/" || path == "" {
|
||||
if boardID, err := strconv.ParseUint(c.Query("board"), 10, 64); err == nil && boardID > 0 {
|
||||
target := services.QueryBoardHome(uint(boardID), permalink)
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
// 保留 sort/keyword 等 query,去掉 board
|
||||
vals := c.Request.URL.Query()
|
||||
vals.Del("board")
|
||||
if rest := vals.Encode(); rest != "" {
|
||||
target += "?" + rest
|
||||
}
|
||||
}
|
||||
c.Redirect(http.StatusMovedPermanently, target)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isBot := services.IsSEOCrawler(c.Request.UserAgent())
|
||||
if isBot {
|
||||
c.Header("Vary", "User-Agent")
|
||||
}
|
||||
|
||||
// 板块首页(含可选伪静态后缀)
|
||||
if bm := permalink.MatchBoardPath(path); bm.OK {
|
||||
if bm.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, bm.Canonical+preserveQueryExceptBoard(c))
|
||||
return
|
||||
}
|
||||
board, err := h.Board.GetByID(bm.ID)
|
||||
if err != nil {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
meta := attachSiteSEO(&seo.PageMeta{
|
||||
Title: pageTitle(board.Name, siteName),
|
||||
Description: services.TruncateRunes(desc, seoDescMax),
|
||||
Keywords: services.JoinSEOKeywords(board.Name, siteKeywords),
|
||||
Canonical: services.AbsoluteURL(base, bm.Canonical),
|
||||
OGType: "website",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botBoardHTML(meta, *board)))
|
||||
return
|
||||
}
|
||||
servePendingSSR(c, meta.Title, `<p>板块页 SSR 迁移中,请先从 <a href="/">首页</a> 浏览。</p>`)
|
||||
return
|
||||
}
|
||||
|
||||
// 帖子详情(含可选伪静态后缀)
|
||||
if pm := permalink.MatchPostPath(path); pm.OK {
|
||||
if pm.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, pm.Canonical)
|
||||
return
|
||||
}
|
||||
post, err := h.Post.FindByID(pm.ID)
|
||||
if err != nil || !services.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
postKeywords := services.JoinSEOKeywords(post.Board.Name, siteKeywords)
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
|
||||
return
|
||||
}
|
||||
servePendingSSR(c, pageTitle(post.Title, siteName), `<p>帖子详情 SSR 迁移中,请先从 <a href="/">首页</a> 返回。</p>`)
|
||||
return
|
||||
}
|
||||
|
||||
// 用户主页
|
||||
if um := permalink.MatchUserPath(path); um.OK {
|
||||
if um.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, um.Canonical)
|
||||
return
|
||||
}
|
||||
user, err := h.User.GetByID(um.ID)
|
||||
if err != nil || user.Banned {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user)))
|
||||
return
|
||||
}
|
||||
servePendingSSR(c, pageTitle(user.Nickname, siteName), `<p>用户主页 SSR 迁移中,请先从 <a href="/">首页</a> 返回。</p>`)
|
||||
return
|
||||
}
|
||||
|
||||
// 自定义单页
|
||||
if pg := permalink.MatchPagePath(path); pg.OK {
|
||||
if strings.TrimSuffix(path, "/") != strings.TrimSuffix(pg.Canonical, "/") {
|
||||
c.Redirect(http.StatusMovedPermanently, pg.Canonical)
|
||||
return
|
||||
}
|
||||
page, err := h.SitePage.GetBySlug(pg.Slug, h.isAdmin(c))
|
||||
if err != nil {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
desc := services.ExcerptFromHTML(page.Content, seoDescMax)
|
||||
meta := attachSiteSEO(&seo.PageMeta{
|
||||
Title: pageTitle(page.Title, siteName),
|
||||
Description: desc,
|
||||
Keywords: services.JoinSEOKeywords(page.Title, siteKeywords),
|
||||
Canonical: services.AbsoluteURL(base, pg.Canonical),
|
||||
OGType: "article",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
if isBot {
|
||||
body := fmt.Sprintf(`<h1>%s</h1><div>%s</div>`, html.EscapeString(page.Title), page.Content)
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(renderBotHTML(meta, body)))
|
||||
return
|
||||
}
|
||||
servePendingSSR(c, meta.Title, page.Content)
|
||||
return
|
||||
}
|
||||
|
||||
// 未知路径 → 404
|
||||
if !isKnownPublicPath(path) {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
|
||||
// 其余已知路由:爬虫可读首页;用户走占位页(首页本身已由 routers/web SSR)
|
||||
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
|
||||
if isBot && (path == "/" || path == "") {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
|
||||
return
|
||||
}
|
||||
servePendingSSR(c, meta.Title, `<p>该页面 SSR 迁移中。<a href="/">返回首页</a></p>`)
|
||||
}
|
||||
|
||||
func servePendingSSR(c *gin.Context, title, bodyHTML string) {
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "姜十三论坛"
|
||||
}
|
||||
page := fmt.Sprintf(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><title>%s</title><link rel="stylesheet" href="/ssr-assets/site.css"/></head><body class="j13-body"><main class="j13-main" style="max-width:800px;margin:2rem auto;padding:1rem">%s</main></body></html>`,
|
||||
html.EscapeString(title), bodyHTML)
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
|
||||
}
|
||||
|
||||
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) {
|
||||
if isBot {
|
||||
c.Header("Vary", "User-Agent")
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path)))
|
||||
return
|
||||
}
|
||||
page := fmt.Sprintf(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>%s</title></head><body><h1>404</h1><p>页面不存在。</p><p><a href="/">返回首页</a></p></body></html>`,
|
||||
html.EscapeString(pageTitle("页面不存在", siteName)))
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(page))
|
||||
}
|
||||
|
||||
func notFoundPageMeta(base, siteName, keywords, path string) *seo.PageMeta {
|
||||
return attachSiteSEO(&seo.PageMeta{
|
||||
Title: pageTitle("页面不存在", siteName),
|
||||
Description: "您访问的页面不存在或已删除",
|
||||
Canonical: services.AbsoluteURL(base, path),
|
||||
OGType: "website",
|
||||
Robots: "noindex,follow",
|
||||
Status: http.StatusNotFound,
|
||||
}, siteName, keywords)
|
||||
}
|
||||
|
||||
// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale
|
||||
func attachSiteSEO(meta *seo.PageMeta, siteName, keywords string) *seo.PageMeta {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
meta.SiteName = strings.TrimSpace(siteName)
|
||||
if strings.TrimSpace(meta.Keywords) == "" {
|
||||
meta.Keywords = strings.TrimSpace(keywords)
|
||||
}
|
||||
meta.Locale = "zh_CN"
|
||||
return meta
|
||||
}
|
||||
|
||||
func isKnownPublicPath(path string) bool {
|
||||
switch path {
|
||||
case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/links", "/boards":
|
||||
return true
|
||||
}
|
||||
if seoPostEditRe.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
permalink := services.PermalinkConfig{}
|
||||
if permalink.MatchBoardPath(path).OK {
|
||||
return true
|
||||
}
|
||||
if permalink.MatchPagePath(path).OK {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand services.SiteBranding, base, siteName, defaultImage string) *seo.PageMeta {
|
||||
siteTitle := brand.DocumentTitle()
|
||||
homeDesc := services.TruncateRunes(brand.MetaDescription(), seoDescMax)
|
||||
siteKeywords := brand.MetaKeywords()
|
||||
meta := attachSiteSEO(&seo.PageMeta{
|
||||
Title: siteTitle,
|
||||
Description: homeDesc,
|
||||
Keywords: siteKeywords,
|
||||
Canonical: services.AbsoluteURL(base, pathWithQuery(c)),
|
||||
OGType: "website",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
|
||||
if isNoIndexPath(path) {
|
||||
meta.Robots = "noindex,nofollow"
|
||||
meta.Title = pageTitle(pathLabel(path), siteName)
|
||||
return meta
|
||||
}
|
||||
|
||||
if path == "/" || path == "" {
|
||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
||||
if boardID > 0 {
|
||||
if board, err := h.Board.GetByID(uint(boardID)); err == nil {
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
meta.Title = pageTitle(board.Name, siteName)
|
||||
meta.Description = services.TruncateRunes(desc, seoDescMax)
|
||||
meta.Canonical = services.AbsoluteURL(base, services.QueryBoardHome(board.ID, h.Settings.Permalink()))
|
||||
meta.Keywords = services.JoinSEOKeywords(board.Name, siteKeywords)
|
||||
return meta
|
||||
}
|
||||
// 无效板块 id:仍显示首页,但可标记 noindex
|
||||
meta.Robots = "noindex,follow"
|
||||
return meta
|
||||
}
|
||||
meta.JSONLD = mustJSON(map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": siteName,
|
||||
"description": meta.Description,
|
||||
"url": services.AbsoluteURL(base, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
if path == "/projects" {
|
||||
meta.Title = pageTitle("项目", siteName)
|
||||
meta.Description = services.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
|
||||
meta.Keywords = services.JoinSEOKeywords("项目", siteKeywords)
|
||||
}
|
||||
|
||||
if path == "/links" {
|
||||
meta.Title = pageTitle("友情链接", siteName)
|
||||
meta.Description = services.TruncateRunes(siteName+" 的友情链接与申请入口", seoDescMax)
|
||||
meta.Keywords = services.JoinSEOKeywords("友情链接", siteKeywords)
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *models.Post) *seo.PageMeta {
|
||||
permalink := h.Settings.Permalink()
|
||||
content := services.RedactGatedPostHTML(post.Content)
|
||||
plain := post.ContentPlain
|
||||
if plain == "" {
|
||||
plain = services.StripHTMLForSearch(content)
|
||||
}
|
||||
desc := services.TruncateRunes(plain, seoDescMax)
|
||||
author := services.DisplayName(&post.User)
|
||||
canonical := services.AbsoluteURL(base, permalink.PostPath(post.ID))
|
||||
ogImage := services.AbsoluteURL(base, services.FirstImageURL(content))
|
||||
if ogImage == "" {
|
||||
ogImage = services.AbsoluteURL(base, post.User.Avatar)
|
||||
}
|
||||
if ogImage == "" {
|
||||
ogImage = defaultImage
|
||||
}
|
||||
|
||||
jsonld := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
"headline": post.Title,
|
||||
"description": desc,
|
||||
"datePublished": post.CreatedAt.UTC().Format(time.RFC3339),
|
||||
"dateModified": post.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
"url": canonical,
|
||||
"mainEntityOfPage": canonical,
|
||||
"author": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": author,
|
||||
"url": services.AbsoluteURL(base, permalink.UserPath(post.UserID)),
|
||||
},
|
||||
"interactionStatistic": map[string]any{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/ViewAction",
|
||||
"userInteractionCount": post.ViewCount,
|
||||
},
|
||||
}
|
||||
if post.Board.Name != "" {
|
||||
jsonld["articleSection"] = post.Board.Name
|
||||
}
|
||||
if ogImage != "" {
|
||||
jsonld["image"] = []string{ogImage}
|
||||
}
|
||||
body := services.TruncateRunes(plain, seoPrerenderMax)
|
||||
if body != "" {
|
||||
jsonld["articleBody"] = body
|
||||
}
|
||||
|
||||
return &seo.PageMeta{
|
||||
Title: pageTitle(post.Title, siteName),
|
||||
Description: desc,
|
||||
Canonical: canonical,
|
||||
OGType: "article",
|
||||
OGImage: ogImage,
|
||||
JSONLD: mustJSON(jsonld),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *models.User) *seo.PageMeta {
|
||||
permalink := h.Settings.Permalink()
|
||||
name := services.DisplayName(user)
|
||||
desc := strings.TrimSpace(user.Signature)
|
||||
if desc == "" {
|
||||
desc = name + " 的主页"
|
||||
}
|
||||
desc = services.TruncateRunes(desc, seoDescMax)
|
||||
canonical := services.AbsoluteURL(base, permalink.UserPath(user.ID))
|
||||
ogImage := services.AbsoluteURL(base, user.Avatar)
|
||||
if ogImage == "" {
|
||||
ogImage = defaultImage
|
||||
}
|
||||
|
||||
jsonld := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"url": canonical,
|
||||
"mainEntity": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"url": canonical,
|
||||
},
|
||||
}
|
||||
if ogImage != "" {
|
||||
jsonld["mainEntity"].(map[string]any)["image"] = ogImage
|
||||
}
|
||||
|
||||
return &seo.PageMeta{
|
||||
Title: pageTitle(name+" 的主页", siteName),
|
||||
Description: desc,
|
||||
Canonical: canonical,
|
||||
OGType: "profile",
|
||||
OGImage: ogImage,
|
||||
JSONLD: mustJSON(jsonld),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) publicBaseURL(c *gin.Context) string {
|
||||
return h.Settings.SitePublicBaseURL(requestOrigin(c))
|
||||
}
|
||||
@@ -546,105 +122,11 @@ func requestOrigin(c *gin.Context) string {
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func pathWithQuery(c *gin.Context) string {
|
||||
path := c.Request.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
permalink := services.PermalinkConfig{} // 由调用方在 buildSPAPageMeta 中单独处理 board
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
if path == "/" {
|
||||
board := c.Query("board")
|
||||
if board != "" {
|
||||
_ = permalink
|
||||
return services.LegacyQueryBoardHome(uint(parseUintOrZero(board)))
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
return path + "?" + q
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func preserveQueryExceptBoard(c *gin.Context) string {
|
||||
vals := c.Request.URL.Query()
|
||||
vals.Del("board")
|
||||
if rest := vals.Encode(); rest != "" {
|
||||
return "?" + rest
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseUintOrZero(s string) uint64 {
|
||||
n, _ := strconv.ParseUint(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func isNoIndexPath(path string) bool {
|
||||
switch {
|
||||
case path == "/login", path == "/register", path == "/compose",
|
||||
path == "/profile", path == "/favorites":
|
||||
return true
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
return true
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pathLabel(path string) string {
|
||||
switch {
|
||||
case path == "/login":
|
||||
return "登录"
|
||||
case path == "/register":
|
||||
return "注册"
|
||||
case path == "/compose":
|
||||
return "发帖"
|
||||
case path == "/profile":
|
||||
return "个人中心"
|
||||
case path == "/favorites":
|
||||
return "我的收藏"
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return "编辑帖子"
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
return "管理后台"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func pageTitle(page, siteName string) string {
|
||||
page = strings.TrimSpace(page)
|
||||
siteName = strings.TrimSpace(siteName)
|
||||
switch {
|
||||
case page == "" && siteName == "":
|
||||
return "姜十三论坛"
|
||||
case page == "":
|
||||
return siteName
|
||||
case siteName == "":
|
||||
return page
|
||||
default:
|
||||
return page + " - " + siteName
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
`&`, "&",
|
||||
`<`, "<",
|
||||
`>`, ">",
|
||||
`"`, """,
|
||||
`'`, "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, "\"", """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/seo"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
)
|
||||
|
||||
// 爬虫专用伪静态 HTML(无 SPA;仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏)
|
||||
|
||||
func renderBotHTML(meta *seo.PageMeta, bodyInner string) string {
|
||||
if meta == nil {
|
||||
meta = &seo.PageMeta{}
|
||||
}
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
if ogType == "" {
|
||||
ogType = "website"
|
||||
}
|
||||
locale := strings.TrimSpace(meta.Locale)
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html><html lang=\"zh-CN\"><head>")
|
||||
b.WriteString("<meta charset=\"UTF-8\"/>")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>")
|
||||
writeEscapedTag(&b, "title", meta.Title)
|
||||
writeEscapedMeta(&b, "name", "description", meta.Description)
|
||||
writeEscapedMeta(&b, "name", "keywords", meta.Keywords)
|
||||
if meta.Robots != "" {
|
||||
writeEscapedMeta(&b, "name", "robots", meta.Robots)
|
||||
}
|
||||
if meta.Canonical != "" {
|
||||
b.WriteString(`<link rel="canonical" href="` + html.EscapeString(meta.Canonical) + `"/>`)
|
||||
}
|
||||
writeEscapedMeta(&b, "property", "og:type", ogType)
|
||||
writeEscapedMeta(&b, "property", "og:site_name", meta.SiteName)
|
||||
writeEscapedMeta(&b, "property", "og:locale", locale)
|
||||
writeEscapedMeta(&b, "property", "og:title", meta.Title)
|
||||
writeEscapedMeta(&b, "property", "og:description", meta.Description)
|
||||
writeEscapedMeta(&b, "property", "og:url", meta.Canonical)
|
||||
writeEscapedMeta(&b, "property", "og:image", meta.OGImage)
|
||||
card := "summary"
|
||||
if strings.TrimSpace(meta.OGImage) != "" {
|
||||
card = "summary_large_image"
|
||||
}
|
||||
writeEscapedMeta(&b, "name", "twitter:card", card)
|
||||
writeEscapedMeta(&b, "name", "twitter:title", meta.Title)
|
||||
writeEscapedMeta(&b, "name", "twitter:description", meta.Description)
|
||||
writeEscapedMeta(&b, "name", "twitter:image", meta.OGImage)
|
||||
if meta.JSONLD != "" {
|
||||
b.WriteString(`<script type="application/ld+json">`)
|
||||
b.WriteString(meta.JSONLD)
|
||||
b.WriteString(`</script>`)
|
||||
}
|
||||
b.WriteString(`<style>
|
||||
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:800px;margin:24px auto;padding:0 16px;color:#222}
|
||||
a{color:#2d6a4f}img{max-width:100%;height:auto}
|
||||
.meta{color:#666;font-size:14px;margin:8px 0 20px}
|
||||
.nav{margin:32px 0;font-size:14px}
|
||||
</style>`)
|
||||
b.WriteString("</head><body>")
|
||||
b.WriteString(bodyInner)
|
||||
b.WriteString(`<p class="nav"><a href="/">← 返回首页</a></p>`)
|
||||
b.WriteString("</body></html>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeEscapedTag(b *strings.Builder, tag, text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("<" + tag + ">" + html.EscapeString(text) + "</" + tag + ">")
|
||||
}
|
||||
|
||||
func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>")
|
||||
}
|
||||
|
||||
func (h *Handlers) botBoardHTML(meta *seo.PageMeta, board models.Board) string {
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = meta.Description
|
||||
}
|
||||
body := fmt.Sprintf(`<h1>%s</h1><p class="meta">%s</p>`,
|
||||
html.EscapeString(board.Name),
|
||||
html.EscapeString(desc),
|
||||
)
|
||||
return renderBotHTML(meta, body)
|
||||
}
|
||||
|
||||
func (h *Handlers) botHomeHTML(meta *seo.PageMeta, brand services.SiteBranding) string {
|
||||
name := strings.TrimSpace(brand.Name)
|
||||
if name == "" {
|
||||
name = "姜十三论坛"
|
||||
}
|
||||
intro := brand.MetaDescription()
|
||||
if intro == "" {
|
||||
intro = brand.Slogan
|
||||
}
|
||||
var body strings.Builder
|
||||
body.WriteString("<h1>" + html.EscapeString(name) + "</h1>")
|
||||
if intro != "" {
|
||||
body.WriteString("<p>" + html.EscapeString(intro) + "</p>")
|
||||
}
|
||||
body.WriteString(`<p><a href="/projects">浏览项目</a></p>`)
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *models.Post) string {
|
||||
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
|
||||
content := services.SanitizePostHTML(services.RedactGatedPostHTML(post.Content))
|
||||
author := services.DisplayName(&post.User)
|
||||
var body strings.Builder
|
||||
body.WriteString("<article>")
|
||||
body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>")
|
||||
body.WriteString(`<p class="meta">`)
|
||||
body.WriteString(html.EscapeString(author))
|
||||
body.WriteString(" · ")
|
||||
body.WriteString(html.EscapeString(post.CreatedAt.Local().Format("2006-01-02 15:04")))
|
||||
if post.Board.Name != "" {
|
||||
body.WriteString(" · ")
|
||||
body.WriteString(html.EscapeString(post.Board.Name))
|
||||
}
|
||||
body.WriteString("</p>")
|
||||
body.WriteString(content)
|
||||
body.WriteString("</article>")
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *models.User) string {
|
||||
meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords)
|
||||
name := services.DisplayName(user)
|
||||
sig := strings.TrimSpace(user.Signature)
|
||||
var body strings.Builder
|
||||
body.WriteString("<h1>" + html.EscapeString(name) + " 的主页</h1>")
|
||||
if sig != "" {
|
||||
body.WriteString("<p>" + html.EscapeString(sig) + "</p>")
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(`<p class="meta">加入于 %s</p>`, html.EscapeString(user.CreatedAt.Local().Format(time.DateOnly))))
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func botNotFoundHTML(base, siteName, keywords, path string) string {
|
||||
meta := notFoundPageMeta(base, siteName, keywords, path)
|
||||
body := `<h1>页面不存在</h1><p>您访问的页面不存在或已删除。</p>`
|
||||
return renderBotHTML(meta, body)
|
||||
}
|
||||
124
routers/install/install.go
Normal file
124
routers/install/install.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Deps 安装依赖
|
||||
type Deps struct {
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
Auth *services.AuthService
|
||||
Settings *services.ForumSettingsService
|
||||
}
|
||||
|
||||
type pageData struct {
|
||||
Title string
|
||||
Description string
|
||||
SiteName string
|
||||
Slogan string
|
||||
LogoMark string
|
||||
CSRF string
|
||||
Error string
|
||||
Flash string
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminNickname string
|
||||
}
|
||||
|
||||
// Register 未安装时的路由(仅 /install)
|
||||
func Register(r *gin.Engine, deps Deps) {
|
||||
r.GET("/install", deps.Get)
|
||||
r.POST("/install", deps.Post)
|
||||
}
|
||||
|
||||
// Get 安装页
|
||||
func (d Deps) Get(c *gin.Context) {
|
||||
if services.IsInstalled(d.DataDir) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
ctx := webctx.New(c, d.JWTSecret)
|
||||
brand := d.Settings.SiteBranding()
|
||||
name := brand.Name
|
||||
if name == "" {
|
||||
name = "姜十三论坛"
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "install", pageData{
|
||||
Title: "安装 · " + name, SiteName: name, LogoMark: "姜",
|
||||
CSRF: ctx.EnsureCSRF(), AdminUsername: "admin",
|
||||
})
|
||||
}
|
||||
|
||||
// Post 提交安装
|
||||
func (d Deps) Post(c *gin.Context) {
|
||||
if services.IsInstalled(d.DataDir) {
|
||||
c.Redirect(http.StatusSeeOther, "/")
|
||||
return
|
||||
}
|
||||
ctx := webctx.New(c, d.JWTSecret)
|
||||
brand := d.Settings.SiteBranding()
|
||||
siteName := strings.TrimSpace(c.PostForm("site_name"))
|
||||
if siteName == "" {
|
||||
siteName = "姜十三论坛"
|
||||
}
|
||||
data := pageData{
|
||||
Title: "安装 · " + siteName, SiteName: siteName, LogoMark: "姜",
|
||||
CSRF: ctx.EnsureCSRF(),
|
||||
AdminUsername: strings.TrimSpace(c.PostForm("admin_username")),
|
||||
AdminEmail: strings.TrimSpace(c.PostForm("admin_email")),
|
||||
AdminNickname: strings.TrimSpace(c.PostForm("admin_nickname")),
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
data.Error = "无效请求,请重试"
|
||||
ctx.HTML(http.StatusBadRequest, "install", data)
|
||||
return
|
||||
}
|
||||
pass := c.PostForm("admin_password")
|
||||
pass2 := c.PostForm("admin_password2")
|
||||
if pass != pass2 {
|
||||
data.Error = "两次密码不一致"
|
||||
ctx.HTML(http.StatusBadRequest, "install", data)
|
||||
return
|
||||
}
|
||||
if _, err := d.Auth.CreateAdmin(data.AdminUsername, pass, data.AdminNickname, data.AdminEmail); err != nil {
|
||||
data.Error = err.Error()
|
||||
ctx.HTML(http.StatusBadRequest, "install", data)
|
||||
return
|
||||
}
|
||||
b := d.Settings.SiteBranding()
|
||||
b.Name = siteName
|
||||
_ = d.Settings.UpdateSiteBranding(b)
|
||||
if err := services.WriteInstallLock(d.DataDir); err != nil {
|
||||
data.Error = "写入安装锁失败: " + err.Error()
|
||||
ctx.HTML(http.StatusInternalServerError, "install", data)
|
||||
return
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "post-install", pageData{
|
||||
Title: "安装完成", SiteName: siteName, LogoMark: "姜",
|
||||
})
|
||||
_ = brand
|
||||
}
|
||||
|
||||
// Guard 未安装则只允许 install / assets / health
|
||||
func Guard(dataDir string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if services.IsInstalled(dataDir) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
path := c.Request.URL.Path
|
||||
if path == "/install" || path == "/health" ||
|
||||
strings.HasPrefix(path, "/ssr-assets/") {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/install")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
220
routers/setup.go
220
routers/setup.go
@@ -11,6 +11,7 @@ import (
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
webpublic "git.iioio.com/freefire/jiang13-forum/public"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers/api"
|
||||
"git.iioio.com/freefire/jiang13-forum/routers/install"
|
||||
webpages "git.iioio.com/freefire/jiang13-forum/routers/web"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -22,7 +23,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// SSR 静态资源
|
||||
if err := services.EnsureInstallLockFromExistingData(cfg.DataDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 安装锁检查失败: %v\n", err)
|
||||
}
|
||||
|
||||
if sub, err := fs.Sub(webpublic.Assets, "assets"); err == nil {
|
||||
ssrFiles := http.StripPrefix("/ssr-assets", http.FileServer(http.FS(sub)))
|
||||
r.GET("/ssr-assets/*filepath", func(c *gin.Context) {
|
||||
@@ -30,15 +34,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
ssrFiles.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
if cfg.DevMode {
|
||||
fmt.Fprintf(os.Stderr, "[dev] SSR 请访问 http://localhost:%d (对照 SPA 请 checkout main)\n", cfg.Port)
|
||||
}
|
||||
|
||||
r.Use(install.Guard(cfg.DataDir))
|
||||
|
||||
filter := services.NewSensitiveFilter()
|
||||
_ = services.WriteDefaultFilterWords(cfg.FilterWordsPath())
|
||||
filter.LoadFromFile(cfg.FilterWordsPath())
|
||||
|
||||
settingsSvc := services.NewForumSettingsService()
|
||||
services.EnsureFilterWordsInSettings(settingsSvc, cfg.FilterWordsPath(), filter)
|
||||
|
||||
authSvc := services.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
||||
userSvc := services.NewUserService(filter, settingsSvc)
|
||||
boardSvc := services.NewBoardService()
|
||||
@@ -58,16 +60,14 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Gitea 仓库同步后置:本阶段不启动后台同步,亦不挂管理入口
|
||||
giteaSvc := services.NewGiteaService(settingsSvc)
|
||||
giteaSvc.StartBackgroundSync()
|
||||
|
||||
uploadStore := services.NewUploadStore(cfg.DataDir, settingsSvc)
|
||||
if err := uploadStore.ReloadFromSettings(settingsSvc); err != nil {
|
||||
// 配置不完整时保持本地磁盘,避免进程无法启动;管理员可在后台修正后热切换
|
||||
fmt.Fprintf(os.Stderr, "警告: 对象存储初始化失败,暂用本地磁盘: %v\n", err)
|
||||
_ = uploadStore.Apply(services.StorageConfig{Type: "local"})
|
||||
}
|
||||
// 后台同步存量文件到媒体索引,避免列表依赖实时扫盘
|
||||
go func() {
|
||||
if n, err := uploadStore.SyncMediaIndex(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "警告: 媒体索引同步失败: %v\n", err)
|
||||
@@ -89,25 +89,26 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
}
|
||||
authMW := auth.NewAuthMiddleware(authSvc)
|
||||
|
||||
// Gitea 式 SSR 公开页(优先于 SPA)
|
||||
install.Register(r, install.Deps{
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Auth: authSvc, Settings: settingsSvc,
|
||||
})
|
||||
|
||||
webpages.Register(r, webpages.Deps{
|
||||
Settings: settingsSvc,
|
||||
Board: boardSvc,
|
||||
Post: postSvc,
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Settings: settingsSvc, Auth: authSvc,
|
||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||
Message: messageSvc, Filter: filter,
|
||||
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
||||
}, authMW)
|
||||
|
||||
// 缩略图使用独立前缀,避免与 Static("/uploads/*filepath") 路由冲突
|
||||
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
|
||||
// 健康检查(容器 / 负载均衡探活)
|
||||
r.GET("/health", h.APIHealth)
|
||||
|
||||
// SEO:抓取规则与站点地图
|
||||
r.GET("/robots.txt", h.RobotsTxt)
|
||||
r.GET("/sitemap.xml", h.SitemapXML)
|
||||
|
||||
// OIDC Provider(Gitea 等外部站点 SSO)
|
||||
// OIDC Provider(外部机器 / Gitea SSO)
|
||||
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||
r.GET("/oauth/jwks", h.OIDCJWKS)
|
||||
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
|
||||
@@ -117,179 +118,12 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
r.GET("/oauth/logout", h.OIDCLogout)
|
||||
r.POST("/oauth/logout", h.OIDCLogout)
|
||||
|
||||
// 公开 JSON API(可选登录)
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth())
|
||||
{
|
||||
pubAPI.GET("/me", h.APIMe)
|
||||
pubAPI.GET("/boards", h.APIBoards)
|
||||
pubAPI.GET("/stats", h.APIStats)
|
||||
pubAPI.GET("/forum-limits", h.APIForumLimits)
|
||||
pubAPI.GET("/site-branding", h.APISiteBranding)
|
||||
pubAPI.GET("/pages", h.APIPages)
|
||||
pubAPI.GET("/pages/:slug", h.APIPageDetail)
|
||||
pubAPI.GET("/captcha", h.APICaptcha)
|
||||
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
||||
pubAPI.POST("/register/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
||||
pubAPI.POST("/password-reset/email-code", auth.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode)
|
||||
pubAPI.POST("/password-reset", auth.RateLimitMiddleware(limiter, "login"), h.APIResetPassword)
|
||||
pubAPI.GET("/posts", h.APIPosts)
|
||||
pubAPI.GET("/posts/hot", h.APIHotPosts)
|
||||
pubAPI.GET("/tags", h.APITags)
|
||||
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
||||
// search / recent 须在 :id 之前
|
||||
pubAPI.GET("/users/search", h.APISearchUsers)
|
||||
pubAPI.GET("/users/recent", h.APIRecentUsers)
|
||||
pubAPI.GET("/users/:id", h.APIUserPublic)
|
||||
pubAPI.GET("/posts/:id", h.APIPostDetail)
|
||||
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
|
||||
pubAPI.POST("/posts/:id/comments", auth.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
|
||||
pubAPI.GET("/projects", h.APIProjects)
|
||||
pubAPI.POST("/register", auth.RateLimitMiddleware(limiter, "register"), h.APIRegister)
|
||||
pubAPI.POST("/login", auth.RateLimitMiddleware(limiter, "login"), h.APILogin)
|
||||
}
|
||||
|
||||
// 需登录 API
|
||||
api := r.Group("/api", authMW.RequireAuth())
|
||||
{
|
||||
api.POST("/logout", h.APILogout)
|
||||
api.GET("/favorites", h.APIFavorites)
|
||||
api.GET("/profile/stats", h.APIProfileStats)
|
||||
api.POST("/profile/nickname", h.APIUpdateProfile)
|
||||
api.POST("/profile/signature", h.APIUpdateSignature)
|
||||
api.POST("/profile/password", h.APIUpdatePassword)
|
||||
api.POST("/profile/avatar", h.APIUploadAvatar)
|
||||
api.POST("/uploads/image", h.APIUploadPostImage)
|
||||
api.POST("/posts", auth.RateLimitMiddleware(limiter, "post"), h.APICreatePost)
|
||||
api.PUT("/posts/:id", h.APIUpdatePost)
|
||||
api.DELETE("/posts/:id", h.APIDeletePost)
|
||||
api.GET("/posts/:id/revisions", h.APIPostRevisions)
|
||||
api.GET("/posts/:id/revisions/:revId", h.APIPostRevisionDetail)
|
||||
api.POST("/posts/:id/like", h.APIToggleLike)
|
||||
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
||||
api.POST("/posts/:id/resolve", h.APISetQuestionResolved)
|
||||
api.POST("/posts/:id/poll/vote", h.APIPollVote)
|
||||
api.POST("/posts/:id/poll/close", h.APIPollClose)
|
||||
api.POST("/posts/:id/bounty/award", h.APIBountyAward)
|
||||
api.POST("/posts/:id/bounty/refund", h.APIBountyRefund)
|
||||
api.POST("/posts/:id/lottery/draw", h.APILotteryDraw)
|
||||
api.POST("/posts/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreatePostReport)
|
||||
api.GET("/messages/unread-count", h.APIMessageUnreadCount)
|
||||
api.GET("/messages/notifications", h.APIMessageNotifications)
|
||||
api.POST("/messages/notifications/read", h.APIMarkNotificationsRead)
|
||||
api.GET("/messages/conversations", h.APIMessageConversations)
|
||||
api.GET("/messages/conversations/:peerId", h.APIConversationMessages)
|
||||
api.POST("/messages/conversations/:peerId/read", h.APIMarkConversationRead)
|
||||
api.POST("/messages", auth.RateLimitMiddleware(limiter, "message"), h.APISendMessage)
|
||||
api.POST("/messages/read-all", h.APIMarkAllMessagesRead)
|
||||
api.POST("/comments/:id/like", h.APIToggleCommentLike)
|
||||
api.POST("/comments/:id/report", auth.RateLimitMiddleware(limiter, "report"), h.APICreateCommentReport)
|
||||
api.DELETE("/comments/:id", h.APIDeleteComment)
|
||||
api.PUT("/comments/:id", h.APIUpdateComment)
|
||||
api.GET("/me/points", h.APIMePoints)
|
||||
api.GET("/me/check-in", h.APIMeCheckInGet)
|
||||
api.POST("/me/check-in", h.APIMeCheckIn)
|
||||
api.GET("/me/lottery", h.APIMeLotteryGet)
|
||||
api.POST("/me/lottery", h.APIMeLotteryDraw)
|
||||
api.POST("/posts/:id/unlock", auth.RateLimitMiddleware(limiter, "post"), h.APIUnlockPostBlock)
|
||||
api.POST("/friend-links/apply", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIApplyFriendLink)
|
||||
api.POST("/friend-links/logo", auth.RateLimitMiddleware(limiter, "post"), h.APIUploadFriendLinkLogo)
|
||||
api.GET("/friend-links/my-applies", h.APIMyFriendLinkApplies)
|
||||
api.PUT("/friend-links/applies/:id", auth.RateLimitMiddleware(limiter, "friend_link"), h.APIUpdateFriendLinkApply)
|
||||
api.DELETE("/friend-links/applies/:id", h.APICancelFriendLinkApply)
|
||||
}
|
||||
|
||||
// 管理员 API(React SPA 后台统一使用 JSON)
|
||||
adminAPI := r.Group("/api/admin", authMW.RequireAuth(), authMW.RequireAdmin())
|
||||
{
|
||||
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
||||
adminAPI.GET("/settings", h.APIAdminSettings)
|
||||
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
|
||||
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
|
||||
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
|
||||
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
|
||||
adminAPI.PUT("/settings/gitea", h.APIAdminUpdateGiteaSettings)
|
||||
adminAPI.POST("/settings/gitea/sync", h.APIAdminSyncGitea)
|
||||
adminAPI.PUT("/settings/storage", h.APIAdminUpdateStorageSettings)
|
||||
adminAPI.PUT("/settings/branding", h.APIAdminUpdateBranding)
|
||||
adminAPI.POST("/settings/branding/upload", h.APIAdminUploadBrandingAsset)
|
||||
adminAPI.POST("/settings/branding/clear", h.APIAdminClearBrandingAsset)
|
||||
adminAPI.GET("/oauth/clients", h.APIAdminListOAuthClients)
|
||||
adminAPI.POST("/oauth/clients", h.APIAdminCreateOAuthClient)
|
||||
adminAPI.PUT("/oauth/clients/:id", h.APIAdminUpdateOAuthClient)
|
||||
adminAPI.DELETE("/oauth/clients/:id", h.APIAdminDeleteOAuthClient)
|
||||
adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
|
||||
adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
|
||||
adminAPI.POST("/boards", h.APIAdminCreateBoard)
|
||||
adminAPI.PUT("/boards/:id", h.APIAdminUpdateBoard)
|
||||
adminAPI.DELETE("/boards/:id", h.APIAdminDeleteBoard)
|
||||
adminAPI.GET("/pages", h.APIAdminPages)
|
||||
adminAPI.GET("/pages/:id", h.APIAdminGetPage)
|
||||
adminAPI.POST("/pages", h.APIAdminCreatePage)
|
||||
adminAPI.PUT("/pages/:id", h.APIAdminUpdatePage)
|
||||
adminAPI.PUT("/pages/:id/published", h.APIAdminSetPagePublished)
|
||||
adminAPI.DELETE("/pages/:id", h.APIAdminDeletePage)
|
||||
adminAPI.GET("/friend-link-applies", h.APIAdminFriendLinkApplies)
|
||||
adminAPI.PUT("/friend-link-settings", h.APIAdminUpdateFriendLinkSettings)
|
||||
adminAPI.POST("/friend-link-applies/:id/approve", h.APIAdminApproveFriendLinkApply)
|
||||
adminAPI.POST("/friend-link-applies/:id/reject", h.APIAdminRejectFriendLinkApply)
|
||||
adminAPI.POST("/friend-link-applies/:id/recheck", h.APIAdminRecheckFriendLinkApply)
|
||||
adminAPI.GET("/posts", h.APIAdminPosts)
|
||||
adminAPI.GET("/posts/trash", h.APIAdminTrashPosts)
|
||||
adminAPI.POST("/posts/:id/pin", h.APIAdminPinPost)
|
||||
adminAPI.POST("/posts/:id/board-pin", h.APIAdminBoardPinPost)
|
||||
adminAPI.POST("/posts/:id/feature", h.APIAdminFeaturePost)
|
||||
adminAPI.POST("/posts/:id/lock", h.APIAdminLockPost)
|
||||
adminAPI.POST("/posts/:id/comments-lock", h.APIAdminCommentsLockPost)
|
||||
adminAPI.POST("/posts/:id/approve", h.APIAdminApprovePost)
|
||||
adminAPI.POST("/posts/:id/reject", h.APIAdminRejectPost)
|
||||
adminAPI.POST("/posts/:id/restore", h.APIAdminRestorePost)
|
||||
adminAPI.DELETE("/posts/:id/purge", h.APIAdminPurgePost)
|
||||
adminAPI.DELETE("/posts/:id", h.APIAdminDeletePost)
|
||||
adminAPI.GET("/reports", h.APIAdminReports)
|
||||
adminAPI.POST("/reports/:id/handle", h.APIAdminHandleReport)
|
||||
adminAPI.GET("/comments", h.APIAdminComments)
|
||||
adminAPI.GET("/comments/trash", h.APIAdminTrashComments)
|
||||
adminAPI.GET("/comments/:id/revisions", h.APIAdminCommentRevisions)
|
||||
adminAPI.POST("/comments/:id/approve", h.APIAdminApproveComment)
|
||||
adminAPI.POST("/comments/:id/reject", h.APIAdminRejectComment)
|
||||
adminAPI.POST("/comments/:id/restore", h.APIAdminRestoreComment)
|
||||
adminAPI.DELETE("/comments/:id/purge", h.APIAdminPurgeComment)
|
||||
adminAPI.DELETE("/comments/:id", h.APIAdminDeleteComment)
|
||||
adminAPI.GET("/users", h.APIAdminUsers)
|
||||
adminAPI.POST("/users/:id/ban", h.APIAdminBanUser)
|
||||
adminAPI.POST("/users/:id/verify", h.APIAdminVerifyUser)
|
||||
adminAPI.POST("/users/:id/level", h.APIAdminSetUserLevel)
|
||||
adminAPI.POST("/users/:id/points", h.APIAdminAdjustPoints)
|
||||
adminAPI.POST("/users/:id/badges", h.APIAdminAwardBadge)
|
||||
adminAPI.GET("/badges", h.APIAdminListBadges)
|
||||
adminAPI.POST("/badges", h.APIAdminUpsertBadge)
|
||||
adminAPI.GET("/media", h.APIAdminMedia)
|
||||
adminAPI.POST("/media/delete", h.APIAdminDeleteMedia)
|
||||
adminAPI.POST("/backup", h.APIAdminBackup)
|
||||
adminAPI.GET("/backup/download/:name", h.APIAdminDownloadBackup)
|
||||
}
|
||||
|
||||
// 管理后台 HTML:SSR 尚未迁移;勿用 /*filepath(与 /admin/login 冲突)
|
||||
adminPendingHTML := `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"/><title>管理后台</title></head><body><h1>管理后台 SSR 迁移中</h1><p>API 仍可用;UI 请暂时对照 <code>main</code> 分支 SPA,或等待后续模板页。</p><p><a href="/">返回首页</a></p></body></html>`
|
||||
adminPending := func(c *gin.Context) {
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, adminPendingHTML)
|
||||
}
|
||||
admin := r.Group("/admin")
|
||||
{
|
||||
admin.GET("/login", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/login")
|
||||
})
|
||||
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
|
||||
{
|
||||
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
|
||||
adminAuth.GET("/dashboard", adminPending)
|
||||
adminAuth.GET("/:page", adminPending)
|
||||
}
|
||||
}
|
||||
|
||||
// 未迁移公开路径:爬虫可读 HTML / 用户占位(首页与板块已由 routers/web 接管)
|
||||
r.NoRoute(h.ServePublicSPA)
|
||||
// 精简机器 API:健康检查已注册;保留只读探测与 OIDC,论坛 UI 不再走 /api
|
||||
r.NoRoute(webpages.Deps{
|
||||
DataDir: cfg.DataDir, JWTSecret: cfg.JWTSecret,
|
||||
Settings: settingsSvc, Auth: authSvc,
|
||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||
}.NotFound)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
500
routers/web/admin.go
Normal file
500
routers/web/admin.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// AdminChrome 后台布局公共字段
|
||||
type AdminChrome struct {
|
||||
PageChrome
|
||||
NavActive string
|
||||
}
|
||||
|
||||
func (d Deps) adminChrome(ctx *webctx.Context, title, nav string) AdminChrome {
|
||||
site := d.Settings.SiteBranding().Name
|
||||
if title == "" {
|
||||
title = "管理后台 · " + site
|
||||
} else {
|
||||
title = title + " · " + site
|
||||
}
|
||||
base := d.chrome(ctx, title, "", "")
|
||||
return AdminChrome{PageChrome: base, NavActive: nav}
|
||||
}
|
||||
|
||||
type adminDashData struct {
|
||||
AdminChrome
|
||||
UserCount int64
|
||||
PostCount int64
|
||||
PendingPosts int64
|
||||
PendingComments int64
|
||||
BoardCount int64
|
||||
}
|
||||
|
||||
// AdminDashboard 概览
|
||||
func (d Deps) AdminDashboard(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
var users, posts, boards int64
|
||||
_ = models.DB.Model(&models.User{}).Count(&users).Error
|
||||
_ = models.DB.Model(&models.Post{}).Where("status = ?", models.ContentStatusPublished).Count(&posts).Error
|
||||
_ = models.DB.Model(&models.Board{}).Count(&boards).Error
|
||||
pendingPosts, _ := d.Post.PendingPostCount()
|
||||
pendingComments, _ := d.Comment.PendingCommentCount()
|
||||
ctx.HTML(http.StatusOK, "admin/dashboard", adminDashData{
|
||||
AdminChrome: d.adminChrome(ctx, "仪表盘", "dashboard"),
|
||||
UserCount: users,
|
||||
PostCount: posts,
|
||||
PendingPosts: pendingPosts,
|
||||
PendingComments: pendingComments,
|
||||
BoardCount: boards,
|
||||
})
|
||||
}
|
||||
|
||||
type adminBoardRow struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
SortOrder int
|
||||
PostCount int
|
||||
}
|
||||
|
||||
type adminBoardsData struct {
|
||||
AdminChrome
|
||||
Boards []adminBoardRow
|
||||
Form adminBoardForm
|
||||
}
|
||||
|
||||
type adminBoardForm struct {
|
||||
ID uint
|
||||
Name string
|
||||
Description string
|
||||
Icon string
|
||||
ColorIndex int
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// AdminBoardsGet 板块列表
|
||||
func (d Deps) AdminBoardsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminBoards(ctx, "", adminBoardForm{})
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminBoards(ctx *webctx.Context, errMsg string, form adminBoardForm) {
|
||||
list, _ := d.Board.ListWithStats()
|
||||
rows := make([]adminBoardRow, 0, len(list))
|
||||
for _, b := range list {
|
||||
rows = append(rows, adminBoardRow{
|
||||
ID: b.ID, Name: b.Name, Description: b.Description,
|
||||
Icon: b.Icon, ColorIndex: b.ColorIndex, SortOrder: b.SortOrder,
|
||||
PostCount: b.PostCount,
|
||||
})
|
||||
}
|
||||
data := adminBoardsData{
|
||||
AdminChrome: d.adminChrome(ctx, "板块", "boards"),
|
||||
Boards: rows,
|
||||
Form: form,
|
||||
}
|
||||
data.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "admin/boards", data)
|
||||
}
|
||||
|
||||
// AdminBoardCreate 新建板块
|
||||
func (d Deps) AdminBoardCreate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
|
||||
return
|
||||
}
|
||||
form := adminBoardFormFrom(c)
|
||||
if strings.TrimSpace(form.Name) == "" {
|
||||
d.renderAdminBoards(ctx, "请填写板块名称", form)
|
||||
return
|
||||
}
|
||||
if _, err := d.Board.Create(form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
|
||||
d.renderAdminBoards(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已创建")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
// AdminBoardUpdate 更新板块
|
||||
func (d Deps) AdminBoardUpdate(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminBoards(ctx, "无效请求,请重试", adminBoardFormFrom(c))
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
form := adminBoardFormFrom(c)
|
||||
form.ID = uint(id)
|
||||
if strings.TrimSpace(form.Name) == "" {
|
||||
d.renderAdminBoards(ctx, "请填写板块名称", form)
|
||||
return
|
||||
}
|
||||
if err := d.Board.Update(uint(id), form.Name, form.Description, form.Icon, form.ColorIndex, form.SortOrder); err != nil {
|
||||
d.renderAdminBoards(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已更新")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
// AdminBoardDelete 删除板块
|
||||
func (d Deps) AdminBoardDelete(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/boards")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Board.Delete(uint(id)); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/boards")
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("板块已删除")
|
||||
ctx.Redirect("/admin/boards")
|
||||
}
|
||||
|
||||
func adminBoardFormFrom(c *gin.Context) adminBoardForm {
|
||||
color, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
sort, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
return adminBoardForm{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Icon: strings.TrimSpace(c.PostForm("icon")),
|
||||
ColorIndex: color,
|
||||
SortOrder: sort,
|
||||
}
|
||||
}
|
||||
|
||||
type adminModPostRow struct {
|
||||
ID uint
|
||||
Title string
|
||||
AuthorName string
|
||||
BoardName string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminModCommentRow struct {
|
||||
ID uint
|
||||
PostID uint
|
||||
PostTitle string
|
||||
Floor int
|
||||
AuthorName string
|
||||
Excerpt string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type adminModData struct {
|
||||
AdminChrome
|
||||
Posts []adminModPostRow
|
||||
Comments []adminModCommentRow
|
||||
}
|
||||
|
||||
// AdminModerationGet 待审帖/评
|
||||
func (d Deps) AdminModerationGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
posts, _, _ := d.Post.List(services.PostListQuery{
|
||||
Page: 1, Size: 50,
|
||||
ViewerIsAdmin: true,
|
||||
Status: models.ContentStatusPending,
|
||||
Sort: "latest",
|
||||
})
|
||||
postRows := make([]adminModPostRow, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
author := ""
|
||||
if p.User.ID > 0 {
|
||||
author = p.User.Nickname
|
||||
if author == "" {
|
||||
author = p.User.Username
|
||||
}
|
||||
}
|
||||
board := ""
|
||||
if p.Board.ID > 0 {
|
||||
board = p.Board.Name
|
||||
}
|
||||
postRows = append(postRows, adminModPostRow{
|
||||
ID: p.ID, Title: p.Title, AuthorName: author, BoardName: board,
|
||||
CreatedAt: p.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
comments, _, _ := d.Comment.ListPending(1, 50)
|
||||
commentRows := make([]adminModCommentRow, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
author := "游客"
|
||||
if cm.UserID > 0 {
|
||||
author = cm.User.Nickname
|
||||
if author == "" {
|
||||
author = cm.User.Username
|
||||
}
|
||||
} else if cm.GuestNick != "" {
|
||||
author = cm.GuestNick
|
||||
}
|
||||
excerpt := strings.TrimSpace(stripTagsRough(cm.Content))
|
||||
runes := []rune(excerpt)
|
||||
if len(runes) > 80 {
|
||||
excerpt = string(runes[:80]) + "…"
|
||||
}
|
||||
title := ""
|
||||
if cm.Post.ID > 0 {
|
||||
title = cm.Post.Title
|
||||
}
|
||||
commentRows = append(commentRows, adminModCommentRow{
|
||||
ID: cm.ID, PostID: cm.PostID, PostTitle: title, Floor: cm.Floor,
|
||||
AuthorName: author, Excerpt: excerpt,
|
||||
CreatedAt: cm.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "admin/moderation", adminModData{
|
||||
AdminChrome: d.adminChrome(ctx, "内容审核", "moderation"),
|
||||
Posts: postRows,
|
||||
Comments: commentRows,
|
||||
})
|
||||
}
|
||||
|
||||
func stripTagsRough(s string) string {
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r == '<':
|
||||
inTag = true
|
||||
case r == '>':
|
||||
inTag = false
|
||||
case !inTag:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// AdminPostApprove 通过帖子
|
||||
func (d Deps) AdminPostApprove(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Post.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
} else {
|
||||
ctx.SetFlash("帖子已通过")
|
||||
}
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminPostReject 拒绝帖子
|
||||
func (d Deps) AdminPostReject(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
if reason == "" {
|
||||
ctx.SetFlash("请填写拒绝原因")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
post, err := d.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if err := d.Post.SetStatus(post.ID, models.ContentStatusRejected); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
pid := post.ID
|
||||
if d.Message != nil {
|
||||
_, _ = d.Message.SendSystem(
|
||||
post.UserID,
|
||||
"帖子《"+post.Title+"》未通过审核",
|
||||
services.FormatRejectContent(post.Title, post.ID, reason),
|
||||
models.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
ctx.SetFlash("已拒绝该帖")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminCommentApprove 通过评论
|
||||
func (d Deps) AdminCommentApprove(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := d.Comment.SetStatus(uint(id), models.ContentStatusPublished); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
} else {
|
||||
ctx.SetFlash("评论已通过")
|
||||
}
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
// AdminCommentReject 拒绝评论
|
||||
func (d Deps) AdminCommentReject(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
reason := strings.TrimSpace(c.PostForm("reason"))
|
||||
if reason == "" {
|
||||
ctx.SetFlash("请填写拒绝原因")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
cm, err := d.Comment.GetByID(uint(id))
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if err := d.Comment.SetStatus(cm.ID, models.ContentStatusRejected); err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect("/admin/moderation")
|
||||
return
|
||||
}
|
||||
if d.Message != nil && cm.UserID > 0 {
|
||||
pid := cm.PostID
|
||||
title := cm.Post.Title
|
||||
_, _ = d.Message.SendSystem(
|
||||
cm.UserID,
|
||||
fmt.Sprintf("评论未通过审核(帖 #%d)", cm.PostID),
|
||||
services.FormatCommentRejectContent(title, cm.PostID, cm.Floor, reason),
|
||||
models.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
ctx.SetFlash("已拒绝该评论")
|
||||
ctx.Redirect("/admin/moderation")
|
||||
}
|
||||
|
||||
type adminSettingsData struct {
|
||||
AdminChrome
|
||||
Brand services.SiteBranding
|
||||
RatePost int
|
||||
RateComment int
|
||||
RateReg int
|
||||
RateLogin int
|
||||
RateWindow int
|
||||
FilterWords string
|
||||
FilterCount int
|
||||
}
|
||||
|
||||
// AdminSettingsGet 设置页
|
||||
func (d Deps) AdminSettingsGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
d.renderAdminSettings(ctx, "")
|
||||
}
|
||||
|
||||
func (d Deps) renderAdminSettings(ctx *webctx.Context, errMsg string) {
|
||||
words := d.Settings.FilterWordsContent()
|
||||
lim := d.Settings.Limits()
|
||||
data := adminSettingsData{
|
||||
AdminChrome: d.adminChrome(ctx, "站点设置", "settings"),
|
||||
Brand: d.Settings.SiteBranding(),
|
||||
RatePost: lim.RateLimitPost,
|
||||
RateComment: lim.RateLimitComment,
|
||||
RateReg: lim.RateLimitRegister,
|
||||
RateLogin: lim.RateLimitLogin,
|
||||
RateWindow: lim.RateLimitWindowSec,
|
||||
FilterWords: words,
|
||||
FilterCount: services.CountFilterWords(words),
|
||||
}
|
||||
data.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "admin/settings", data)
|
||||
}
|
||||
|
||||
// AdminSettingsBrandPost 品牌
|
||||
func (d Deps) AdminSettingsBrandPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
cur := d.Settings.SiteBranding()
|
||||
in := services.SiteBranding{
|
||||
Name: strings.TrimSpace(c.PostForm("name")),
|
||||
Slogan: strings.TrimSpace(c.PostForm("slogan")),
|
||||
Description: strings.TrimSpace(c.PostForm("description")),
|
||||
Keywords: strings.TrimSpace(c.PostForm("keywords")),
|
||||
LogoMark: strings.TrimSpace(c.PostForm("logo_mark")),
|
||||
Logo: cur.Logo,
|
||||
Favicon: cur.Favicon,
|
||||
OGImage: cur.OGImage,
|
||||
ICPBeian: strings.TrimSpace(c.PostForm("icp_beian")),
|
||||
ICPBeianURL: strings.TrimSpace(c.PostForm("icp_beian_url")),
|
||||
FriendLinks: cur.FriendLinks,
|
||||
}
|
||||
if err := d.Settings.UpdateSiteBranding(in); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("品牌设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsLimitsPost 限流
|
||||
func (d Deps) AdminSettingsLimitsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
postN, _ := strconv.Atoi(c.PostForm("rate_limit_post"))
|
||||
commentN, _ := strconv.Atoi(c.PostForm("rate_limit_comment"))
|
||||
regN, _ := strconv.Atoi(c.PostForm("rate_limit_register"))
|
||||
loginN, _ := strconv.Atoi(c.PostForm("rate_limit_login"))
|
||||
windowN, _ := strconv.Atoi(c.PostForm("rate_limit_window_sec"))
|
||||
if err := d.Settings.UpdateRateLimits(postN, commentN, regN, loginN, windowN); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("限流设置已保存")
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
|
||||
// AdminSettingsFilterWordsPost 敏感词
|
||||
func (d Deps) AdminSettingsFilterWordsPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderAdminSettings(ctx, "无效请求,请重试")
|
||||
return
|
||||
}
|
||||
content := c.PostForm("filter_words")
|
||||
if err := d.Settings.UpdateFilterWords(content, d.Filter); err != nil {
|
||||
d.renderAdminSettings(ctx, err.Error())
|
||||
return
|
||||
}
|
||||
ctx.SetFlash(fmt.Sprintf("敏感词已更新(有效词 %d 个)· %s", services.CountFilterWords(content), time.Now().Format("15:04:05")))
|
||||
ctx.Redirect("/admin/settings")
|
||||
}
|
||||
208
routers/web/auth.go
Normal file
208
routers/web/auth.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type loginData struct {
|
||||
PageChrome
|
||||
Username string
|
||||
Redirect string
|
||||
}
|
||||
|
||||
type registerData struct {
|
||||
PageChrome
|
||||
Username string
|
||||
Nickname string
|
||||
Email string
|
||||
MailReady bool
|
||||
RequireEmailCode bool
|
||||
}
|
||||
|
||||
type registerForm struct {
|
||||
Username string
|
||||
Nickname string
|
||||
Email string
|
||||
}
|
||||
|
||||
// LoginGet 登录页
|
||||
func (d Deps) LoginGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
if c.Query("banned") == "1" {
|
||||
chrome.Error = "账号已被禁言"
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "auth/login", loginData{
|
||||
PageChrome: chrome,
|
||||
Redirect: c.Query("redirect"),
|
||||
})
|
||||
}
|
||||
|
||||
// LoginPost 登录提交
|
||||
func (d Deps) LoginPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderLogin(ctx, "无效请求,请重试", c.PostForm("username"), c.PostForm("redirect"))
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("login", c.ClientIP()) {
|
||||
d.renderLogin(ctx, "操作过于频繁,请稍后再试", c.PostForm("username"), c.PostForm("redirect"))
|
||||
return
|
||||
}
|
||||
user := strings.TrimSpace(c.PostForm("username"))
|
||||
pass := c.PostForm("password")
|
||||
redir := strings.TrimSpace(c.PostForm("redirect"))
|
||||
if redir == "" || !strings.HasPrefix(redir, "/") || strings.HasPrefix(redir, "//") {
|
||||
redir = "/"
|
||||
}
|
||||
token, _, err := d.Auth.Login(user, pass, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
msg := "用户名或密码错误"
|
||||
if err == services.ErrUserBanned {
|
||||
msg = "账号已被禁言"
|
||||
}
|
||||
d.renderLogin(ctx, msg, user, redir)
|
||||
return
|
||||
}
|
||||
ctx.SetLoginCookie(token)
|
||||
ctx.Redirect(redir)
|
||||
}
|
||||
|
||||
func (d Deps) renderLogin(ctx *webctx.Context, errMsg, username, redir string) {
|
||||
chrome := d.chrome(ctx, "登录 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
ctx.HTML(http.StatusOK, "auth/login", loginData{
|
||||
PageChrome: chrome,
|
||||
Username: username,
|
||||
Redirect: redir,
|
||||
})
|
||||
}
|
||||
|
||||
// LogoutPost 退出
|
||||
func (d Deps) LogoutPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.CheckCSRF() {
|
||||
ctx.ClearLoginCookie()
|
||||
}
|
||||
ctx.Redirect("/")
|
||||
}
|
||||
|
||||
// RegisterGet 注册页
|
||||
func (d Deps) RegisterGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
d.renderRegister(ctx, "", registerForm{})
|
||||
}
|
||||
|
||||
// RegisterSendCode POST 发送注册邮箱验证码
|
||||
func (d Deps) RegisterSendCode(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
form := registerFormFrom(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderRegister(ctx, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
|
||||
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
|
||||
return
|
||||
}
|
||||
if d.EmailCode == nil || !d.Settings.MailReady() {
|
||||
d.renderRegister(ctx, "邮件服务未配置,无需验证码即可注册", form)
|
||||
return
|
||||
}
|
||||
if err := d.EmailCode.SendRegisterCode(form.Email); err != nil {
|
||||
d.renderRegister(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Flash = "验证码已发送,请查收邮箱"
|
||||
d.renderRegisterWithChrome(ctx, chrome, "", form)
|
||||
}
|
||||
|
||||
// RegisterPost 注册提交
|
||||
func (d Deps) RegisterPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if ctx.IsSigned() {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
form := registerFormFrom(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderRegister(ctx, "无效请求,请重试", form)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("register", c.ClientIP()) {
|
||||
d.renderRegister(ctx, "操作过于频繁,请稍后再试", form)
|
||||
return
|
||||
}
|
||||
mailReady := d.Settings.MailReady()
|
||||
if mailReady {
|
||||
code := strings.TrimSpace(c.PostForm("email_code"))
|
||||
if d.EmailCode == nil || !d.EmailCode.Verify(form.Email, code) {
|
||||
d.renderRegister(ctx, services.ErrEmailCodeInvalid.Error(), form)
|
||||
return
|
||||
}
|
||||
}
|
||||
pass := c.PostForm("password")
|
||||
pass2 := c.PostForm("password2")
|
||||
if pass != pass2 {
|
||||
d.renderRegister(ctx, "两次密码不一致", form)
|
||||
return
|
||||
}
|
||||
user, err := d.Auth.Register(form.Username, pass, form.Nickname, form.Email)
|
||||
if err != nil {
|
||||
d.renderRegister(ctx, err.Error(), form)
|
||||
return
|
||||
}
|
||||
sid, err := d.Auth.CreateSessionForUser(user, c.ClientIP(), c.Request.UserAgent())
|
||||
if err != nil {
|
||||
ctx.SetFlash("注册成功,请登录")
|
||||
ctx.Redirect("/login")
|
||||
return
|
||||
}
|
||||
ctx.SetLoginCookie(sid)
|
||||
ctx.SetFlash("注册成功,欢迎加入")
|
||||
ctx.Redirect("/")
|
||||
}
|
||||
|
||||
func registerFormFrom(c *gin.Context) registerForm {
|
||||
return registerForm{
|
||||
Username: strings.TrimSpace(c.PostForm("username")),
|
||||
Nickname: strings.TrimSpace(c.PostForm("nickname")),
|
||||
Email: strings.TrimSpace(c.PostForm("email")),
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) renderRegister(ctx *webctx.Context, errMsg string, form registerForm) {
|
||||
chrome := d.chrome(ctx, "注册 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
d.renderRegisterWithChrome(ctx, chrome, errMsg, form)
|
||||
}
|
||||
|
||||
func (d Deps) renderRegisterWithChrome(ctx *webctx.Context, chrome PageChrome, errMsg string, form registerForm) {
|
||||
chrome.Error = errMsg
|
||||
mailReady := d.Settings.MailReady()
|
||||
ctx.HTML(http.StatusOK, "auth/register", registerData{
|
||||
PageChrome: chrome,
|
||||
Username: form.Username,
|
||||
Nickname: form.Nickname,
|
||||
Email: form.Email,
|
||||
MailReady: mailReady,
|
||||
RequireEmailCode: mailReady,
|
||||
})
|
||||
}
|
||||
110
routers/web/chrome.go
Normal file
110
routers/web/chrome.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Deps 页面依赖
|
||||
type Deps struct {
|
||||
DataDir string
|
||||
JWTSecret string
|
||||
Settings *services.ForumSettingsService
|
||||
Auth *services.AuthService
|
||||
Board *services.BoardService
|
||||
Post *services.PostService
|
||||
Comment *services.CommentService
|
||||
Message *services.MessageService
|
||||
Filter *services.SensitiveFilter
|
||||
Limiter *services.RateLimiter
|
||||
EmailCode *services.EmailCodeService
|
||||
Store *services.UploadStore
|
||||
}
|
||||
|
||||
// BoardView 侧栏
|
||||
type BoardView struct {
|
||||
ID uint
|
||||
Name string
|
||||
}
|
||||
|
||||
// PageChrome 布局公共字段
|
||||
type PageChrome struct {
|
||||
Title string
|
||||
Description string
|
||||
SiteName string
|
||||
Slogan string
|
||||
LogoMark string
|
||||
LoggedIn bool
|
||||
IsAdmin bool
|
||||
ViewerName string
|
||||
Boards []BoardView
|
||||
ActiveBoard uint
|
||||
Inner string // 保留字段;入口模板已固定组合,不再动态 template
|
||||
CSRF string
|
||||
Flash string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (d Deps) ctx(c *gin.Context) *webctx.Context {
|
||||
return webctx.New(c, d.JWTSecret)
|
||||
}
|
||||
|
||||
func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome {
|
||||
brand := d.Settings.SiteBranding()
|
||||
if title == "" {
|
||||
title = brand.DocumentTitle()
|
||||
}
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
name := "我的"
|
||||
if ctx.IsSigned() {
|
||||
name = strings.TrimSpace(ctx.Doer.Nickname)
|
||||
if name == "" {
|
||||
name = ctx.Doer.Username
|
||||
}
|
||||
}
|
||||
boards, _ := d.Board.List()
|
||||
bv := make([]BoardView, 0, len(boards))
|
||||
for _, b := range boards {
|
||||
bv = append(bv, BoardView{ID: b.ID, Name: b.Name})
|
||||
}
|
||||
return PageChrome{
|
||||
Title: title,
|
||||
Description: desc,
|
||||
SiteName: brand.Name,
|
||||
Slogan: brand.Slogan,
|
||||
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
||||
LoggedIn: ctx.IsSigned(),
|
||||
IsAdmin: ctx.IsAdmin(),
|
||||
ViewerName: name,
|
||||
Boards: bv,
|
||||
Inner: inner,
|
||||
CSRF: ctx.EnsureCSRF(),
|
||||
Flash: ctx.TakeFlash(),
|
||||
}
|
||||
}
|
||||
|
||||
func firstRuneOr(s, fallback string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range s {
|
||||
return string(r)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stripIDParam(raw, permalinkExt string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if permalinkExt != "" {
|
||||
raw = strings.TrimSuffix(raw, "."+permalinkExt)
|
||||
}
|
||||
raw = strings.TrimSuffix(raw, ".html")
|
||||
raw = strings.TrimSuffix(raw, ".htm")
|
||||
return raw
|
||||
}
|
||||
224
routers/web/compose.go
Normal file
224
routers/web/compose.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type composeData struct {
|
||||
PageChrome
|
||||
IsEdit bool
|
||||
PostID uint
|
||||
FormAction string
|
||||
BoardID uint
|
||||
Title string
|
||||
Tags string
|
||||
Content string
|
||||
Boards []BoardView
|
||||
TitleMax int
|
||||
TagsMax int
|
||||
ContentMax int
|
||||
}
|
||||
|
||||
// ComposeGet 发帖页
|
||||
func (d Deps) ComposeGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if err := d.ensureCanWrite(ctx); err != "" {
|
||||
ctx.SetFlash(err)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
||||
d.renderCompose(ctx, "", composeForm{
|
||||
BoardID: uint(boardID),
|
||||
}, false, 0)
|
||||
}
|
||||
|
||||
// ComposePost 发帖提交
|
||||
func (d Deps) ComposePost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), false, 0)
|
||||
return
|
||||
}
|
||||
if msg := d.ensureCanWrite(ctx); msg != "" {
|
||||
ctx.SetFlash(msg)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
d.renderCompose(ctx, "发帖过于频繁,请稍后再试", composeFormFrom(c), false, 0)
|
||||
return
|
||||
}
|
||||
form := composeFormFrom(c)
|
||||
htmlBody := services.ComposeBodyToHTML(form.Content)
|
||||
post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, models.PostTypeNormal, ctx.SkipsModeration())
|
||||
if err != nil {
|
||||
d.renderCompose(ctx, err.Error(), form, false, 0)
|
||||
return
|
||||
}
|
||||
if post.Status == models.ContentStatusPending {
|
||||
ctx.SetFlash("帖子已提交,等待审核")
|
||||
} else {
|
||||
ctx.SetFlash("发帖成功")
|
||||
}
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
|
||||
}
|
||||
|
||||
// PostEditGet 编辑帖
|
||||
func (d Deps) PostEditGet(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
|
||||
if errMsg != "" {
|
||||
ctx.SetFlash(errMsg)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
d.renderCompose(ctx, "", composeForm{
|
||||
BoardID: post.BoardID,
|
||||
Title: post.Title,
|
||||
Tags: post.Tags,
|
||||
Content: services.HTMLToComposePlain(post.Content),
|
||||
}, true, post.ID)
|
||||
}
|
||||
|
||||
// PostEditPost 编辑提交
|
||||
func (d Deps) PostEditPost(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
post, errMsg := d.loadEditablePost(ctx, c.Param("id"))
|
||||
if errMsg != "" {
|
||||
ctx.SetFlash(errMsg)
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
d.renderCompose(ctx, "无效请求,请重试", composeFormFrom(c), true, post.ID)
|
||||
return
|
||||
}
|
||||
if d.Limiter != nil && !d.Limiter.Allow("post", fmt.Sprintf("%d", ctx.UserID())) {
|
||||
d.renderCompose(ctx, "操作过于频繁,请稍后再试", composeFormFrom(c), true, post.ID)
|
||||
return
|
||||
}
|
||||
form := composeFormFrom(c)
|
||||
htmlBody := services.ComposeBodyToHTML(form.Content)
|
||||
if err := d.Post.Update(ctx.UserID(), post.ID, ctx.IsAdmin(), ctx.SkipsModeration(), form.Title, htmlBody, form.Tags, models.PostTypeNormal, form.BoardID); err != nil {
|
||||
d.renderCompose(ctx, err.Error(), form, true, post.ID)
|
||||
return
|
||||
}
|
||||
ctx.SetFlash("已保存")
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d", post.ID))
|
||||
}
|
||||
|
||||
// ComposeUpload 帖图上传(JSON,供 compose 页 fetch)
|
||||
func (d Deps) ComposeUpload(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.IsSigned() {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
if !ctx.CheckCSRF() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求"})
|
||||
return
|
||||
}
|
||||
if ctx.Doer != nil && ctx.Doer.Banned {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "账号已被禁言"})
|
||||
return
|
||||
}
|
||||
if d.Store == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "上传不可用"})
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
url, err := services.SaveUploadedImage(d.Store, file, services.UploadCategoryPosts, fmt.Sprintf("%d", ctx.UserID()))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
type composeForm struct {
|
||||
BoardID uint
|
||||
Title string
|
||||
Tags string
|
||||
Content string
|
||||
}
|
||||
|
||||
func composeFormFrom(c *gin.Context) composeForm {
|
||||
bid, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||
return composeForm{
|
||||
BoardID: uint(bid),
|
||||
Title: strings.TrimSpace(c.PostForm("title")),
|
||||
Tags: strings.TrimSpace(c.PostForm("tags")),
|
||||
Content: c.PostForm("content"),
|
||||
}
|
||||
}
|
||||
|
||||
func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm, isEdit bool, postID uint) {
|
||||
title := "发帖"
|
||||
action := "/compose"
|
||||
if isEdit {
|
||||
title = "编辑帖子"
|
||||
action = fmt.Sprintf("/post/%d/edit", postID)
|
||||
}
|
||||
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
|
||||
chrome.Error = errMsg
|
||||
chrome.ActiveBoard = form.BoardID
|
||||
ctx.HTML(http.StatusOK, "compose", composeData{
|
||||
PageChrome: chrome,
|
||||
IsEdit: isEdit,
|
||||
PostID: postID,
|
||||
FormAction: action,
|
||||
BoardID: form.BoardID,
|
||||
Title: form.Title,
|
||||
Tags: form.Tags,
|
||||
Content: form.Content,
|
||||
Boards: chrome.Boards,
|
||||
TitleMax: d.Settings.PostTitleMax(),
|
||||
TagsMax: d.Settings.PostTagsMax(),
|
||||
ContentMax: d.Settings.PostContentMax(),
|
||||
})
|
||||
}
|
||||
|
||||
func (d Deps) ensureCanWrite(ctx *webctx.Context) string {
|
||||
if !ctx.IsSigned() {
|
||||
return "请先登录"
|
||||
}
|
||||
if ctx.Doer != nil && ctx.Doer.Banned {
|
||||
return "账号已被禁言,无法发帖"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d Deps) loadEditablePost(ctx *webctx.Context, idParam string) (*models.Post, string) {
|
||||
if msg := d.ensureCanWrite(ctx); msg != "" {
|
||||
return nil, msg
|
||||
}
|
||||
idStr := stripIDParam(idParam, d.Settings.Permalink().Ext)
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return nil, "帖子不存在"
|
||||
}
|
||||
post, err := d.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
return nil, "帖子不存在"
|
||||
}
|
||||
if !ctx.IsAdmin() && post.UserID != ctx.UserID() {
|
||||
return nil, "无权编辑此帖"
|
||||
}
|
||||
if reason := d.Post.EditBlockReason(post, ctx.IsAdmin()); reason != "" {
|
||||
return nil, reason
|
||||
}
|
||||
return post, ""
|
||||
}
|
||||
@@ -7,27 +7,74 @@ import (
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/auth"
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webrender"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Deps 页面路由依赖(复用现有 service,避免 Phase 1 大搬家)
|
||||
type Deps struct {
|
||||
Settings *services.ForumSettingsService
|
||||
Board *services.BoardService
|
||||
Post *services.PostService
|
||||
// Register 注册已安装后的 web 路由
|
||||
func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
||||
g := r.Group("/", authMW.OptionalAuth())
|
||||
g.GET("/", deps.Home)
|
||||
g.GET("/board/:id", deps.Home)
|
||||
g.GET("/post/:id", deps.PostView)
|
||||
g.GET("/post/:id/edit", authMW.RequireAuth(), deps.PostEditGet)
|
||||
g.POST("/post/:id/edit", authMW.RequireAuth(), deps.PostEditPost)
|
||||
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
|
||||
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
||||
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
|
||||
g.GET("/login", deps.LoginGet)
|
||||
g.POST("/login", deps.LoginPost)
|
||||
g.POST("/logout", deps.LogoutPost)
|
||||
g.GET("/register", deps.RegisterGet)
|
||||
g.POST("/register", deps.RegisterPost)
|
||||
g.POST("/register/send-code", deps.RegisterSendCode)
|
||||
g.GET("/compose", authMW.RequireAuth(), deps.ComposeGet)
|
||||
g.POST("/compose", authMW.RequireAuth(), deps.ComposePost)
|
||||
g.POST("/compose/upload", authMW.RequireAuth(), deps.ComposeUpload)
|
||||
g.GET("/admin/login", func(c *gin.Context) { c.Redirect(http.StatusFound, "/login?redirect=/admin/dashboard") })
|
||||
|
||||
admin := g.Group("/admin", authMW.RequireAuth(), authMW.RequireAdmin())
|
||||
{
|
||||
admin.GET("", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
|
||||
admin.GET("/dashboard", deps.AdminDashboard)
|
||||
admin.GET("/boards", deps.AdminBoardsGet)
|
||||
admin.POST("/boards", deps.AdminBoardCreate)
|
||||
admin.POST("/boards/:id", deps.AdminBoardUpdate)
|
||||
admin.POST("/boards/:id/delete", deps.AdminBoardDelete)
|
||||
admin.GET("/moderation", deps.AdminModerationGet)
|
||||
admin.POST("/posts/:id/approve", deps.AdminPostApprove)
|
||||
admin.POST("/posts/:id/reject", deps.AdminPostReject)
|
||||
admin.POST("/comments/:id/approve", deps.AdminCommentApprove)
|
||||
admin.POST("/comments/:id/reject", deps.AdminCommentReject)
|
||||
admin.GET("/settings", deps.AdminSettingsGet)
|
||||
admin.POST("/settings/brand", deps.AdminSettingsBrandPost)
|
||||
admin.POST("/settings/limits", deps.AdminSettingsLimitsPost)
|
||||
admin.POST("/settings/filter-words", deps.AdminSettingsFilterWordsPost)
|
||||
}
|
||||
|
||||
g.GET("/profile", deps.PendingPage)
|
||||
g.GET("/messages", deps.PendingPage)
|
||||
g.GET("/favorites", deps.PendingPage)
|
||||
g.GET("/projects", deps.PendingPage)
|
||||
g.GET("/links", deps.PendingPage)
|
||||
g.GET("/boards", deps.PendingPage)
|
||||
}
|
||||
|
||||
// BoardView 侧栏板块
|
||||
type BoardView struct {
|
||||
ID uint
|
||||
Name string
|
||||
// HomePageData Feed
|
||||
type HomePageData struct {
|
||||
PageChrome
|
||||
BoardName string
|
||||
Sort string
|
||||
Posts []PostListItem
|
||||
Page int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// PostView 列表项
|
||||
type PostView struct {
|
||||
// PostListItem 列表项
|
||||
type PostListItem struct {
|
||||
ID uint
|
||||
Title string
|
||||
AuthorName string
|
||||
@@ -38,39 +85,10 @@ type PostView struct {
|
||||
CreatedLabel string
|
||||
}
|
||||
|
||||
// HomePageData 首页 / 板块 Feed
|
||||
type HomePageData struct {
|
||||
Title string
|
||||
Description string
|
||||
SiteName string
|
||||
Slogan string
|
||||
LogoMark string
|
||||
LoggedIn bool
|
||||
IsAdmin bool
|
||||
ViewerName string
|
||||
Boards []BoardView
|
||||
ActiveBoard uint
|
||||
BoardName string
|
||||
Sort string
|
||||
Posts []PostView
|
||||
Page int
|
||||
PrevPage int
|
||||
NextPage int
|
||||
HasPrev bool
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// Register 注册已迁移的 SSR 页面(优先于 SPA)
|
||||
func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
||||
g := r.Group("/", authMW.OptionalAuth())
|
||||
g.GET("/", deps.Home)
|
||||
g.GET("/board/:id", deps.Home)
|
||||
}
|
||||
|
||||
// Home SSR 首页与板块列表
|
||||
// Home 首页 / 板块
|
||||
func (d Deps) Home(c *gin.Context) {
|
||||
brand := d.Settings.SiteBranding()
|
||||
sort := c.DefaultQuery("sort", "latest")
|
||||
ctx := d.ctx(c)
|
||||
sort := normalizeSort(c.DefaultQuery("sort", "latest"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
@@ -80,54 +98,38 @@ func (d Deps) Home(c *gin.Context) {
|
||||
var boardID uint
|
||||
var boardName string
|
||||
if idStr := c.Param("id"); idStr != "" {
|
||||
// 兼容伪静态后缀 123.html
|
||||
idStr = strings.TrimSuffix(idStr, "."+d.Settings.Permalink().Ext)
|
||||
idStr = strings.TrimSuffix(idStr, ".html")
|
||||
idStr = strings.TrimSuffix(idStr, ".htm")
|
||||
idStr = stripIDParam(idStr, d.Settings.Permalink().Ext)
|
||||
if n, err := strconv.ParseUint(idStr, 10, 64); err == nil {
|
||||
boardID = uint(n)
|
||||
}
|
||||
}
|
||||
|
||||
boards, _ := d.Board.List()
|
||||
boardViews := make([]BoardView, 0, len(boards))
|
||||
for _, b := range boards {
|
||||
boardViews = append(boardViews, BoardView{ID: b.ID, Name: b.Name})
|
||||
chrome := d.chrome(ctx, "", "", "home/feed")
|
||||
chrome.ActiveBoard = boardID
|
||||
for _, b := range chrome.Boards {
|
||||
if b.ID == boardID {
|
||||
boardName = b.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var uid uint
|
||||
if v, ok := c.Get(auth.CtxUserID); ok {
|
||||
uid, _ = v.(uint)
|
||||
if boardName != "" {
|
||||
chrome.Title = boardName + " · " + chrome.SiteName
|
||||
}
|
||||
isAdmin := false
|
||||
if v, ok := c.Get(auth.CtxRole); ok {
|
||||
switch r := v.(type) {
|
||||
case models.Role:
|
||||
isAdmin = r == models.RoleAdmin
|
||||
case string:
|
||||
isAdmin = r == string(models.RoleAdmin)
|
||||
}
|
||||
}
|
||||
username, _ := c.Get(auth.CtxUsername)
|
||||
|
||||
q := services.PostListQuery{
|
||||
items, total, err := d.Post.ListItems(services.PostListQuery{
|
||||
BoardID: boardID,
|
||||
Page: page,
|
||||
Size: size,
|
||||
Sort: sort,
|
||||
ViewerID: uid,
|
||||
ViewerIsAdmin: isAdmin,
|
||||
}
|
||||
items, total, err := d.Post.ListItems(q)
|
||||
ViewerID: ctx.UserID(),
|
||||
ViewerIsAdmin: ctx.IsAdmin(),
|
||||
})
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载帖子失败")
|
||||
return
|
||||
}
|
||||
|
||||
posts := make([]PostView, 0, len(items))
|
||||
posts := make([]PostListItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
author := strings.TrimSpace(it.User.Nickname)
|
||||
if author == "" {
|
||||
@@ -137,49 +139,24 @@ func (d Deps) Home(c *gin.Context) {
|
||||
if it.Board.ID > 0 {
|
||||
bname = it.Board.Name
|
||||
}
|
||||
posts = append(posts, PostView{
|
||||
ID: it.ID,
|
||||
Title: it.Title,
|
||||
AuthorName: author,
|
||||
BoardName: bname,
|
||||
Pinned: it.Pinned,
|
||||
Featured: it.Featured,
|
||||
CommentCount: it.CommentCount,
|
||||
CreatedLabel: formatTime(it.CreatedAt),
|
||||
posts = append(posts, PostListItem{
|
||||
ID: it.ID, Title: it.Title, AuthorName: author, BoardName: bname,
|
||||
Pinned: it.Pinned, Featured: it.Featured, CommentCount: it.CommentCount,
|
||||
CreatedLabel: it.CreatedAt.Local().Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
title := brand.DocumentTitle()
|
||||
if boardName != "" {
|
||||
title = boardName + " · " + brand.Name
|
||||
}
|
||||
|
||||
data := HomePageData{
|
||||
Title: title,
|
||||
Description: brand.MetaDescription(),
|
||||
SiteName: brand.Name,
|
||||
Slogan: brand.Slogan,
|
||||
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
||||
LoggedIn: uid > 0,
|
||||
IsAdmin: isAdmin,
|
||||
ViewerName: fmtViewer(username),
|
||||
Boards: boardViews,
|
||||
ActiveBoard: boardID,
|
||||
BoardName: boardName,
|
||||
Sort: normalizeSort(sort),
|
||||
Posts: posts,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
HasMore: int64(page*size) < total,
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Status(http.StatusOK)
|
||||
if err := webrender.Execute(c.Writer, "home", data); err != nil {
|
||||
c.String(http.StatusInternalServerError, "模板渲染失败: %v", err)
|
||||
}
|
||||
ctx.HTML(http.StatusOK, "home", HomePageData{
|
||||
PageChrome: chrome,
|
||||
BoardName: boardName,
|
||||
Sort: sort,
|
||||
Posts: posts,
|
||||
Page: page,
|
||||
PrevPage: page - 1,
|
||||
NextPage: page + 1,
|
||||
HasPrev: page > 1,
|
||||
HasMore: int64(page*size) < total,
|
||||
})
|
||||
}
|
||||
|
||||
func normalizeSort(s string) string {
|
||||
@@ -194,26 +171,3 @@ func normalizeSort(s string) string {
|
||||
func formatTime(t time.Time) string {
|
||||
return t.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func firstRuneOr(s, fallback string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
for _, r := range s {
|
||||
return string(r)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func fmtViewer(v any) string {
|
||||
if v == nil {
|
||||
return "我的"
|
||||
}
|
||||
s, _ := v.(string)
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "我的"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
35
routers/web/pending.go
Normal file
35
routers/web/pending.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/modules/webctx"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type pendingData struct {
|
||||
PageChrome
|
||||
Heading string
|
||||
Message string
|
||||
}
|
||||
|
||||
// PendingPage 未迁移页
|
||||
func (d Deps) PendingPage(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
chrome := d.chrome(ctx, "页面准备中 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
ctx.HTML(http.StatusOK, "status/pending", pendingData{
|
||||
PageChrome: chrome,
|
||||
Heading: "页面准备中",
|
||||
Message: "该功能尚未用模板实现,请稍后再来。",
|
||||
})
|
||||
}
|
||||
|
||||
func (d Deps) render404(ctx *webctx.Context) {
|
||||
chrome := d.chrome(ctx, "页面不存在 · "+d.Settings.SiteBranding().Name, "", "")
|
||||
ctx.HTML(http.StatusNotFound, "status/404", chrome)
|
||||
}
|
||||
|
||||
// NotFound NoRoute
|
||||
func (d Deps) NotFound(c *gin.Context) {
|
||||
d.render404(d.ctx(c))
|
||||
}
|
||||
193
routers/web/post.go
Normal file
193
routers/web/post.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/models"
|
||||
"git.iioio.com/freefire/jiang13-forum/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PostPageData 帖详情
|
||||
type PostPageData struct {
|
||||
PageChrome
|
||||
PostID uint
|
||||
PostPath string
|
||||
PostTitle string
|
||||
AuthorName string
|
||||
BoardID uint
|
||||
BoardName string
|
||||
Pinned bool
|
||||
Featured bool
|
||||
PostTypeLabel string
|
||||
CreatedLabel string
|
||||
ViewCount int
|
||||
LikeCount int
|
||||
Liked bool
|
||||
Favorited bool
|
||||
BodyHTML string
|
||||
CommentCount int
|
||||
Comments []CommentView
|
||||
CommentsLocked bool
|
||||
CanEdit bool
|
||||
}
|
||||
|
||||
// CommentView 评论
|
||||
type CommentView struct {
|
||||
Floor int
|
||||
AuthorName string
|
||||
CreatedLabel string
|
||||
Content string
|
||||
ContentHidden bool
|
||||
}
|
||||
|
||||
// PostView GET /post/:id
|
||||
func (d Deps) PostView(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
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
|
||||
}
|
||||
post, err := d.Post.FindByID(uint(id))
|
||||
if err != nil || !services.CanViewPost(post, ctx.UserID(), ctx.IsAdmin()) {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
if post.Status == models.ContentStatusPublished {
|
||||
d.Post.RecordView(uint(id))
|
||||
}
|
||||
|
||||
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
|
||||
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
|
||||
|
||||
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
|
||||
cv := make([]CommentView, 0, len(comments))
|
||||
for _, cm := range comments {
|
||||
an := strings.TrimSpace(cm.User.Nickname)
|
||||
if an == "" {
|
||||
an = cm.User.Username
|
||||
}
|
||||
cv = append(cv, CommentView{
|
||||
Floor: cm.Floor, AuthorName: an, CreatedLabel: formatTime(cm.CreatedAt),
|
||||
Content: cm.Content, ContentHidden: cm.ContentHidden,
|
||||
})
|
||||
}
|
||||
|
||||
author := strings.TrimSpace(post.User.Nickname)
|
||||
if author == "" {
|
||||
author = post.User.Username
|
||||
}
|
||||
boardName := ""
|
||||
if post.Board.ID > 0 {
|
||||
boardName = post.Board.Name
|
||||
}
|
||||
|
||||
chrome := d.chrome(ctx, post.Title+" · "+d.Settings.SiteBranding().Name, "", "post/body")
|
||||
chrome.ActiveBoard = post.BoardID
|
||||
|
||||
ctx.HTML(http.StatusOK, "post", PostPageData{
|
||||
PageChrome: chrome, PostID: post.ID,
|
||||
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
|
||||
PostTitle: post.Title, AuthorName: author, BoardID: post.BoardID, BoardName: boardName,
|
||||
Pinned: post.Pinned || post.BoardPinned, Featured: post.Featured,
|
||||
PostTypeLabel: postTypeLabel(post.PostType), CreatedLabel: formatTime(post.CreatedAt),
|
||||
ViewCount: post.ViewCount, LikeCount: post.LikeCount,
|
||||
Liked: d.Post.IsLiked(ctx.UserID(), post.ID), Favorited: d.Post.IsFavorited(ctx.UserID(), post.ID),
|
||||
BodyHTML: body, CommentCount: len(cv), Comments: cv,
|
||||
CommentsLocked: post.CommentsLocked,
|
||||
CanEdit: d.Post.CanUserEdit(post, ctx.UserID(), ctx.IsAdmin()),
|
||||
})
|
||||
}
|
||||
|
||||
func postTypeLabel(t string) string {
|
||||
switch t {
|
||||
case models.PostTypeQuestion:
|
||||
return "问答"
|
||||
case models.PostTypePoll:
|
||||
return "投票"
|
||||
case models.PostTypeBounty:
|
||||
return "悬赏"
|
||||
case models.PostTypeLottery:
|
||||
return "抽奖"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// PostComment POST 评论
|
||||
func (d Deps) PostComment(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求,请重试")
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
id, err := parsePostID(c, d)
|
||||
if err != nil {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
content := strings.TrimSpace(c.PostForm("content"))
|
||||
if content == "" {
|
||||
ctx.SetFlash("评论不能为空")
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||||
return
|
||||
}
|
||||
safe := "<p>" + html.EscapeString(content) + "</p>"
|
||||
_, err = d.Comment.Create(services.CommentCreateInput{
|
||||
PostID: id, UserID: ctx.UserID(), Content: safe,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.SetFlash(err.Error())
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||||
return
|
||||
}
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||||
}
|
||||
|
||||
// PostLike 赞
|
||||
func (d Deps) PostLike(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求")
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
id, err := parsePostID(c, d)
|
||||
if err != nil {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
_, _ = d.Post.ToggleLike(ctx.UserID(), id)
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||||
}
|
||||
|
||||
// PostFavorite 收藏
|
||||
func (d Deps) PostFavorite(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
ctx.SetFlash("无效请求")
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
id, err := parsePostID(c, d)
|
||||
if err != nil {
|
||||
d.render404(ctx)
|
||||
return
|
||||
}
|
||||
_, _ = d.Post.ToggleFavorite(ctx.UserID(), id)
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d", id))
|
||||
}
|
||||
|
||||
func parsePostID(c *gin.Context, d Deps) (uint, error) {
|
||||
idStr := stripIDParam(c.Param("id"), d.Settings.Permalink().Ext)
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
return uint(id), err
|
||||
}
|
||||
Reference in New Issue
Block a user