Files
tamabox/internal/cmd/web.go
T
tamakyi e807dea218 feat(share-card): 字体自动发现 + 启动探测,缺字体不再等到第一次请求才 500
- 系统候选补上 glyf 轮廓的 CJK 字体(文泉驿正黑/微米黑、DroidSansFallback、
  AR PL UMing),并把 Noto Sans CJK 压到最后:它是 CFF/OTTO,freetype 必定解析失败,
  排前面只会白白多读十几 MB
- static/fonts 同时按当前工作目录与可执行文件目录查找,systemd 没配
  WorkingDirectory 时也能找到(原先只按 CWD,文件在却报「没有可用字体」)
- 启动时探一次字体:命中打 Info 带路径,缺失打 WARN 并给出补救命令
  (apt install fonts-wqy-zenhei / 放 static/fonts/ / 配 font_path)
- .ttc 先提取首个字面再用(仅对 glyf 轮廓的集合有效,如文泉驿);
  失败结果缓存 1 分钟,避免公开端点被「每请求重新解析十几 MB」放大

实测:本地实例 apt install fonts-wqy-zenhei 后无需任何配置,启动即打出
font=/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc,/share-card 与单题分享图
从 500 变为 200 image/jpeg,中文与二维码渲染正常。
2026-10-27 12:37:48 +08:00

150 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 cmd
import (
"fmt"
"net/http"
"time"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/uptrace/opentelemetry-go-extra/otellogrus"
"github.com/uptrace/uptrace-go/uptrace"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
"github.com/tamakyi/TamaBox/internal/db"
"github.com/tamakyi/TamaBox/internal/route"
"github.com/tamakyi/TamaBox/internal/security/honeypot"
"github.com/tamakyi/TamaBox/internal/shared"
"github.com/tamakyi/TamaBox/internal/stats"
"github.com/tamakyi/TamaBox/internal/tracing"
"github.com/tamakyi/TamaBox/route/question"
)
var Web = &cli.Command{
Name: "web",
Usage: "Start web server",
Action: runWeb,
}
func runWeb(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
// 解析可信代理网段(用于客户端 IP 解析,避免维护 Cloudflare 庞大 CIDR 清单)。
if err := shared.InitTrustedProxies(); err != nil {
return errors.Wrap(err, "init trusted proxies")
}
if conf.App.UptraceDSN != "" {
uptrace.ConfigureOpentelemetry(
uptrace.WithDSN(conf.App.UptraceDSN),
uptrace.WithServiceName("tamabox"),
uptrace.WithServiceVersion(conf.BuildCommit),
)
logrus.WithContext(ctx.Context).Debug("Tracing enabled.")
}
logrus.AddHook(otellogrus.NewHook(otellogrus.WithLevels(
logrus.PanicLevel,
logrus.FatalLevel,
logrus.ErrorLevel,
logrus.WarnLevel,
)))
dbType := conf.Database.Type
var dsn string
switch dbType {
case "mysql", "":
dsn = conf.MySQLDsn()
case "postgres":
dsn = conf.PostgresDsn()
default:
return errors.Errorf("unknown database type: %q", dbType)
}
conf.Database.DSN = dsn
_, err := db.Init(dbType, dsn)
if err != nil {
return errors.Wrap(err, "connect to database")
}
// 蜜罐命中记录保留期清理:honeypot_hits 是只追加的审计表,写入完全由
// 外部扫描流量驱动(不可预期、不可节流)。没有自动清理的话,表体积只增不减。
// 保留期复用 honeypot_retain_days(与 CLI prune 同一个口径),开关为 honeypot_auto_clean。
//
// 凭据文件(明文账号密码)也按同一个保留期清理 —— 它们不该比元数据活得更久。
// 这里独立打开一次目录即可:PurgeBefore 只删「整天都早于保留期」的文件,
// 与 route 层那个 Append 用的句柄不会争抢同一天的文件。
stopRetention := make(chan struct{})
if conf.Security.HoneypotAutoClean {
var credPurge db.CredPurgeable
if cs, err := honeypot.NewFileCredStore(conf.Security.HoneypotCredDir); err != nil {
logrus.WithError(err).Warn("蜜罐凭据目录不可用,凭据文件将不会被自动清理")
} else {
credPurge = cs
}
db.NewHoneypotRetention(db.Honeypots, credPurge, conf.Security.HoneypotRetainDays, conf.Security.HoneypotMaxRows, 0).Start(stopRetention)
defer close(stopRetention)
}
logrus.WithContext(ctx.Context).WithField("external_url", conf.App.ExternalURL).Info("Starting web server")
// 站点默认语言初始化(default_lang 配置,未配置时回退 zh-CN)
shared.App.SetLang(conf.App.DefaultLang)
// 分享卡字体:缺字体只会在「第一张分享图被请求」时暴露成 500,而 /share-card 是公开端点。
// 启动时先探一次(顺带把读文件 + 解析的开销挪出请求路径),缺了直接 WARN 并给出补救办法。
if fontPath, err := question.CheckShareCardFont(); err != nil {
logrus.WithError(err).Warn("分享卡字体不可用,/share-card 将返回 500:" +
"装一个 glyf 轮廓的 CJK 字体(apt install fonts-wqy-zenhei)" +
"或把 ttf/otf/ttc 放进 static/fonts/,也可用 [app] font_path 指定")
} else {
logrus.WithField("font", fontPath).Info("分享卡字体已就绪")
}
r := route.New()
r.Use(tracing.Middleware("TamaBox"))
// 全站数据页预热:整页聚合是若干次全表 GROUP BY,若摊在当天第一个访客身上,
// 首屏要白等一秒以上。这里延后几秒在后台先算一次,把等待挪出请求路径。
// 失败只告警:页面在真正被访问时会自己重算并(若仍失败)回退到上一次成功的结果。
go func() {
time.Sleep(3 * time.Second)
if _, err := stats.OverviewData(ctx.Context); err != nil {
logrus.WithError(err).Warn("预热全站统计数据失败")
}
}()
// 最外层挂载 gzip 压缩,并在 http.Server 上配置超时:
// - ReadHeaderTimeout 防止慢速客户端占用连接
// - ReadTimeout / WriteTimeout 防止读写挂死
// - IdleTimeout 复用 keep-alive 连接
// listen 留空时回退到 ":port"(监听所有网卡);Cloudflare Tunnel 部署建议设为
// "127.0.0.1:<port>",使源站端口仅对本地 cloudflared 可见,即使防火墙误配也无法被外部直连。
addr := conf.Server.Listen
if addr == "" {
addr = fmt.Sprintf(":%d", conf.Server.Port)
}
server := &http.Server{
Addr: addr,
Handler: route.GzipHandler(r),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
logrus.WithContext(ctx.Context).WithField("port", conf.Server.Port).Info("Listening on")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return errors.Wrap(err, "start http server")
}
return nil
}