diff --git a/.gitignore b/.gitignore index 3c18b7f..2da9ad0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,11 +8,12 @@ /node_modules/ /frontend/node_modules/ /frontend/dist/ -/embed_static/static/spa/ +/.vite/ # Go 编译产物 /dist/ *.exe +/jiang13-* # 临时文件 tmp-cookie.txt diff --git a/.vite/deps/_metadata.json b/.vite/deps/_metadata.json deleted file mode 100644 index 2378c33..0000000 --- a/.vite/deps/_metadata.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "hash": "de7e4cff", - "configHash": "9a7296da", - "lockfileHash": "e3b0c442", - "browserHash": "8c168d3c", - "optimized": {}, - "chunks": {} -} \ No newline at end of file diff --git a/.vite/deps/package.json b/.vite/deps/package.json deleted file mode 100644 index 3dbc1ca..0000000 --- a/.vite/deps/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d39cfbb..a2db1b2 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -264,8 +264,6 @@ export const api = { }), postRevisions: (id: number) => request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`), - postRevision: (id: number, revId: number) => - request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`), adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }), adminTrashPosts: (params: { page?: number; keyword?: string }) => { const q = new URLSearchParams(); @@ -582,7 +580,6 @@ export const api = { `/api/users/search?${sp}`, ); }, - captcha: () => request<{ id: string; image: string }>('/api/captcha'), logout: () => request('/api/logout', { method: 'POST' }), like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }), likeComment: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/comments/${id}/like`, { method: 'POST' }), diff --git a/handler/api.go b/handler/api.go index 10a8d18..48490df 100644 --- a/handler/api.go +++ b/handler/api.go @@ -1127,16 +1127,6 @@ func (h *Handlers) APIPostComments(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"comments": comments, "total": len(comments)}) } -// APIHotPosts 近期活跃讨论(近 7 日有回复) -func (h *Handlers) APIHotPosts(c *gin.Context) { - items, err := h.Post.HotPosts(10) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"posts": items}) -} - // APITags 标签云(按使用次数聚合) func (h *Handlers) APITags(c *gin.Context) { limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40")) @@ -1211,29 +1201,6 @@ func (h *Handlers) APIPostRevisions(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"revisions": revs}) } -// APIPostRevisionDetail 查看某个历史版本 -func (h *Handlers) APIPostRevisionDetail(c *gin.Context) { - id, _ := strconv.ParseUint(c.Param("id"), 10, 64) - revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64) - post, err := h.Post.FindByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"}) - return - } - uid := h.currentUserID(c) - isAdmin := h.isAdmin(c) - if !isAdmin && post.UserID != uid { - c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"}) - return - } - rev, err := h.Post.GetRevision(uint(id), uint(revID)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"revision": rev}) -} - func isClientLimitError(err error) bool { return errors.Is(err, service.ErrSearchKeywordTooShort) || errors.Is(err, service.ErrSearchKeywordTooLong) diff --git a/handler/handlers.go b/handler/handlers.go index 23ea5ab..a2b231d 100644 --- a/handler/handlers.go +++ b/handler/handlers.go @@ -1,7 +1,6 @@ package handler import ( - "encoding/base64" "errors" "fmt" "net/http" @@ -33,7 +32,6 @@ type Handlers struct { Filter *service.SensitiveFilter Limiter *service.RateLimiter Settings *service.ForumSettingsService - Captcha *service.CaptchaService Mail *service.MailService EmailCode *service.EmailCodeService OIDC *service.OIDCService @@ -114,18 +112,6 @@ func calcTotalPages(total int64, size int) int { // --- API --- -func (h *Handlers) APICaptcha(c *gin.Context) { - id, svg, err := h.Captcha.Generate() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"}) - return - } - c.JSON(http.StatusOK, gin.H{ - "id": id, - "image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)), - }) -} - // APIRegisterConfig 注册页所需公开配置 func (h *Handlers) APIRegisterConfig(c *gin.Context) { userCount := h.Auth.UserCount() diff --git a/jiang13-linux-amd64 b/jiang13-linux-amd64 deleted file mode 100644 index 601adb6..0000000 Binary files a/jiang13-linux-amd64 and /dev/null differ diff --git a/router/router.go b/router/router.go index 34a6ed3..1939fa0 100644 --- a/router/router.go +++ b/router/router.go @@ -50,7 +50,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { reportSvc := service.NewReportService(filter, settingsSvc, messageSvc, postSvc, commentSvc) backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir) limiter := service.NewRateLimiter(settingsSvc) - captchaSvc := service.NewCaptchaService() mailSvc := service.NewMailService(settingsSvc) emailCodeSvc := service.NewEmailCodeService(mailSvc) notifySvc := service.NewNotifyService(messageSvc, mailSvc, settingsSvc) @@ -82,7 +81,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc, Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc, Filter: filter, Limiter: limiter, Settings: settingsSvc, - Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc, + Mail: mailSvc, EmailCode: emailCodeSvc, OIDC: oidcSvc, Gitea: giteaSvc, Points: service.NewPointsService(), Badge: service.NewBadgeService(), SitePage: service.NewSitePageService(filter), @@ -127,13 +126,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { 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", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode) pubAPI.POST("/password-reset/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendResetEmailCode) pubAPI.POST("/password-reset", middleware.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 之前 @@ -164,7 +161,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { 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) diff --git a/service/captcha.go b/service/captcha.go deleted file mode 100644 index 74cd43c..0000000 --- a/service/captcha.go +++ /dev/null @@ -1,158 +0,0 @@ -package service - -import ( - "crypto/rand" - "encoding/hex" - "fmt" - "math/big" - "strings" - "sync" - "time" -) - -const ( - captchaLen = 4 - captchaTTL = 5 * time.Minute - captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - captchaWidth = 120 - captchaHeight = 40 -) - -type captchaEntry struct { - answer string - expiresAt time.Time -} - -// CaptchaService 内存图形验证码 -type CaptchaService struct { - mu sync.Mutex - entries map[string]captchaEntry -} - -func NewCaptchaService() *CaptchaService { - s := &CaptchaService{entries: make(map[string]captchaEntry)} - go s.cleanup() - return s -} - -// Generate 生成验证码,返回 id 与 SVG 图片 -func (s *CaptchaService) Generate() (id, svg string, err error) { - answer, err := randomCaptchaText(captchaLen) - if err != nil { - return "", "", err - } - rawID := make([]byte, 16) - if _, err := rand.Read(rawID); err != nil { - return "", "", err - } - id = hex.EncodeToString(rawID) - - s.mu.Lock() - s.entries[id] = captchaEntry{ - answer: strings.ToUpper(answer), - expiresAt: time.Now().Add(captchaTTL), - } - s.mu.Unlock() - - return id, renderCaptchaSVG(answer), nil -} - -// Verify 校验验证码(一次性,大小写不敏感) -func (s *CaptchaService) Verify(id, answer string) bool { - if id == "" || answer == "" { - return false - } - s.mu.Lock() - defer s.mu.Unlock() - entry, ok := s.entries[id] - if !ok { - return false - } - delete(s.entries, id) - if time.Now().After(entry.expiresAt) { - return false - } - return strings.EqualFold(entry.answer, strings.TrimSpace(answer)) -} - -func (s *CaptchaService) cleanup() { - ticker := time.NewTicker(time.Minute) - for range ticker.C { - now := time.Now() - s.mu.Lock() - for id, entry := range s.entries { - if now.After(entry.expiresAt) { - delete(s.entries, id) - } - } - s.mu.Unlock() - } -} - -func randomCaptchaText(n int) (string, error) { - var b strings.Builder - b.Grow(n) - max := big.NewInt(int64(len(captchaChars))) - for i := 0; i < n; i++ { - idx, err := rand.Int(rand.Reader, max) - if err != nil { - return "", err - } - b.WriteByte(captchaChars[idx.Int64()]) - } - return b.String(), nil -} - -func renderCaptchaSVG(text string) string { - var b strings.Builder - b.WriteString(fmt.Sprintf( - ``, - captchaWidth, captchaHeight, captchaWidth, captchaHeight, - )) - b.WriteString(``) - - // 干扰线 - for i := 0; i < 4; i++ { - x1, _ := randInt(0, captchaWidth) - y1, _ := randInt(0, captchaHeight) - x2, _ := randInt(0, captchaWidth) - y2, _ := randInt(0, captchaHeight) - color := noiseColor() - b.WriteString(fmt.Sprintf( - ``, - x1, y1, x2, y2, color, - )) - } - - step := captchaWidth / (len(text) + 1) - for i, ch := range text { - x := step*(i+1) - 6 - y, _ := randInt(26, 34) - rot, _ := randInt(-18, 18) - b.WriteString(fmt.Sprintf( - `%c`, - x, y, rot, x+6, y-6, ch, - )) - } - - b.WriteString(``) - return b.String() -} - -func randInt(min, max int) (int, error) { - if max <= min { - return min, nil - } - n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1))) - if err != nil { - return min, err - } - return min + int(n.Int64()), nil -} - -func noiseColor() string { - r, _ := randInt(160, 210) - g, _ := randInt(170, 220) - b, _ := randInt(160, 210) - return fmt.Sprintf("#%02x%02x%02x", r, g, b) -} diff --git a/service/common.go b/service/common.go index 082a2b1..23bdb67 100644 --- a/service/common.go +++ b/service/common.go @@ -27,7 +27,6 @@ var ( ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑") ErrPostCommentsLocked = errors.New("该帖子已锁定讨论,无法评论") ErrPostEditExpired = errors.New("已超过可编辑时限") - ErrRevisionNotFound = errors.New("历史版本不存在") ErrInvalidSetting = errors.New("无效的设置值") ErrSearchKeywordTooShort = errors.New("搜索关键词过短") ErrSearchKeywordTooLong = errors.New("搜索关键词过长") @@ -35,7 +34,6 @@ var ( ErrPostTagsTooLong = errors.New("标签过长") ErrPostContentTooLong = errors.New("正文过长") ErrCommentTooLong = errors.New("评论内容过长") - ErrCaptchaInvalid = errors.New("验证码错误或已过期") ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期") ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试") ErrMailNotConfigured = errors.New("邮件服务未配置或未启用") diff --git a/service/post.go b/service/post.go index 9f52a1b..ac9696e 100644 --- a/service/post.go +++ b/service/post.go @@ -172,52 +172,6 @@ func (s *PostService) lastReplyInfoMap(postIDs []uint) map[uint]lastReplyInfo { return m } -// HotPosts 近期活跃讨论(近 7 日有公开回复,按最后回复时间倒序) -func (s *PostService) HotPosts(limit int) ([]PostListItem, error) { - if limit <= 0 { - limit = 10 - } - since := time.Now().Add(-7 * 24 * time.Hour) - var posts []model.Post - err := model.DB.Preload("User").Preload("Board"). - Where("status = ?", model.ContentStatusPublished). - Where(`EXISTS ( - SELECT 1 FROM comments - WHERE comments.post_id = posts.id - AND comments.deleted_at IS NULL - AND comments.status = ? - AND comments.created_at >= ? - )`, model.ContentStatusPublished, since). - Order(`( - SELECT MAX(created_at) FROM comments - WHERE comments.post_id = posts.id - AND comments.deleted_at IS NULL - AND comments.status = 'published' - ) DESC`). - Limit(limit).Find(&posts).Error - if err != nil { - return nil, err - } - ids := make([]uint, len(posts)) - for i, p := range posts { - ids[i] = p.ID - } - countMap := s.commentCountMap(ids) - replyMap := s.lastReplyInfoMap(ids) - items := make([]PostListItem, len(posts)) - for i, p := range posts { - info := replyMap[p.ID] - items[i] = PostListItem{ - Post: p, - CommentCount: countMap[p.ID], - LastReplyAt: info.At, - LastReplyUser: info.User, - LastReplyGuestNick: info.GuestNick, - } - } - return items, nil -} - // TagCount 标签及其出现次数 type TagCount struct { Name string `json:"name"` @@ -680,16 +634,6 @@ func (s *PostService) ListRevisions(postID uint) ([]model.PostRevision, error) { return revs, nil } -func (s *PostService) GetRevision(postID, revID uint) (*model.PostRevision, error) { - var rev model.PostRevision - err := model.DB.Preload("Editor"). - Where("id = ? AND post_id = ?", revID, postID).First(&rev).Error - if err != nil { - return nil, ErrRevisionNotFound - } - return &rev, nil -} - // Delete 软删除帖子及其评论(进入回收站);点赞/收藏保留以便恢复。仅管理员可删。 func (s *PostService) Delete(userID, postID uint, isAdmin bool) error { if !isAdmin {