chore: baseline snapshot before moderation-strictness feature
Repo .git was damaged (refs/ missing, index truncated, object store near-empty). Repaired via git init (rebuilt refs), stale index preserved as .git/index.corrupt-20260910.bak, then full worktree committed as a rollback baseline.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package question
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
// isQuestioner 判断当前请求者是否为该提问的提问者。
|
||||
// 匿名提问者通过 token(URL 或表单隐藏域)验证;登录提问者通过账号 ID 验证。
|
||||
func isQuestioner(ctx context.Context, question *db.Question, token string) bool {
|
||||
if question.Token != "" && question.Token == token {
|
||||
return true
|
||||
}
|
||||
if ctx.IsLogged && question.AskerUserID != 0 && ctx.User.ID == question.AskerUserID {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PublishFollowUp 提问者在问题被回答后,用 token 继续追问。
|
||||
func PublishFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishFollowUpQuestion) {
|
||||
// govalid 校验失败(追问内容为空)时回显表单,与 AnswerFollowUp 同路径。
|
||||
// (旧实现漏掉该早退:required 失败后仍会继续 censor/入库,追问空内容也会被创建。)
|
||||
if ctx.HasError() {
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
// 追问内容长度上限(conf.Limits.FollowUpContentLen,[limits] 配置段),
|
||||
// 与前端追问框 maxlength 同源,rune 计数。
|
||||
if utf8.RuneCountInString(f.Content) > conf.Limits.FollowUpContentLen {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.contentTooLong", conf.Limits.FollowUpContentLen)), f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
token := ctx.Query("t")
|
||||
if token == "" {
|
||||
token = f.Token
|
||||
}
|
||||
|
||||
if !isQuestioner(ctx, question, token) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNoPermission"))
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// 只能对已回答的问题追问
|
||||
if question.Answer == "" {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotAnswered"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
|
||||
// 追问次数限制:先快速预检查(友好提示),CreateIfBelowLimit 内再原子兜底
|
||||
// (并发场景下 count-then-create 存在竞态,必须以数据库原子检查为准)。
|
||||
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), question.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
if count >= db.MaxFollowUpCount {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
|
||||
// 服务端校验"等主人回复":模板仅在前一条被回复后才展示追问表单,
|
||||
// 但 POST 可被直接构造绕过,因此必须检查最新一条追问是否已回复。
|
||||
latest, err := db.FollowUps.GetLatestByQuestionID(ctx.Request().Context(), question.ID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get latest follow up")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
if latest != nil && latest.Answer == "" {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errWaitReply"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
// 内容安全检测
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up text")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetErrorFlash(censorResponse.ErrorMessage())
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
|
||||
created, err := db.FollowUps.CreateIfBelowLimit(ctx.Request().Context(), db.CreateFollowUpOptions{
|
||||
QuestionID: question.ID,
|
||||
Content: f.Content,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrFollowUpLimitReached) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errLimit", db.MaxFollowUpCount))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create follow up")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
return
|
||||
}
|
||||
_ = created
|
||||
|
||||
// 邮件通知提问箱主人(开启邮件提醒时)
|
||||
shared.SafeGo(func() {
|
||||
if pageUser.Notify == db.NotifyTypeEmail {
|
||||
if err := mail.SendNewFollowUpMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, f.Content); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new follow up mail to user")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.sent"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token))
|
||||
}
|
||||
|
||||
// AnswerFollowUp 提问箱主人回答某条追问。
|
||||
func AnswerFollowUp(ctx context.Context, pageUser *db.User, question *db.Question, f form.AnswerFollowUpQuestion) {
|
||||
if ctx.User.ID != pageUser.ID {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
// 追问的回答内容长度上限:与主回答共用 conf.Limits.AnswerContentLen
|
||||
// ([limits] 配置段),与前端回复框 maxlength 同源,rune 计数。
|
||||
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.followup.answerTooLong", conf.Limits.AnswerContentLen)), f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
followUpID := uint(ctx.ParamInt("followUpID"))
|
||||
|
||||
// 内容安全检测
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), f.Answer)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor follow up answer")
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "censor.unavailable"))
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetErrorFlash(censorResponse.ErrorMessage())
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.FollowUps.UpdateAnswerByID(ctx.Request().Context(), question.ID, followUpID, f.Answer); err != nil {
|
||||
if errors.Is(err, db.ErrFollowUpNotFound) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "followup.errNotFound"))
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer follow up")
|
||||
ctx.SetInternalErrorFlash()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
// 邮件通知提问者(留下邮箱时):复用"您的提问有了回复"模板,带 token 链接
|
||||
shared.SafeGo(func() {
|
||||
if question.ReceiveReplyEmail != "" {
|
||||
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send follow up answer mail to questioner")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "followup.flash.answered"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
|
||||
}
|
||||
@@ -0,0 +1,787 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package question
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdctx "context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/flamego/recaptcha"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/wuhan005/govalid"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/captcha"
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/dbutil"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/storage"
|
||||
templatepkg "github.com/tamakyi/TamaBox/internal/template"
|
||||
)
|
||||
|
||||
func Pager(ctx context.Context) {
|
||||
domain := ctx.Param("domain")
|
||||
|
||||
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrUserNotExists) {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get user by domain")
|
||||
ctx.SetInternalError()
|
||||
}
|
||||
ctx.Success("question/page")
|
||||
return
|
||||
}
|
||||
ctx.Map(pageUser)
|
||||
|
||||
// 个人页语言:永远跟随提问箱主人(pageUser)配置的语言,忽略访客 cookie;未配置时回退站点默认语言
|
||||
if pageUser.Language != "" {
|
||||
lang := i18n.Resolve(pageUser.Language)
|
||||
ctx.Data["Lang"] = lang
|
||||
ctx.Data["LangCode"] = lang
|
||||
}
|
||||
|
||||
// 页面密码检查
|
||||
if pageUser.PagePassword != "" && !(ctx.IsLogged && ctx.User.ID == pageUser.ID) {
|
||||
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
|
||||
if authorized, _ := ctx.Session.Get(passKey).(bool); !authorized {
|
||||
// 记录原始请求地址(含 query,如带 token 的单题链接),验证通过后跳回此处而非默认首页
|
||||
ctx.Data["Next"] = ctx.Request().URL.RequestURI()
|
||||
ctx.Data["PageUser"] = pageUser
|
||||
ctx.Success("question/password")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 分页加载公开已回答的问题(首次加载 20 条,置顶优先)
|
||||
// 多取 1 条探测是否仍有下一页,供模板决定「加载更多」按钮的初始可见状态,
|
||||
// 避免内容不足一页时按钮仍然显示、点击后才消失。
|
||||
const pageQuestionsPageSize = 20
|
||||
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
|
||||
Cursor: &dbutil.Cursor{
|
||||
PageSize: pageQuestionsPageSize + 1, // 每页数量 + 1 条探测行
|
||||
},
|
||||
FilterAnswered: true, // 只显示已回答的问题
|
||||
PrivateStatus: 1, // 1 = 仅公开(is_private = false)
|
||||
PinFirst: true, // 置顶的问题排在最前
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions by page id")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/page")
|
||||
return
|
||||
}
|
||||
pageQuestionHasMore := false
|
||||
if len(pageQuestions) > pageQuestionsPageSize {
|
||||
pageQuestions = pageQuestions[:pageQuestionsPageSize]
|
||||
pageQuestionHasMore = true
|
||||
}
|
||||
|
||||
answeredCount, err := db.Questions.Count(ctx.Request().Context(), pageUser.ID, db.GetQuestionsCountOptions{
|
||||
FilterAnswered: true,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count questions")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/page")
|
||||
return
|
||||
}
|
||||
|
||||
// 页面标题语言与页面语言保持一致:Data["Lang"] 已由 Pager 上方逻辑或 Contexter 解析好
|
||||
// (提问箱主人语言 > 站点默认语言 shared.App > i18n.Default;个人页忽略访客 cookie)。
|
||||
titleLang := i18n.Default
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
titleLang = l
|
||||
}
|
||||
ctx.SetTitle(fmt.Sprintf("%s - %s", pageUser.Name, i18n.T(titleLang, "site.title")))
|
||||
|
||||
ctx.Data["IsUserPage"] = true
|
||||
ctx.Data["IsOwnPage"] = ctx.IsLogged && ctx.User.ID == pageUser.ID
|
||||
ctx.Data["PageUser"] = pageUser
|
||||
ctx.Data["PageQuestions"] = pageQuestions
|
||||
ctx.Data["PageQuestionHasMore"] = pageQuestionHasMore
|
||||
ctx.Data["AnsweredCount"] = answeredCount
|
||||
ctx.Data["ChatMaxMessages"] = pageUser.ChatMaxMessages
|
||||
|
||||
// 功能开关(开关 + 定时 + 定量收集)统一由 feature_controls 驱动:
|
||||
// - 提问:关闭后隐藏提问框并提示原因;
|
||||
// - 私聊:关闭后隐藏/禁用私聊入口;
|
||||
// - 投票:关闭后投票 Tab 保留显示但置灰禁用,hover 提示原因(不再隐藏入口)。
|
||||
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
|
||||
ctx.Data["CanAsk"] = askState.Open && (ctx.IsLogged || pageUser.HarassmentSetting != db.HarassmentSettingTypeRegisterOnly)
|
||||
ctx.Data["AskState"] = askState
|
||||
// 二维码下的引导文案随「仅允许注册用户提问」设置联动,避免勾选后仍显示"谁都可以匿名提问"。
|
||||
ctx.Data["RegisterOnlyAsk"] = pageUser.HarassmentSetting == db.HarassmentSettingTypeRegisterOnly
|
||||
if !askState.Open {
|
||||
ctx.Data["AskClosedMessage"] = i18n.FeatureClosedMessage(titleLang, db.FeatureQuestion, askState.Reason)
|
||||
}
|
||||
// 关闭状态下展示更多信息:上次关闭时间 / 下次开放时间(含循环定时)。
|
||||
ctx.Data["AskNextOpenAtStr"] = formatFeatureTime(askState.NextOpenAt)
|
||||
ctx.Data["AskLastCloseAtStr"] = formatFeatureTime(askState.LastCloseAt)
|
||||
|
||||
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
|
||||
ctx.Data["AllowChat"] = chatState.Open
|
||||
ctx.Data["ChatState"] = chatState
|
||||
|
||||
voteState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureVote)
|
||||
ctx.Data["VoteEnabled"] = voteState.Open
|
||||
ctx.Data["VoteState"] = voteState
|
||||
if !voteState.Open {
|
||||
// 投票 Tab 置灰禁用时的原因提示(手动关闭 / 未到开放时间 / 已结束 / 已达上限)。
|
||||
ctx.Data["VoteClosedMessage"] = i18n.FeatureClosedMessage(titleLang, db.FeatureVote, voteState.Reason)
|
||||
}
|
||||
|
||||
// 个人主页初始 Tab:默认提问;支持 ?tab=votes 直达投票面板(可分享链接)。
|
||||
// hash(#votes)优先级更高,由前端 JS 在渲染后覆盖。
|
||||
// 投票功能关闭时投票 Tab 置灰不可点,直达链接 ?tab=votes 回落到提问 Tab,
|
||||
// 否则会渲染出一个所有面板都隐藏的空页面。
|
||||
activeTab := ctx.Query("tab")
|
||||
if activeTab != "votes" || !voteState.Open {
|
||||
activeTab = "questions"
|
||||
}
|
||||
ctx.Data["ActiveProfileTab"] = activeTab
|
||||
|
||||
// 匿名投票板块:该箱主"进行中"的公开投票(个人主页投票 Tab 展示,最多 20 条)。
|
||||
// 箱主关闭投票功能时不展示任何投票(也不再浪费一次查询)。
|
||||
pageVotes := []*db.Vote{}
|
||||
if voteState.Open {
|
||||
pageVotes, err = db.Votes.ListPublishedPublicByUserID(ctx.Request().Context(), pageUser.ID, 20)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to list page votes")
|
||||
pageVotes = []*db.Vote{}
|
||||
}
|
||||
}
|
||||
ctx.Data["PageVotes"] = pageVotes
|
||||
|
||||
// 设置下一页游标(如果有数据)。置顶优先场景下必须使用复合键集游标,
|
||||
// 否则置顶项与非置顶项 id 交错会造成"加载更多"漏掉介于其间的非置顶项。
|
||||
if len(pageQuestions) > 0 {
|
||||
last := pageQuestions[len(pageQuestions)-1]
|
||||
ctx.Data["PageQuestionCursor"] = db.EncodePinCursor(last.IsPinned, last.CreatedAt, last.ID)
|
||||
} else {
|
||||
ctx.Data["PageQuestionCursor"] = ""
|
||||
}
|
||||
}
|
||||
|
||||
// conversationWithFirstMessage 用于在列表展示私聊时附带第一条消息
|
||||
type conversationWithFirstMessage struct {
|
||||
Conversation *db.Conversation
|
||||
FirstMessage string
|
||||
}
|
||||
|
||||
// formatFeatureTime 格式化功能开放/关闭时间供模板展示;nil 返回空串。
|
||||
func formatFeatureTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func PasswordPage(ctx context.Context) {
|
||||
domain := ctx.Param("domain")
|
||||
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
ctx.Data["PageUser"] = pageUser
|
||||
// 直接访问密码页(无"上一页")时,验证后回首页
|
||||
ctx.Data["Next"] = "/_/" + domain
|
||||
// 密码页同样遵循提问箱主人配置的语言
|
||||
if pageUser.Language != "" {
|
||||
lang := i18n.Resolve(pageUser.Language)
|
||||
ctx.Data["Lang"] = lang
|
||||
ctx.Data["LangCode"] = lang
|
||||
}
|
||||
ctx.Success("question/password")
|
||||
}
|
||||
|
||||
func VerifyPagePassword(ctx context.Context) {
|
||||
domain := ctx.Param("domain")
|
||||
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
password := ctx.Request().FormValue("password")
|
||||
if pageUser.CheckPagePassword(password) {
|
||||
passKey := fmt.Sprintf("page_pass_%d", pageUser.ID)
|
||||
ctx.Session.Set(passKey, true)
|
||||
// 验证成功后跳回原始页面(如带 token 的单题链接),避免一律回到首页
|
||||
next := ctx.Request().FormValue("next")
|
||||
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.Contains(next, "://") {
|
||||
next = "/_/" + domain
|
||||
}
|
||||
ctx.Redirect(next)
|
||||
} else {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errWrongPassword"))
|
||||
ctx.Redirect("/_/" + domain + "/password")
|
||||
}
|
||||
}
|
||||
|
||||
func List(ctx context.Context) {
|
||||
ctx.Success("question/list")
|
||||
}
|
||||
|
||||
func ListAPI(ctx context.Context) error {
|
||||
domain := ctx.Param("domain")
|
||||
pageSize := ctx.QueryInt("page_size")
|
||||
cursorValue := ctx.Query("cursor")
|
||||
|
||||
// 规范化每页大小,并多取 1 条用于探测是否仍有下一页(has_more),
|
||||
// 使前端在加载完最后一页后能立即隐藏「加载更多」按钮,
|
||||
// 而不是依赖下一次点击返回空列表来收尾(避免最后一页重复展示)。
|
||||
if pageSize < 1 {
|
||||
pageSize = dbutil.DefaultPageSize
|
||||
}
|
||||
if pageSize > dbutil.MaxDefaultPageSize-1 {
|
||||
pageSize = dbutil.MaxDefaultPageSize - 1
|
||||
}
|
||||
|
||||
pageUser, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrUserNotExists) {
|
||||
return ctx.JSONError(40400, i18n.T(ctx.Lang(), "auth.errUserNotFound"))
|
||||
}
|
||||
return ctx.ServerError()
|
||||
}
|
||||
|
||||
pageQuestions, err := db.Questions.GetByUserID(ctx.Request().Context(), pageUser.ID, db.GetQuestionsByUserIDOptions{
|
||||
Cursor: &dbutil.Cursor{
|
||||
Value: cursorValue,
|
||||
PageSize: pageSize + 1,
|
||||
},
|
||||
FilterAnswered: true,
|
||||
PrivateStatus: 1, // 只返回公开问题
|
||||
PinFirst: true,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get questions by page id")
|
||||
return ctx.ServerError()
|
||||
}
|
||||
|
||||
// 实际取回条数超过 pageSize 说明还有下一页;裁掉多取的探测行。
|
||||
hasMore := false
|
||||
if len(pageQuestions) > pageSize {
|
||||
pageQuestions = pageQuestions[:pageSize]
|
||||
hasMore = true
|
||||
}
|
||||
|
||||
// 列表 API 返回纯文本内容(剥离 Markdown 符号/排版,与首屏展示一致)
|
||||
for _, q := range pageQuestions {
|
||||
q.Content = templatepkg.PlainText(q.Content)
|
||||
}
|
||||
|
||||
// 响应直接附带服务端编码好的下一页复合键集游标(与 EncodePinCursor 一致)。
|
||||
// 不在前端用 Date.getTime()*1e6 自行拼游标:JS 双精度浮点无法精确表示纳秒
|
||||
// 级 UnixNano(毫秒以下精度丢失),时间戳相同的提问会在翻页边界被跳过。
|
||||
resp := struct {
|
||||
Data []*db.Question `json:"data"`
|
||||
NextCursor string `json:"next_cursor"`
|
||||
HasMore bool `json:"has_more"`
|
||||
}{Data: pageQuestions, HasMore: hasMore}
|
||||
if len(pageQuestions) > 0 {
|
||||
last := pageQuestions[len(pageQuestions)-1]
|
||||
resp.NextCursor = db.EncodePinCursor(last.IsPinned, last.CreatedAt, last.ID)
|
||||
}
|
||||
return ctx.JSON(resp)
|
||||
}
|
||||
|
||||
func New(ctx context.Context, f form.NewQuestion, pageUser *db.User, recaptcha recaptcha.RecaptchaV3) {
|
||||
|
||||
// 提问密码检查
|
||||
if pageUser.AskPassword != "" {
|
||||
if !pageUser.CheckAskPassword(f.AskPassword) {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errAskPasswordWrong")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 提问功能开关 / 定时 / 定量:任一条件不满足即拒绝普通提问(纵深防御,页面入口已隐藏表单)。
|
||||
// 私聊请求不受影响:提问与私聊是相互独立的功能,提问关闭不应阻断已开启的私聊。
|
||||
isChatRequest := f.StartChat == "true" && ctx.IsLogged
|
||||
askState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion)
|
||||
if !askState.Open && !isChatRequest {
|
||||
lang := i18n.Default
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
lang = l
|
||||
}
|
||||
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureQuestion, askState.Reason))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
if !ctx.IsLogged && pageUser.HarassmentSetting == db.HarassmentSettingTypeRegisterOnly {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errLoginRequired"))
|
||||
ctx.Redirect(fmt.Sprintf("/login?to=%s", ctx.Request().Request.RequestURI))
|
||||
return
|
||||
}
|
||||
|
||||
// ---------- 私聊处理 ----------
|
||||
if f.StartChat == "true" && ctx.IsLogged {
|
||||
|
||||
if ctx.User.ID == pageUser.ID {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errSelfChat"))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 私聊功能开关 / 定时 / 定量
|
||||
chatState := db.FeatureStateOf(ctx.Request().Context(), pageUser.ID, db.FeatureChat)
|
||||
if !chatState.Open {
|
||||
lang := i18n.Default
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
lang = l
|
||||
}
|
||||
ctx.SetErrorFlash(i18n.FeatureClosedMessage(lang, db.FeatureChat, chatState.Reason))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否被对方拉黑
|
||||
blocked, err := db.Users.IsUserBlocked(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Failed to check block status")
|
||||
}
|
||||
if blocked {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errBlockedChat"))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查每日消息限制
|
||||
if pageUser.DailyMessageLimit > 0 {
|
||||
todayCount, err := db.Users.CountTodayMessagesFromUser(ctx.Request().Context(), pageUser.ID, ctx.User.ID)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Error("Failed to count today messages")
|
||||
}
|
||||
if todayCount >= int64(pageUser.DailyMessageLimit) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errChatDailyLimit", pageUser.DailyMessageLimit))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
// 私聊首条消息长度上限:与对话内后续消息一致(conf.Limits.ChatMessageLen,
|
||||
// [limits] 配置段)。此分支与提问共用同一 content 字段,长度不能按提问上限约束,
|
||||
// 必须在入口按聊天语义单独校验。
|
||||
if utf8.RuneCountInString(f.Content) > conf.Limits.ChatMessageLen {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errTooLong", conf.Limits.ChatMessageLen))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 箱主全局屏蔽词(提问/私聊/投票共用一份词表):首条消息同样拦截,
|
||||
// 防止通过发起私聊绕过提问入口的屏蔽词校验(发送者是对方,非箱主本人)。
|
||||
if _, hit := shared.BlockWordHit(pageUser.BlockWords, f.Content); hit {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "chat.errBlockWords"))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 私聊同样需要验证码校验(防止机器人批量发起私聊刷骚扰信息)
|
||||
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 同 IP + 相同内容重复拦截(防刷屏)
|
||||
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(shared.ResolveClientIP(ctx.Request().Request), f.Content)) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errDuplicateSubmit"))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 内容安全检测
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), f.Content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
ctx.SetError(errors.New(censorResponse.ErrorMessage()), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
maxMessages := pageUser.ChatMaxMessages
|
||||
if maxMessages <= 0 {
|
||||
// 存量兜底(老数据 / 极端配置):回落到系统硬顶 conf.Limits.ChatMaxMessages
|
||||
maxMessages = conf.Limits.ChatMaxMessages
|
||||
}
|
||||
|
||||
conv, err := db.Conversations.Create(ctx.Request().Context(), db.CreateConversationOptions{
|
||||
OwnerID: pageUser.ID,
|
||||
AskerID: ctx.User.ID,
|
||||
MaxMessages: maxMessages,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create conversation")
|
||||
ctx.SetInternalError(f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.Messages.Create(ctx.Request().Context(), db.CreateMessageOptions{
|
||||
ConversationID: conv.ID,
|
||||
SenderID: ctx.User.ID,
|
||||
Content: f.Content,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create first message")
|
||||
ctx.SetInternalError(f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Conversations.IncrementMessageCount(ctx.Request().Context(), conv.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to increment message count")
|
||||
}
|
||||
|
||||
// 私聊定量收集:新建会话即计一条(后续往来消息不重复计数)
|
||||
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureChat, 1); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment chat quota")
|
||||
}
|
||||
|
||||
// 请求态数据提前提取,避免 goroutine 在请求结束后访问 ctx.User
|
||||
askerName := ctx.User.Name
|
||||
askerEmail := ctx.User.Email
|
||||
shared.SafeGo(func() {
|
||||
if pageUser.Notify == db.NotifyTypeEmail {
|
||||
if err := mail.SendNewConversationMail(pageUser.Email, askerName, pageUser.Domain, conv.Token, f.Content); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to owner")
|
||||
}
|
||||
if err := mail.SendNewConversationMail(askerEmail, askerName, pageUser.Domain, conv.Token, f.Content); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new conversation mail to asker")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
chatURL := fmt.Sprintf("/_/%s/chat/%s", pageUser.Domain, conv.Token)
|
||||
chatAbsURL := fmt.Sprintf("%s%s", strings.TrimRight(conf.App.ExternalURL, "/"), chatURL)
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.chatSent", chatAbsURL))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// ---------- 普通提问 ----------
|
||||
|
||||
// 提问内容长度上限(conf.Limits.QuestionContentLen,[limits] 配置段),
|
||||
// 与前端提问框 maxlength 同源。旧实现依赖 govalid maxlen 标签(写死 2000,
|
||||
// 无法随配置调整),现移到此处以 rune 计数校验。
|
||||
if utf8.RuneCountInString(f.Content) > conf.Limits.QuestionContentLen {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.question.contentTooLong", conf.Limits.QuestionContentLen)), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
var receiveReplyEmail string
|
||||
if f.ReceiveReplyViaEmail != "" {
|
||||
if errs, ok := govalid.Check(struct {
|
||||
Email string `valid:"required;email" label:"邮箱地址" msg:"form.email.required"`
|
||||
}{
|
||||
Email: f.ReceiveReplyEmail,
|
||||
}); !ok {
|
||||
ctx.SetError(errs[0], f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
receiveReplyEmail = f.ReceiveReplyEmail
|
||||
}
|
||||
|
||||
if ctx.HasError() {
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
// Check captcha code (unified: recaptcha, turnstile, or math).
|
||||
ok, err := captcha.Verify(f.Recaptcha, shared.ResolveClientIP(ctx.Request().Request), ctx.Session, recaptcha, ctx.Lang())
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to check captcha")
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "captcha.error"))
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
content := f.Content
|
||||
isPrivate := f.IsPrivate != ""
|
||||
|
||||
// 统一走 shared.ResolveClientIP:仅 trust_proxy=true 时信任代理头,防止伪造 IP 混入审计记录
|
||||
fromIP := shared.ResolveClientIP(ctx.Request().Request)
|
||||
|
||||
// 同 IP + 相同内容重复拦截(防刷屏)
|
||||
if !shared.DuplicateGuard.Allow(shared.DuplicateKey(fromIP, content)) {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errDuplicateSubmit")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
// 箱主全局屏蔽词(逗号分隔,提问/私聊/投票共用一份词表):命中即拒绝。
|
||||
if _, hit := shared.BlockWordHit(pageUser.BlockWords, content); hit {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errBlockWords")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), content)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行,
|
||||
// 避免审核服务宕机期间提问内容免审直接公开。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
errorMessage := censorResponse.ErrorMessage()
|
||||
ctx.SetError(errors.New(errorMessage), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
// 统一走 shared.ResolveClientIP(已在重复拦截处解析,此处复用)
|
||||
var askerUserID uint
|
||||
if ctx.IsLogged {
|
||||
askerUserID = ctx.User.ID
|
||||
}
|
||||
|
||||
question, err := db.Questions.Create(ctx.Request().Context(), db.CreateQuestionOptions{
|
||||
FromIP: fromIP,
|
||||
UserID: pageUser.ID,
|
||||
Content: content,
|
||||
ReceiveReplyEmail: receiveReplyEmail,
|
||||
AskerUserID: askerUserID,
|
||||
IsPrivate: isPrivate,
|
||||
})
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to create new question")
|
||||
ctx.SetInternalError(f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
|
||||
// 提问定量收集:一条提问计一次(含私密提问);追问不重复计数
|
||||
if err := db.FeatureControls.IncrementQuota(ctx.Request().Context(), pageUser.ID, db.FeatureQuestion, 1); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Warn("Failed to increment question quota")
|
||||
}
|
||||
|
||||
if len(f.Images) > 0 {
|
||||
image := f.Images[0]
|
||||
if err := uploadImage(ctx, uploadImageOptions{
|
||||
Type: db.UploadImageQuestionTypeAsk,
|
||||
Image: image,
|
||||
QuestionID: question.ID,
|
||||
UploaderUserID: askerUserID,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errImageTooLarge"))
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "question.errUploadImageFailed")), f)
|
||||
ctx.Success("question/list")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
|
||||
ContentCensorMetadata: censorResponse.ToJSON(),
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update question censor result")
|
||||
}
|
||||
|
||||
shared.SafeGo(func() {
|
||||
if pageUser.Notify == db.NotifyTypeEmail {
|
||||
if err := mail.SendNewQuestionMail(pageUser.Email, pageUser.Domain, question.ID, question.Content, question.Token); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to user")
|
||||
}
|
||||
if err := mail.SendNewQuestionMailToUser(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, question.Token); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send new question mail to questioner")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
questionPrivateURL := fmt.Sprintf("/_/%s/%d?t=%s", pageUser.Domain, question.ID, question.Token)
|
||||
questionPrivateAbsURL := fmt.Sprintf("%s%s", strings.TrimRight(conf.App.ExternalURL, "/"), questionPrivateURL)
|
||||
|
||||
// 提问成功页:强提醒保存私密链接/token(尤其针对未留邮箱的匿名提问者)
|
||||
ctx.Data["Question"] = question
|
||||
ctx.Data["PrivateURL"] = questionPrivateURL
|
||||
ctx.Data["PrivateAbsURL"] = questionPrivateAbsURL
|
||||
ctx.Data["HasReceiveEmail"] = receiveReplyEmail != ""
|
||||
titleLang := i18n.Default
|
||||
if l, ok := ctx.Data["Lang"].(string); ok && l != "" {
|
||||
titleLang = l
|
||||
}
|
||||
ctx.SetTitle(i18n.T(titleLang, "success.pageTitle", i18n.T(titleLang, "site.title")))
|
||||
ctx.Success("question/success")
|
||||
}
|
||||
|
||||
type uploadImageOptions struct {
|
||||
Type db.UploadImageQuestionType
|
||||
Image *multipart.FileHeader
|
||||
QuestionID uint
|
||||
UploaderUserID uint
|
||||
IsDeletingPrevious bool
|
||||
}
|
||||
|
||||
var ErrUploadImageSizeTooLarge = errors.New("图片文件大小不能大于 5Mb")
|
||||
|
||||
func uploadImage(ctx context.Context, opts uploadImageOptions) error {
|
||||
imgFile := opts.Image
|
||||
fileName := imgFile.Filename
|
||||
fileExt := filepath.Ext(fileName)
|
||||
fileSize := imgFile.Size
|
||||
if fileSize > 1024*1024*5 {
|
||||
return ErrUploadImageSizeTooLarge
|
||||
}
|
||||
|
||||
uploadImageFile, err := imgFile.Open()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "open image")
|
||||
}
|
||||
defer func() { _ = uploadImageFile.Close() }()
|
||||
|
||||
// 读取原始文件数据并计算 MD5
|
||||
hasher := md5.New()
|
||||
originalData, err := io.ReadAll(io.TeeReader(uploadImageFile, hasher))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "read image data")
|
||||
}
|
||||
fileMd5 := fmt.Sprintf("%x", hasher.Sum(nil))
|
||||
|
||||
// 根据格式决定是否转换为 WebP
|
||||
var uploadData []byte
|
||||
var contentType string
|
||||
var uploadKey string
|
||||
|
||||
// 检测原始格式(解码前先校验像素总量,防止解压炸弹)
|
||||
if err := imagecheck.CheckPixelLimit(bytes.NewReader(originalData)); err != nil {
|
||||
if errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||||
return imagecheck.ErrImageTooLarge
|
||||
}
|
||||
return errors.Wrap(err, "decode image config")
|
||||
}
|
||||
|
||||
_, format, err := image.Decode(bytes.NewReader(originalData))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "decode image")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch format {
|
||||
case "gif":
|
||||
// GIF 保持原样上传,不转换(动画 GIF 需要 cwebp 工具支持)
|
||||
uploadData = originalData
|
||||
contentType = "image/gif"
|
||||
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
|
||||
default:
|
||||
// JPEG / PNG 等静态图片转换为 WebP
|
||||
webpData, err := storage.ConvertStaticImageToWebP(bytes.NewReader(originalData))
|
||||
if err != nil {
|
||||
// 转换失败时回退为原始数据
|
||||
logrus.WithError(err).Warn("WebP conversion failed, falling back to original format")
|
||||
uploadData = originalData
|
||||
contentType = contentTypeFromFormat(format)
|
||||
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), fileExt)
|
||||
} else {
|
||||
uploadData = webpData
|
||||
contentType = "image/webp"
|
||||
// WebP 无后缀名,与 UploadPictureToS3 风格一致
|
||||
uploadKey = fmt.Sprintf("%d/%d/%d%s", now.Year(), now.Month(), now.UnixNano(), ".webp")
|
||||
}
|
||||
}
|
||||
|
||||
client, err := storage.GetS3Client()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "get S3 client")
|
||||
}
|
||||
if _, err := client.PutObject(ctx.Request().Context(), &s3.PutObjectInput{
|
||||
Bucket: aws.String(conf.Upload.ImageBucket),
|
||||
Key: aws.String(uploadKey),
|
||||
Body: bytes.NewReader(uploadData),
|
||||
ContentLength: aws.Int64(int64(len(uploadData))),
|
||||
ContentType: aws.String(contentType),
|
||||
}); err != nil {
|
||||
return errors.Wrap(err, "put object")
|
||||
}
|
||||
|
||||
_, err = db.UploadImages.Create(ctx.Request().Context(), db.CreateUploadImageOptions{
|
||||
Type: opts.Type,
|
||||
QuestionID: opts.QuestionID,
|
||||
UploaderUserID: opts.UploaderUserID,
|
||||
Name: fileName,
|
||||
FileSize: int64(len(uploadData)),
|
||||
Md5: fileMd5,
|
||||
Key: uploadKey,
|
||||
IsDeletingPrevious: opts.IsDeletingPrevious,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create upload image")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// contentTypeFromFormat 根据图片格式返回 MIME 类型
|
||||
func contentTypeFromFormat(format string) string {
|
||||
switch format {
|
||||
case "jpeg":
|
||||
return "image/jpeg"
|
||||
case "png":
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
// Copyright 2022 E99p1ant. All rights reserved.
|
||||
// Use of this source code is governed by a MIT-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package question
|
||||
|
||||
import (
|
||||
stdctx "context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/form"
|
||||
"github.com/tamakyi/TamaBox/internal/i18n"
|
||||
"github.com/tamakyi/TamaBox/internal/imagecheck"
|
||||
"github.com/tamakyi/TamaBox/internal/mail"
|
||||
"github.com/tamakyi/TamaBox/internal/security/censor"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
)
|
||||
|
||||
func Questioner(ctx context.Context, pageUser *db.User) {
|
||||
// 从 URL query 读取 token;追问等 POST 请求 token 在表单隐藏域中,需回退读取表单字段。
|
||||
// FormValue 同时覆盖 query 与 body(含 multipart),对 GET 请求行为与 Query 一致。
|
||||
token := ctx.Query("t")
|
||||
if token == "" {
|
||||
token = ctx.Request().FormValue("token")
|
||||
}
|
||||
questionID := uint(ctx.ParamInt("questionID"))
|
||||
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, db.ErrQuestionNotExist) {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get question by ID")
|
||||
}
|
||||
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
ctx.Data["Question"] = question
|
||||
|
||||
askUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAsk, questionID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get ask upload images")
|
||||
}
|
||||
ctx.Data["AskUploadImages"] = askUploadImages
|
||||
|
||||
answerUploadImages, err := db.UploadImages.GetByTypeQuestionID(ctx.Request().Context(), db.UploadImageQuestionTypeAnswer, questionID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get answer upload images")
|
||||
}
|
||||
ctx.Data["AnswerUploadImages"] = answerUploadImages
|
||||
|
||||
// Check the question is belongs to the correct page user.
|
||||
// If the question has not been answered, we should check the question is belongs to the correct page user.
|
||||
// The questioner can use the token to view the question.
|
||||
// 注意:token 为空的未回答/私密问题同样禁止匿名访问(不能因 token 为空而放行)。
|
||||
if question.UserID != pageUser.ID ||
|
||||
((question.Answer == "" || question.IsPrivate) &&
|
||||
(!ctx.IsLogged || ctx.User.ID != question.UserID) &&
|
||||
!(question.Token != "" && question.Token == token)) {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// The page's owner or the question's token can have the permission to delete the question.
|
||||
// Inject the permission into the context.
|
||||
canDelete := ctx.IsLogged && ctx.User.ID == pageUser.ID
|
||||
ctx.Map(canDelete)
|
||||
ctx.Data["CanDelete"] = canDelete
|
||||
|
||||
// ---------- 追问 ----------
|
||||
followUps, err := db.FollowUps.GetByQuestionID(ctx.Request().Context(), questionID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get follow ups")
|
||||
followUps = []*db.FollowUp{}
|
||||
}
|
||||
ctx.Data["FollowUps"] = followUps
|
||||
|
||||
// 提问者身份:token 匹配(匿名)或登录账号匹配(登录用户)
|
||||
isQuestioner := isQuestioner(ctx, question, token)
|
||||
ctx.Data["IsQuestioner"] = isQuestioner
|
||||
|
||||
// 是否可追问:已回答 + 提问者 + 追问次数未达上限
|
||||
followUpRemaining := 0
|
||||
if isQuestioner && question.Answer != "" {
|
||||
count, err := db.FollowUps.CountByQuestionID(ctx.Request().Context(), questionID)
|
||||
if err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to count follow ups")
|
||||
} else {
|
||||
followUpRemaining = db.MaxFollowUpCount - int(count)
|
||||
}
|
||||
}
|
||||
// 是否可追问:已回答 + 提问者 + 追问次数未达上限 + 上一条追问已被主人回复
|
||||
// (主人尚未回复上一条追问时,提问者不应继续发起新的追问)
|
||||
// 等待提示同样仅对提问者展示——主人/旁观者看到的应是回复框或正常内容。
|
||||
waitingFollowUpReply := false
|
||||
if isQuestioner && len(followUps) > 0 && followUps[len(followUps)-1].Answer == "" {
|
||||
waitingFollowUpReply = true
|
||||
}
|
||||
canFollowUp := isQuestioner && question.Answer != "" && followUpRemaining > 0 && !waitingFollowUpReply
|
||||
ctx.Data["CanFollowUp"] = canFollowUp
|
||||
ctx.Data["WaitingFollowUpReply"] = waitingFollowUpReply
|
||||
ctx.Data["FollowUpRemaining"] = followUpRemaining
|
||||
ctx.Data["FollowUpLimit"] = db.MaxFollowUpCount
|
||||
// 供追问表单隐藏域回传(匿名提问者必须携带 token)
|
||||
ctx.Data["QuestionToken"] = token
|
||||
|
||||
// 返回来源:admin=从管理后台(/user/questions)进入;空=从提问箱主页(/_/domain)或直接访问,
|
||||
// 默认返回提问箱主页。详情页底部"返回xxx的提问箱"按钮据此决定跳转目标。
|
||||
backFrom := ctx.Query("from")
|
||||
ctx.Data["BackFrom"] = backFrom
|
||||
// 从后台返回时,把进入时携带的筛选参数(sort/status/type/read/q)原样回传,
|
||||
// 使"返回"跳回带筛选条件的后台列表,避免丢失已选的筛选/搜索条件。
|
||||
if backFrom == "admin" {
|
||||
backQuery := url.Values{}
|
||||
for _, key := range []string{"sort", "status", "type", "read", "q"} {
|
||||
if v := ctx.Query(key); v != "" {
|
||||
backQuery.Set(key, v)
|
||||
}
|
||||
}
|
||||
backTo := "/user/questions"
|
||||
if qs := backQuery.Encode(); qs != "" {
|
||||
backTo += "?" + qs
|
||||
}
|
||||
ctx.Data["BackTo"] = backTo
|
||||
}
|
||||
|
||||
ctx.Map(question)
|
||||
}
|
||||
|
||||
func Item(ctx context.Context) {
|
||||
ctx.Success("question/item")
|
||||
}
|
||||
|
||||
func PublishAnswer(ctx context.Context, pageUser *db.User, question *db.Question, f form.PublishAnswerQuestion) {
|
||||
if ctx.HasError() {
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
// 回答内容长度上限(conf.Limits.AnswerContentLen,[limits] 配置段),
|
||||
// 与前端回答框 maxlength 同源(发布与编辑共用,rune 计数)。
|
||||
if utf8.RuneCountInString(f.Answer) > conf.Limits.AnswerContentLen {
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "form.answer.contentTooLong", conf.Limits.AnswerContentLen)), f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
if ctx.User.ID != pageUser.ID {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// 纵深防御:确认该提问归属当前页面箱主(Questioner 中间件已保证,这里二次校验防中间件改动引入越权)。
|
||||
if question.UserID != pageUser.ID {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
answer := f.Answer
|
||||
|
||||
// 🚨 Content security check.
|
||||
censorResponse, err := censor.Text(ctx.Request().Context(), answer)
|
||||
if err != nil {
|
||||
// 审核源全部不可用:fail-closed,拒绝发布而非免审放行。
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to censor text")
|
||||
ctx.SetError(errors.New(i18n.T(ctx.Lang(), "censor.unavailable")), f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
if !censorResponse.Pass {
|
||||
errorMessage := censorResponse.ErrorMessage()
|
||||
ctx.SetError(errors.New(errorMessage), f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
if len(f.Images) > 0 {
|
||||
image := f.Images[0]
|
||||
|
||||
if err := uploadImage(ctx, uploadImageOptions{
|
||||
Type: db.UploadImageQuestionTypeAnswer,
|
||||
Image: image,
|
||||
QuestionID: question.ID,
|
||||
UploaderUserID: ctx.User.ID,
|
||||
IsDeletingPrevious: true,
|
||||
}); err != nil {
|
||||
if errors.Is(err, ErrUploadImageSizeTooLarge) || errors.Is(err, imagecheck.ErrImageTooLarge) {
|
||||
ctx.SetErrorFlash(err.Error())
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
} else {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to upload image")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Questions.AnswerByID(ctx.Request().Context(), question.ID, f.Answer); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to answer question")
|
||||
ctx.SetInternalError(f)
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
// Update censor result.
|
||||
if err := db.Questions.UpdateCensor(ctx.Request().Context(), question.ID, db.UpdateQuestionCensorOptions{
|
||||
AnswerCensorMetadata: censorResponse.ToJSON(),
|
||||
}); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update answer censor result")
|
||||
}
|
||||
|
||||
shared.SafeGo(func() {
|
||||
if question.ReceiveReplyEmail != "" && question.Answer == "" { // We only send the email when the question has not been answered.
|
||||
// Send notification to questioner.
|
||||
if err := mail.SendNewAnswerMail(question.ReceiveReplyEmail, pageUser.Domain, question.ID, question.Content, f.Answer, question.Token); err != nil {
|
||||
logrus.WithContext(stdctx.Background()).WithError(err).Error("Failed to send receive reply mail to questioner")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.answered"))
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
|
||||
}
|
||||
|
||||
func Delete(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
|
||||
if !canDelete {
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Questions.DeleteByID(ctx.Request().Context(), question.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to delete question")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
}
|
||||
|
||||
func SetPrivate(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
|
||||
if !canDelete {
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Questions.SetPrivate(ctx.Request().Context(), question.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question private")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
|
||||
}
|
||||
|
||||
func SetPublic(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
|
||||
if !canDelete {
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Questions.SetPublic(ctx.Request().Context(), question.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question public")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Redirect(fmt.Sprintf("/_/%s/%d", pageUser.Domain, question.ID))
|
||||
}
|
||||
|
||||
// TogglePin 置顶/取消置顶(仅提问箱主人,操作入口在我的提问管理页)。
|
||||
func TogglePin(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
|
||||
if !canDelete {
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Questions.SetPinned(ctx.Request().Context(), question.ID, !question.IsPinned); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to toggle question pinned")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.operated"))
|
||||
ctx.Redirect("/user/questions")
|
||||
}
|
||||
|
||||
// SetRead 手动标记提问为已读,跳转回管理列表并定位到该条。
|
||||
func SetRead(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
|
||||
if !canDelete {
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Questions.SetRead(ctx.Request().Context(), question.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question read")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedRead"))
|
||||
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
|
||||
}
|
||||
|
||||
// SetUnread 手动标记提问为未读。已回答的提问锁定为已读,不可改回未读。
|
||||
// 跳转与 SetRead 一致,回到管理列表并定位到该条,保证"已读/未读"操作路径统一。
|
||||
func SetUnread(ctx context.Context, pageUser *db.User, question *db.Question, canDelete bool) {
|
||||
if !canDelete {
|
||||
ctx.Redirect("/_/" + pageUser.Domain)
|
||||
return
|
||||
}
|
||||
|
||||
// 已回答的问题锁定为已读:上层直接拦截,避免误导用户。
|
||||
if question.Answer != "" {
|
||||
ctx.SetErrorFlash(i18n.T(ctx.Lang(), "question.errAnsweredLocked"))
|
||||
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
|
||||
return
|
||||
}
|
||||
|
||||
if err := db.Questions.SetUnread(ctx.Request().Context(), question.ID); err != nil {
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to set question unread")
|
||||
ctx.SetInternalError()
|
||||
ctx.Success("question/item")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSuccessFlash(i18n.T(ctx.Lang(), "question.flash.markedUnread"))
|
||||
ctx.Redirect(fmt.Sprintf("/user/questions#question-%d", question.ID))
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
package question
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
stdctx "context"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/golang/freetype"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/sirupsen/logrus"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/tamakyi/TamaBox/internal/conf"
|
||||
"github.com/tamakyi/TamaBox/internal/context"
|
||||
"github.com/tamakyi/TamaBox/internal/db"
|
||||
"github.com/tamakyi/TamaBox/internal/shared"
|
||||
"github.com/tamakyi/TamaBox/internal/template"
|
||||
)
|
||||
|
||||
// 卡片尺寸与留白
|
||||
const (
|
||||
CardWidth = 960
|
||||
CardHeight = 540
|
||||
Margin = 15
|
||||
)
|
||||
|
||||
// 内容区域边界
|
||||
const (
|
||||
ContentX = Margin
|
||||
ContentY = Margin
|
||||
ContentW = CardWidth - 2*Margin
|
||||
ContentH = CardHeight - 2*Margin
|
||||
)
|
||||
|
||||
// 头像参数
|
||||
const (
|
||||
AvatarSize = 112
|
||||
AvatarCX = ContentX + 90
|
||||
AvatarCY = ContentY + 90
|
||||
AvatarRingW = 6
|
||||
AvatarRingR = AvatarSize/2 + AvatarRingW
|
||||
)
|
||||
|
||||
// 左侧文字区域
|
||||
const (
|
||||
TextStartX = AvatarCX + AvatarRingR + 30
|
||||
TextStartY = ContentY + 75
|
||||
)
|
||||
|
||||
// 右侧二维码卡片尺寸
|
||||
const (
|
||||
QRCardW = 270
|
||||
QRCardH = 315
|
||||
QRCardX = ContentX + ContentW - QRCardW - 15
|
||||
QRCardY = ContentY + (ContentH-QRCardH)/2
|
||||
)
|
||||
|
||||
// 二维码尺寸
|
||||
const (
|
||||
QRCodeSize = 165
|
||||
QRCodeX = (QRCardW - QRCodeSize) / 2
|
||||
)
|
||||
|
||||
// 社交信息
|
||||
const (
|
||||
SocialIconW = 24
|
||||
SocialIconH = 24
|
||||
SocialTextX = TextStartX + SocialIconW + 8
|
||||
SocialLineH = 32
|
||||
)
|
||||
|
||||
// 底部水印
|
||||
const (
|
||||
FooterY = ContentY + ContentH - 20
|
||||
)
|
||||
|
||||
// 字体大小
|
||||
const (
|
||||
FontSizeName = 36
|
||||
FontSizeIntro = 20
|
||||
FontSizeLink = 15
|
||||
FontSizeSocial = 17
|
||||
FontSizeQRHint = 17
|
||||
FontSizeFooter = 14
|
||||
)
|
||||
|
||||
// 颜色系统
|
||||
var (
|
||||
ColorWhite = color.RGBA{255, 255, 255, 255}
|
||||
ColorBlack = color.RGBA{0, 0, 0, 255}
|
||||
ColorDarkText = color.RGBA{30, 30, 30, 255}
|
||||
ColorGrayText = color.RGBA{100, 100, 100, 255}
|
||||
ColorSocialGray = color.RGBA{80, 80, 80, 255}
|
||||
ColorHintGray = color.RGBA{90, 90, 90, 255}
|
||||
ColorFooterGray = color.RGBA{150, 150, 150, 255}
|
||||
OverlayColor = color.NRGBA{R: 255, G: 255, B: 255, A: 165}
|
||||
)
|
||||
|
||||
// 全局字体缓存
|
||||
var (
|
||||
fontOnce sync.Once
|
||||
fontCache *truetype.Font
|
||||
fontErr error
|
||||
)
|
||||
|
||||
func getFont() (*truetype.Font, error) {
|
||||
fontOnce.Do(func() {
|
||||
fontBytes, err := os.ReadFile(conf.App.FontPath)
|
||||
if err != nil {
|
||||
fontErr = err
|
||||
return
|
||||
}
|
||||
fontCache, fontErr = freetype.ParseFont(fontBytes)
|
||||
})
|
||||
return fontCache, fontErr
|
||||
}
|
||||
|
||||
// shareDialer 与 shareDialContext:SSRF 防护——解析域名后校验所有解析 IP 均非内网,
|
||||
// 再交由 shared.DialFirstReachable 对公网候选 IP 并发拨号取最先成功者(happy-eyeballs 风格)。
|
||||
// 防 DNS rebinding(IP 字面量直连、无二次解析)的同时,避免固定拨单一 IP 抽中劣化边缘
|
||||
// 导致分享卡缺图(分享图源同样在 Cloudflare/R2,链路质量差时多 IP 容错尤为必要)。
|
||||
var shareDialer = &net.Dialer{Timeout: 8 * time.Second, KeepAlive: 30 * time.Second}
|
||||
|
||||
// shareDialMaxCandidates 限制并发拨号扇出上限。
|
||||
const shareDialMaxCandidates = 6
|
||||
|
||||
func shareDialContext(ctx stdctx.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var candidates []net.IP
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip) {
|
||||
return nil, fmt.Errorf("blocked ip address: %s", ip)
|
||||
}
|
||||
candidates = append(candidates, ip)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return nil, fmt.Errorf("no public ip address resolved for %s", host)
|
||||
}
|
||||
return shared.DialFirstReachable(ctx, shareDialer, network, candidates, port, shareDialMaxCandidates)
|
||||
}
|
||||
|
||||
// 全局 HTTP 客户端:连接池复用,避免每次请求都重新建立 TCP/TLS 连接。
|
||||
// 超时预算说明:分享卡图源(头像/背景/社交图标)同样位于 Cloudflare/R2,服务器到该链路
|
||||
// 质量差(实测单次约 6.5s),总预算放宽以容纳多 IP 并发拨号与慢速首字节;R2 图缺失时
|
||||
// 分享卡会静默降级(无头像/背景),宁可等待也不轻易放弃。
|
||||
var shareHTTPClient = &http.Client{
|
||||
Timeout: 20 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 8 * time.Second,
|
||||
ResponseHeaderTimeout: 8 * time.Second,
|
||||
DialContext: shareDialContext,
|
||||
},
|
||||
// SSRF 防护:重定向逐跳校验,防止 302 跳转到内网地址(如 169.254.169.254 云元数据)绕过初始校验
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
if isBlockedImageURL(req.URL.String()) {
|
||||
return fmt.Errorf("blocked redirect to %s", req.URL)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// ttlCache 是一个简单的并发安全带 TTL 缓存。
|
||||
// 带 maxItems 条数上限,超限时先清过期、再淘汰最早过期的一条,
|
||||
// 防止低配服务器(1C1G)上缓存无限膨胀。
|
||||
type ttlCache struct {
|
||||
mu sync.Mutex
|
||||
items map[string]ttlCacheItem
|
||||
maxItems int
|
||||
}
|
||||
|
||||
type ttlCacheItem struct {
|
||||
value interface{}
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newTTLCache(maxItems int) *ttlCache {
|
||||
c := &ttlCache{items: make(map[string]ttlCacheItem), maxItems: maxItems}
|
||||
// 常驻清理循环:每轮迭代单独 recover,panic 后循环不中断
|
||||
go func() {
|
||||
for range time.Tick(5 * time.Minute) {
|
||||
func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logrus.WithField("panic", r).Error("ttlCache cleanup panic recovered")
|
||||
}
|
||||
}()
|
||||
c.cleanup()
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ttlCache) Get(key string) (interface{}, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
item, ok := c.items[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if time.Now().After(item.expiresAt) {
|
||||
delete(c.items, key)
|
||||
return nil, false
|
||||
}
|
||||
return item.value, true
|
||||
}
|
||||
|
||||
func (c *ttlCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// 超限时先清过期,仍满则淘汰最早过期的一条
|
||||
if len(c.items) >= c.maxItems {
|
||||
now := time.Now()
|
||||
for k, item := range c.items {
|
||||
if now.After(item.expiresAt) {
|
||||
delete(c.items, k)
|
||||
}
|
||||
}
|
||||
if len(c.items) >= c.maxItems {
|
||||
var victim string
|
||||
var earliest time.Time
|
||||
first := true
|
||||
for k, item := range c.items {
|
||||
if first || item.expiresAt.Before(earliest) {
|
||||
earliest = item.expiresAt
|
||||
victim = k
|
||||
first = false
|
||||
}
|
||||
}
|
||||
if victim != "" {
|
||||
delete(c.items, victim)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.items[key] = ttlCacheItem{value: value, expiresAt: time.Now().Add(ttl)}
|
||||
}
|
||||
|
||||
func (c *ttlCache) cleanup() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k, item := range c.items {
|
||||
if now.After(item.expiresAt) {
|
||||
delete(c.items, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 远程图片缓存(背景图、头像、社交图标),TTL 1 小时。
|
||||
// 存的是解码后的 image.Image,最吃内存(1280×720 RGBA ≈ 3.7MB/张),
|
||||
// 1C1G 环境下限制为 24 条:正常站点同一时刻活跃用户远小于此值。
|
||||
// 社交图标 URL 固定不变,基本永远命中;头像/背景变更时最多延迟 1 小时生效。
|
||||
var imageCache = newTTLCache(24)
|
||||
|
||||
// 分享图结果缓存,TTL 30 分钟。key 含用户资料指纹,资料变更后自动失效。
|
||||
// 存的是 PNG 编码字节(约 0.2~1MB/张),限制 32 条,总内存 ≤ 32MB。
|
||||
var shareCardCache = newTTLCache(32)
|
||||
|
||||
func ShareCard(ctx context.Context) {
|
||||
domain := ctx.Param("domain")
|
||||
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// 命中缓存直接返回,避免重复下载图片与重新绘制
|
||||
cacheKey := shareCardCacheKey(user)
|
||||
if cached, ok := shareCardCache.Get(cacheKey); ok {
|
||||
writeShareCardResponse(ctx, cached.([]byte))
|
||||
return
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
||||
|
||||
// 1. 背景
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
||||
innerRect := image.Rect(ContentX, ContentY, ContentX+ContentW, ContentY+ContentH)
|
||||
|
||||
// ---- 并发下载所有远程图片 ----
|
||||
var (
|
||||
bgImg image.Image
|
||||
avImg image.Image
|
||||
)
|
||||
icons := []struct {
|
||||
show bool
|
||||
url string
|
||||
icon string
|
||||
}{
|
||||
{user.Acfunlink != "" && user.Acfunlink != "none", user.Acfunlink, "https://r2.337845818.best/tamabox/icon/social/ailicili.png"},
|
||||
{user.Bililink != "" && user.Bililink != "none", user.Bililink, "https://r2.337845818.best/tamabox/icon/social/bili.png"},
|
||||
{user.Weibolink != "" && user.Weibolink != "none", user.Weibolink, "https://r2.337845818.best/tamabox/icon/social/weibo.png"},
|
||||
{user.Bloglink != "" && user.Bloglink != "none", user.Bloglink, "https://r2.337845818.best/tamabox/icon/social/blog.png"},
|
||||
{user.Xiaohongshulink != "" && user.Xiaohongshulink != "none", user.Xiaohongshulink, "https://r2.337845818.best/tamabox/icon/social/xiaohongshu.png"},
|
||||
}
|
||||
iconImgs := make([]image.Image, len(icons))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 背景图
|
||||
bgURL := user.Background
|
||||
if bgURL == "" {
|
||||
bgURL = conf.App.ShareBackground
|
||||
}
|
||||
wg.Add(1)
|
||||
shared.SafeGo(func() {
|
||||
defer wg.Done()
|
||||
img, err := loadImage(bgURL, ContentW, ContentH)
|
||||
if err == nil {
|
||||
bgImg = img
|
||||
}
|
||||
})
|
||||
|
||||
// 头像
|
||||
wg.Add(1)
|
||||
shared.SafeGo(func() {
|
||||
defer wg.Done()
|
||||
img, err := loadImage(user.Avatar, AvatarSize, AvatarSize)
|
||||
if err == nil {
|
||||
avImg = img
|
||||
}
|
||||
})
|
||||
|
||||
// 社交图标
|
||||
for i, item := range icons {
|
||||
if !item.show {
|
||||
continue
|
||||
}
|
||||
idx, iconURL := i, item.icon
|
||||
wg.Add(1)
|
||||
shared.SafeGo(func() {
|
||||
defer wg.Done()
|
||||
img, err := loadImage(iconURL, SocialIconW, SocialIconH)
|
||||
if err == nil {
|
||||
iconImgs[idx] = img
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
// ---- 下载结束 ----
|
||||
|
||||
// 绘制背景
|
||||
if bgImg != nil {
|
||||
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
|
||||
}
|
||||
overlay := image.NewUniform(OverlayColor)
|
||||
draw.Draw(canvas, innerRect, overlay, image.Point{}, draw.Over)
|
||||
|
||||
// 2. 加载字体(复用缓存)
|
||||
ft, err := getFont()
|
||||
if err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for share card")
|
||||
return
|
||||
}
|
||||
fontCtx := freetype.NewContext()
|
||||
fontCtx.SetFont(ft)
|
||||
fontCtx.SetFontSize(24)
|
||||
fontCtx.SetDPI(72)
|
||||
|
||||
// 3. 头像
|
||||
drawCircleFilled(canvas, AvatarCX, AvatarCY, AvatarRingR, ColorWhite)
|
||||
if avImg != nil {
|
||||
avImg = imaging.Fill(avImg, AvatarSize, AvatarSize, imaging.Center, imaging.Lanczos)
|
||||
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
|
||||
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
|
||||
draw.DrawMask(canvas,
|
||||
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
|
||||
avImg, image.Point{}, mask, image.Point{}, draw.Over)
|
||||
} else {
|
||||
mask := image.NewAlpha(image.Rect(0, 0, AvatarSize, AvatarSize))
|
||||
drawCircle(mask, AvatarSize/2, AvatarSize/2, AvatarSize/2, color.Alpha{255})
|
||||
draw.DrawMask(canvas,
|
||||
image.Rect(AvatarCX-AvatarSize/2, AvatarCY-AvatarSize/2, AvatarCX+AvatarSize/2, AvatarCY+AvatarSize/2),
|
||||
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
|
||||
}
|
||||
|
||||
// 4. 左侧文字
|
||||
// 文字区裁剪边界:左侧文字不得侵入右侧二维码卡片(QRCardX 左侧留 20px 间距)。
|
||||
leftClip := image.Rect(TextStartX, ContentY, QRCardX-20, ContentY+ContentH)
|
||||
leftMaxW := QRCardX - TextStartX - 60
|
||||
y := TextStartY
|
||||
drawTextWithShadow(canvas, user.Name, TextStartX, y, FontSizeName, ColorDarkText, color.NRGBA{0, 0, 0, 50}, 1, fontCtx)
|
||||
y += int(FontSizeName) + 15
|
||||
// 简介最多 4 行,超出截断加省略号,避免长简介向下覆盖社交区/水印
|
||||
y = drawTextMultilineLimited(canvas, user.Intro, TextStartX, y, leftMaxW, FontSizeIntro, ColorGrayText, fontCtx, 46, 4, leftClip)
|
||||
y += 30
|
||||
|
||||
// 5. 社交信息
|
||||
socialY := y + 22
|
||||
for i, item := range icons {
|
||||
if !item.show || iconImgs[i] == nil {
|
||||
continue
|
||||
}
|
||||
draw.Draw(canvas,
|
||||
image.Rect(TextStartX, socialY, TextStartX+SocialIconW, socialY+SocialIconH),
|
||||
iconImgs[i], image.Point{}, draw.Over)
|
||||
drawTextClipped(canvas, item.url, SocialTextX, socialY+SocialIconH-6, FontSizeSocial, ColorSocialGray, fontCtx, leftClip)
|
||||
socialY += SocialLineH
|
||||
}
|
||||
|
||||
// 6. 右侧二维码卡片
|
||||
cardRect := image.Rect(QRCardX, QRCardY, QRCardX+QRCardW, QRCardY+QRCardH)
|
||||
cardCenterX := QRCardX + QRCardW/2
|
||||
|
||||
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 18)
|
||||
drawRoundedRect(canvas, cardRect, ColorWhite, 18)
|
||||
|
||||
boxURL := fmt.Sprintf("%s/_/%s", conf.App.ExternalURL, user.Domain)
|
||||
qr, err := generateQR(boxURL, user, avImg)
|
||||
if err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code")
|
||||
return
|
||||
}
|
||||
qrResized := imaging.Resize(qr, QRCodeSize, QRCodeSize, imaging.NearestNeighbor)
|
||||
|
||||
const (
|
||||
qrToHintGap = 26
|
||||
hintLineHeight = 17
|
||||
hintToSepGap = 21
|
||||
sepHeight = 1
|
||||
sepToLinkGap = 21
|
||||
linkLineHeight = 15
|
||||
)
|
||||
totalContentH := QRCodeSize + qrToHintGap + hintLineHeight + hintToSepGap + sepHeight + sepToLinkGap + linkLineHeight
|
||||
startY := QRCardY + (QRCardH-totalContentH)/2
|
||||
|
||||
qrDrawX := QRCardX + QRCodeX
|
||||
qrDrawY := startY
|
||||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QRCodeSize, qrDrawY+QRCodeSize), qrResized, image.Point{}, draw.Over)
|
||||
|
||||
hintY := qrDrawY + QRCodeSize + qrToHintGap + hintLineHeight
|
||||
drawTextCentered(canvas, "扫码向我匿名提问", cardCenterX, hintY, FontSizeQRHint, ColorHintGray, fontCtx)
|
||||
|
||||
sepY := hintY + hintToSepGap
|
||||
drawLine(canvas, cardCenterX-90, sepY, cardCenterX+90, sepY, color.NRGBA{200, 200, 200, 180})
|
||||
|
||||
linkY := sepY + sepToLinkGap + linkLineHeight
|
||||
linkText := boxURL
|
||||
linkFontSize := float64(FontSizeLink)
|
||||
availableW := QRCardW - 45
|
||||
actualW := measureTextWidth(linkText, linkFontSize)
|
||||
if actualW > float64(availableW) {
|
||||
linkFontSize = linkFontSize * float64(availableW) / actualW
|
||||
}
|
||||
drawTextCentered(canvas, linkText, cardCenterX, linkY, linkFontSize, ColorDarkText, fontCtx)
|
||||
|
||||
// 7. 底部水印
|
||||
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, FooterY, FontSizeFooter, ColorFooterGray, fontCtx)
|
||||
|
||||
// 8. 输出(缓存 + 响应)
|
||||
buf := new(bytes.Buffer)
|
||||
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode share card PNG")
|
||||
return
|
||||
}
|
||||
|
||||
shareCardCache.Set(cacheKey, buf.Bytes(), time.Hour)
|
||||
writeShareCardResponse(ctx, buf.Bytes())
|
||||
}
|
||||
|
||||
// writeShareCardResponse 统一输出分享图响应。
|
||||
// 加 Cache-Control 让浏览器与 CDN(如 Cloudflare)缓存,进一步减轻服务器压力。
|
||||
func writeShareCardResponse(ctx context.Context, data []byte) {
|
||||
ctx.ResponseWriter().Header().Set("Content-Type", "image/jpeg")
|
||||
ctx.ResponseWriter().Header().Set("Content-Disposition", "attachment; filename=\"share.jpg\"")
|
||||
ctx.ResponseWriter().Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
ctx.ResponseWriter().Header().Set("Cache-Control", "public, max-age=300")
|
||||
ctx.ResponseWriter().WriteHeader(http.StatusOK)
|
||||
_, _ = ctx.ResponseWriter().Write(data)
|
||||
}
|
||||
|
||||
// shareCardCacheKey 基于用户资料生成缓存指纹。
|
||||
// 资料(名称/简介/头像/背景/社交链接)任何一项变更都会导致 key 变化,缓存自动失效。
|
||||
func shareCardCacheKey(user *db.User) string {
|
||||
h := md5.New()
|
||||
fmt.Fprintf(h, "%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
|
||||
user.Domain, user.Name, user.Intro, user.Avatar, user.Background,
|
||||
user.Acfunlink, user.Bililink, user.Weibolink, user.Bloglink, user.Xiaohongshulink)
|
||||
return fmt.Sprintf("sharecard:%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// ---------- 工具函数 ----------
|
||||
|
||||
// maxImagePixels 限制远程图片解码后的最大像素总量(4096×4096≈67MB RGBA),
|
||||
// 防止恶意超大图耗尽服务器内存。
|
||||
const maxImagePixels = 4096 * 4096
|
||||
|
||||
// isBlockedImageURL SSRF 防护:仅允许 http/https,且主机不能解析到私网/环回等内网地址。
|
||||
func isBlockedImageURL(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return true
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return true
|
||||
}
|
||||
// 直接是 IP 字面量:私网/环回/链路本地/未指定/组播一律拒绝
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return isBlockedIP(ip)
|
||||
}
|
||||
// 域名:解析出任一内网 IP 即拒绝
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
// 解析失败交给 HTTP 层报错(不阻断),避免 DNS 临时故障误伤正常图片
|
||||
return false
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if isBlockedIP(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
|
||||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast()
|
||||
}
|
||||
|
||||
func loadImage(url string, w, h int) (image.Image, error) {
|
||||
// SSRF 防护:拒绝非 http/https 与内网地址(127.0.0.1、10.x、192.168.x 等)
|
||||
if isBlockedImageURL(url) {
|
||||
return nil, fmt.Errorf("blocked image url: %s", url)
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("%s|%dx%d", url, w, h)
|
||||
if cached, ok := imageCache.Get(key); ok {
|
||||
return cached.(image.Image), nil
|
||||
}
|
||||
|
||||
resp, err := shareHTTPClient.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
img, _, err := image.Decode(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解码后限制像素总量,防止超大图耗尽内存
|
||||
b := img.Bounds()
|
||||
if b.Dx()*b.Dy() > maxImagePixels {
|
||||
return nil, fmt.Errorf("image too large: %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
filled := imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
|
||||
imageCache.Set(key, filled, time.Hour)
|
||||
return filled, nil
|
||||
}
|
||||
|
||||
func drawCircle(mask *image.Alpha, cx, cy, r int, c color.Alpha) {
|
||||
for x := -r; x <= r; x++ {
|
||||
for y := -r; y <= r; y++ {
|
||||
if x*x+y*y <= r*r {
|
||||
mask.SetAlpha(cx+x, cy+y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawCircleFilled(dst *image.RGBA, cx, cy, r int, clr color.Color) {
|
||||
for x := -r; x <= r; x++ {
|
||||
for y := -r; y <= r; y++ {
|
||||
if x*x+y*y <= r*r {
|
||||
dst.Set(cx+x, cy+y, clr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawRoundedRect(dst *image.RGBA, rect image.Rectangle, clr color.Color, radius int) {
|
||||
draw.Draw(dst, image.Rect(rect.Min.X+radius, rect.Min.Y, rect.Max.X-radius, rect.Max.Y),
|
||||
&image.Uniform{clr}, image.Point{}, draw.Over)
|
||||
draw.Draw(dst, image.Rect(rect.Min.X, rect.Min.Y+radius, rect.Max.X, rect.Max.Y-radius),
|
||||
&image.Uniform{clr}, image.Point{}, draw.Over)
|
||||
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Min.Y+radius, radius, clr)
|
||||
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Min.Y+radius, radius, clr)
|
||||
drawCircleFilledRGBA(dst, rect.Min.X+radius, rect.Max.Y-radius, radius, clr)
|
||||
drawCircleFilledRGBA(dst, rect.Max.X-radius, rect.Max.Y-radius, radius, clr)
|
||||
}
|
||||
|
||||
func drawCircleFilledRGBA(dst *image.RGBA, cx, cy, r int, clr color.Color) {
|
||||
for x := -r; x <= r; x++ {
|
||||
for y := -r; y <= r; y++ {
|
||||
if x*x+y*y <= r*r {
|
||||
dst.Set(cx+x, cy+y, clr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawLine(dst *image.RGBA, x1, y1, x2, y2 int, clr color.Color) {
|
||||
if y1 != y2 {
|
||||
return
|
||||
}
|
||||
for x := x1; x <= x2; x++ {
|
||||
dst.Set(x, y1, clr)
|
||||
}
|
||||
}
|
||||
|
||||
func drawText(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
|
||||
ftCtx.SetClip(dst.Bounds())
|
||||
ftCtx.SetSrc(image.NewUniform(col))
|
||||
ftCtx.SetFontSize(fontSize)
|
||||
ftCtx.SetDst(dst)
|
||||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||||
}
|
||||
|
||||
// drawTextClipped 同 drawText,但将绘制裁剪到 clip 区域。
|
||||
// 用于左侧文字区:即使宽度估算偏差导致某行文字偏宽,也只会被裁掉,不会侵入右侧二维码卡片。
|
||||
func drawTextClipped(dst *image.RGBA, text string, x, y int, fontSize float64, col color.Color, ftCtx *freetype.Context, clip image.Rectangle) {
|
||||
ftCtx.SetClip(clip)
|
||||
ftCtx.SetSrc(image.NewUniform(col))
|
||||
ftCtx.SetFontSize(fontSize)
|
||||
ftCtx.SetDst(dst)
|
||||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||||
}
|
||||
|
||||
// drawTextWithShadow 先绘制偏移阴影再绘制原色文字,增强在复杂背景上的可读性。
|
||||
func drawTextWithShadow(dst *image.RGBA, text string, x, y int, fontSize float64, col, shadow color.Color, offset int, ftCtx *freetype.Context) {
|
||||
drawText(dst, text, x+offset, y+offset, fontSize, shadow, ftCtx)
|
||||
drawText(dst, text, x, y, fontSize, col, ftCtx)
|
||||
}
|
||||
|
||||
func splitText(text string, fontSize float64, maxWidth int) []string {
|
||||
var lines []string
|
||||
var currentLine []rune
|
||||
var currentWidth float64
|
||||
for _, r := range text {
|
||||
charW := charPixelWidth(r, fontSize)
|
||||
if currentWidth+charW > float64(maxWidth) && len(currentLine) > 0 {
|
||||
lines = append(lines, string(currentLine))
|
||||
currentLine = []rune{r}
|
||||
currentWidth = charW
|
||||
} else {
|
||||
currentLine = append(currentLine, r)
|
||||
currentWidth += charW
|
||||
}
|
||||
}
|
||||
if len(currentLine) > 0 {
|
||||
lines = append(lines, string(currentLine))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func charPixelWidth(r rune, fontSize float64) float64 {
|
||||
// 宽度系数取「保守上界」:中文字体是方形,实际渲染宽度≈fontSize;
|
||||
// 若估算偏窄,splitText 换行后的行宽会超出 maxWidth,文字侵入相邻区域。
|
||||
// 这里按实际宽度的上界估算,保证 splitText 的行数是安全的(宁可多换行,配合行数截断)。
|
||||
if unicode.Is(unicode.Han, r) || unicode.Is(unicode.Punct, r) || unicode.Is(unicode.Symbol, r) {
|
||||
return fontSize * 1.0
|
||||
}
|
||||
return fontSize * 0.62
|
||||
}
|
||||
|
||||
// measureTextWidth 用逐字符宽度估算(与 splitText 的 charPixelWidth 一致),
|
||||
// 避免每次测量都创建 freetype.Context 并执行 DrawString(显著降低生成耗时)。
|
||||
func measureTextWidth(text string, fontSize float64) float64 {
|
||||
return measureTextEstimate(text, fontSize)
|
||||
}
|
||||
|
||||
func measureTextEstimate(text string, fontSize float64) float64 {
|
||||
total := 0.0
|
||||
for _, r := range text {
|
||||
total += charPixelWidth(r, fontSize)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func drawTextCentered(dst *image.RGBA, text string, cx, y int, fontSize float64, col color.Color, ftCtx *freetype.Context) {
|
||||
ftCtx.SetClip(dst.Bounds())
|
||||
ftCtx.SetSrc(image.NewUniform(col))
|
||||
ftCtx.SetFontSize(fontSize)
|
||||
actualW := measureTextWidth(text, fontSize)
|
||||
x := cx - int(actualW/2)
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
ftCtx.SetDst(dst)
|
||||
_, _ = ftCtx.DrawString(text, freetype.Pt(x, y))
|
||||
}
|
||||
|
||||
func generateQR(content string, user *db.User, logoImg image.Image) (image.Image, error) {
|
||||
qr, err := qrcode.New(content, qrcode.High)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 强制传统黑白,忽略用户所有颜色设置
|
||||
qr.BackgroundColor = color.RGBA{255, 255, 255, 255}
|
||||
qr.ForegroundColor = color.RGBA{0, 0, 0, 255}
|
||||
|
||||
qrPaletted := qr.Image(256)
|
||||
rgba := image.NewRGBA(qrPaletted.Bounds())
|
||||
draw.Draw(rgba, rgba.Bounds(), qrPaletted, image.Point{}, draw.Src)
|
||||
|
||||
if logoImg != nil {
|
||||
logo := imaging.Fill(logoImg, 48, 48, imaging.Center, imaging.Lanczos)
|
||||
bounds := rgba.Bounds()
|
||||
logoPos := image.Pt(
|
||||
bounds.Min.X+(bounds.Dx()-logo.Bounds().Dx())/2,
|
||||
bounds.Min.Y+(bounds.Dy()-logo.Bounds().Dy())/2,
|
||||
)
|
||||
draw.Draw(rgba, logo.Bounds().Add(logoPos), logo, image.Point{}, draw.Over)
|
||||
}
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// ---------- 单题分享图 ----------
|
||||
|
||||
// 单题分享图布局常量(1280x720)
|
||||
const (
|
||||
QContentX = 45
|
||||
QContentY = 45
|
||||
QContentW = CardWidth - 2*QContentX
|
||||
QContentH = CardHeight - 2*QContentY
|
||||
|
||||
QAvatarSize = 33
|
||||
QAvatarX = QContentX + 2
|
||||
QAvatarY = QContentY + 2
|
||||
QNameX = QAvatarX + QAvatarSize + 12
|
||||
QNameY = QContentY + 28
|
||||
|
||||
QSepY = QContentY + 50
|
||||
|
||||
QLabelFont = 20.0
|
||||
QQuestionFont = 26.0
|
||||
QQuestionLineH = 34
|
||||
QQuestionMaxLine = 3
|
||||
QAnswerFont = 22.0
|
||||
QAnswerLineH = 30
|
||||
QAnswerMaxLine = 7
|
||||
|
||||
// 右侧二维码卡片
|
||||
QQRCardW = 210
|
||||
QQRCardH = 255
|
||||
QQRCardX = QContentX + QContentW - QQRCardW
|
||||
QQRCardY = QContentY + (QContentH-QQRCardH)/2
|
||||
QQRSize = 128
|
||||
QQRCodeX = (QQRCardW - QQRSize) / 2
|
||||
|
||||
// 内容区最大宽度(左侧,避开二维码卡片)
|
||||
QTextMaxW = QQRCardX - QContentX - 30
|
||||
|
||||
QWatermarkY = CardHeight - 30
|
||||
)
|
||||
|
||||
// 单题分享图结果缓存,TTL 30 分钟,上限 16 条(1C1G 内存友好)。
|
||||
var questionShareCache = newTTLCache(16)
|
||||
|
||||
// QuestionShareCard 生成单个问题(问题+回答)的分享卡片图。
|
||||
func QuestionShareCard(ctx context.Context) {
|
||||
domain := ctx.Param("domain")
|
||||
user, err := db.Users.GetByDomain(ctx.Request().Context(), domain)
|
||||
if err != nil {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
questionID := uint(ctx.ParamInt("questionID"))
|
||||
question, err := db.Questions.GetByID(ctx.Request().Context(), questionID)
|
||||
if err != nil {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// 仅公开且已回答的问题可以生成分享图(Questioner 中间件已挡住私密/未回答,这里再兜底一次)
|
||||
if question.UserID != user.ID || question.Answer == "" || question.IsPrivate {
|
||||
ctx.Redirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := questionShareCardCacheKey(user, question)
|
||||
if cached, ok := questionShareCache.Get(cacheKey); ok {
|
||||
writeShareCardResponse(ctx, cached.([]byte))
|
||||
return
|
||||
}
|
||||
|
||||
canvas := image.NewRGBA(image.Rect(0, 0, CardWidth, CardHeight))
|
||||
innerRect := image.Rect(QContentX, QContentY, QContentX+QContentW, QContentY+QContentH)
|
||||
|
||||
// ---- 并发加载背景与头像 ----
|
||||
var (
|
||||
bgImg image.Image
|
||||
avImg image.Image
|
||||
)
|
||||
var wg sync.WaitGroup
|
||||
bgURL := user.Background
|
||||
if bgURL == "" {
|
||||
bgURL = conf.App.ShareBackground
|
||||
}
|
||||
wg.Add(1)
|
||||
shared.SafeGo(func() {
|
||||
defer wg.Done()
|
||||
img, err := loadImage(bgURL, QContentW, QContentH)
|
||||
if err == nil {
|
||||
bgImg = img
|
||||
}
|
||||
})
|
||||
wg.Add(1)
|
||||
shared.SafeGo(func() {
|
||||
defer wg.Done()
|
||||
img, err := loadImage(user.Avatar, QAvatarSize, QAvatarSize)
|
||||
if err == nil {
|
||||
avImg = img
|
||||
}
|
||||
})
|
||||
wg.Wait()
|
||||
|
||||
// 背景 + 白色遮罩
|
||||
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ColorWhite}, image.Point{}, draw.Src)
|
||||
if bgImg != nil {
|
||||
draw.Draw(canvas, innerRect, bgImg, image.Point{}, draw.Src)
|
||||
}
|
||||
draw.Draw(canvas, innerRect, image.NewUniform(OverlayColor), image.Point{}, draw.Over)
|
||||
|
||||
// 字体
|
||||
ft, err := getFont()
|
||||
if err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "分享图生成失败:字体文件未配置或无法加载", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to load font for question share card")
|
||||
return
|
||||
}
|
||||
fontCtx := freetype.NewContext()
|
||||
fontCtx.SetFont(ft)
|
||||
fontCtx.SetFontSize(24)
|
||||
fontCtx.SetDPI(72)
|
||||
|
||||
// 头部:头像 + 名字
|
||||
drawCircleFilled(canvas, QAvatarX+QAvatarSize/2, QAvatarY+QAvatarSize/2, QAvatarSize/2+2, ColorWhite)
|
||||
if avImg != nil {
|
||||
avImg = imaging.Fill(avImg, QAvatarSize, QAvatarSize, imaging.Center, imaging.Lanczos)
|
||||
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
|
||||
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
|
||||
draw.DrawMask(canvas,
|
||||
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
|
||||
avImg, image.Point{}, mask, image.Point{}, draw.Over)
|
||||
} else {
|
||||
mask := image.NewAlpha(image.Rect(0, 0, QAvatarSize, QAvatarSize))
|
||||
drawCircle(mask, QAvatarSize/2, QAvatarSize/2, QAvatarSize/2, color.Alpha{255})
|
||||
draw.DrawMask(canvas,
|
||||
image.Rect(QAvatarX, QAvatarY, QAvatarX+QAvatarSize, QAvatarY+QAvatarSize),
|
||||
&image.Uniform{color.Gray{180}}, image.Point{}, mask, image.Point{}, draw.Over)
|
||||
}
|
||||
drawTextWithShadow(canvas, user.Name+" 的提问箱", QNameX, QNameY, 22, ColorDarkText, color.NRGBA{0, 0, 0, 45}, 1, fontCtx)
|
||||
|
||||
// 分隔线
|
||||
drawLine(canvas, QContentX+2, QSepY, QContentX+QContentW-2, QSepY, color.NRGBA{190, 190, 190, 200})
|
||||
|
||||
// 左侧文字区裁剪边界:问题/回答文字不得侵入右侧二维码卡片(QQRCardX 左侧留 10px 间距)。
|
||||
qLeftClip := image.Rect(QContentX, QContentY, QQRCardX-10, QContentY+QContentH)
|
||||
|
||||
// 问题区
|
||||
labelY := QSepY + 32
|
||||
drawText(canvas, "提问", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
|
||||
|
||||
textY := labelY + 30
|
||||
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Content)), QContentX+2, textY, QTextMaxW, QQuestionFont, ColorDarkText, fontCtx, QQuestionLineH, QQuestionMaxLine, qLeftClip)
|
||||
|
||||
// 回答区
|
||||
labelY = textY + 22
|
||||
drawText(canvas, "回答", QContentX+2, labelY, QLabelFont, ColorGrayText, fontCtx)
|
||||
|
||||
textY = labelY + 28
|
||||
textY = drawTextMultilineLimited(canvas, sanitizeText(template.MarkdownToPlainText(question.Answer)), QContentX+2, textY, QTextMaxW, QAnswerFont, ColorDarkText, fontCtx, QAnswerLineH, QAnswerMaxLine, qLeftClip)
|
||||
_ = textY
|
||||
|
||||
// 右侧二维码卡片
|
||||
cardRect := image.Rect(QQRCardX, QQRCardY, QQRCardX+QQRCardW, QQRCardY+QQRCardH)
|
||||
drawRoundedRect(canvas, cardRect, color.NRGBA{245, 245, 245, 255}, 20)
|
||||
drawRoundedRect(canvas, cardRect, ColorWhite, 20)
|
||||
|
||||
questionURL := fmt.Sprintf("%s/_/%s/%d", conf.App.ExternalURL, user.Domain, question.ID)
|
||||
qr, err := generateQR(questionURL, user, avImg)
|
||||
if err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "二维码生成失败", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to generate QR code for question share card")
|
||||
return
|
||||
}
|
||||
qrResized := imaging.Resize(qr, QQRSize, QQRSize, imaging.NearestNeighbor)
|
||||
qrDrawX := QQRCardX + QQRCodeX
|
||||
// 二维码与提示语整体垂直居中(已删除底部冗余的长链接,卡片更简洁)
|
||||
qrDrawY := QQRCardY + 40
|
||||
draw.Draw(canvas, image.Rect(qrDrawX, qrDrawY, qrDrawX+QQRSize, qrDrawY+QQRSize), qrResized, image.Point{}, draw.Over)
|
||||
|
||||
hintY := qrDrawY + QQRSize + 30
|
||||
drawTextCentered(canvas, "扫码查看回答", QQRCardX+QQRCardW/2, hintY, 17, ColorHintGray, fontCtx)
|
||||
|
||||
// 底部水印
|
||||
drawTextCentered(canvas, "Powered by 狼的提问箱", CardWidth/2, QWatermarkY, FontSizeFooter, ColorFooterGray, fontCtx)
|
||||
|
||||
// 输出(缓存 + 响应)
|
||||
buf := new(bytes.Buffer)
|
||||
if err := jpeg.Encode(buf, canvas, &jpeg.Options{Quality: 88}); err != nil {
|
||||
http.Error(ctx.ResponseWriter(), "图片生成失败", http.StatusInternalServerError)
|
||||
logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to encode question share card PNG")
|
||||
return
|
||||
}
|
||||
|
||||
questionShareCache.Set(cacheKey, buf.Bytes(), time.Hour)
|
||||
writeShareCardResponse(ctx, buf.Bytes())
|
||||
}
|
||||
|
||||
// questionShareCardCacheKey 基于问题内容与用户资料生成缓存指纹,内容/资料变更自动失效。
|
||||
func questionShareCardCacheKey(user *db.User, question *db.Question) string {
|
||||
h := md5.New()
|
||||
fmt.Fprintf(h, "q|%d|%s|%s|%s|%s|%s", question.ID, question.Content, question.Answer, user.Name, user.Avatar, user.Background)
|
||||
return fmt.Sprintf("qshare:%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// sanitizeText 将换行符替换为空格,避免绘图时排版错乱。
|
||||
func sanitizeText(text string) string {
|
||||
return strings.ReplaceAll(text, "\n", " ")
|
||||
}
|
||||
|
||||
// drawTextMultilineLimited 多行绘制,超过 maxLines 行时截断并追加省略号。
|
||||
// clip 限定绘制区域,防止估算换行偏差导致文字溢出到相邻元素(如右侧二维码卡片)。
|
||||
func drawTextMultilineLimited(dst *image.RGBA, text string, x, y int, maxWidth int, fontSize float64, col color.Color, ftCtx *freetype.Context, lineHeight int, maxLines int, clip image.Rectangle) int {
|
||||
lines := splitText(text, fontSize, maxWidth)
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[:maxLines]
|
||||
lines[maxLines-1] = lines[maxLines-1] + "…"
|
||||
}
|
||||
curY := y
|
||||
for _, line := range lines {
|
||||
drawTextClipped(dst, line, x, curY, fontSize, col, ftCtx, clip)
|
||||
curY += lineHeight
|
||||
}
|
||||
return curY
|
||||
}
|
||||
Reference in New Issue
Block a user