新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -16,10 +16,20 @@ type Config struct {
ConfigFile string
// 监听端口
Port int
// 对外公网根地址无尾斜杠OIDC Issuer 使用
RootURL string
// 数据目录SQLite、上传、日志绝对路径
DataDir string
// JWT 签名密钥
JWTSecret string
// OIDC 客户端P0写死在 app.ini供 Gitea 对接)
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURIs []string
// Gitea API 同步种子(可选,运行时以管理后台为准)
GiteaBaseURL string
GiteaToken string
GiteaSyncEnabled bool
// 日志文件路径
LogFile string
// 系统服务控制动作install|uninstall|start|stop|restart|status空表示正常运行
@@ -83,13 +93,20 @@ func Parse() (*Config, error) {
}
cfg := &Config{
WorkPath: workPath,
ConfigFile: configFile,
Port: port,
DataDir: absData,
JWTSecret: jwtSecret,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
WorkPath: workPath,
ConfigFile: configFile,
Port: port,
RootURL: normalizeRootURL(fileCfg.RootURL),
DataDir: absData,
JWTSecret: jwtSecret,
OAuthClientID: fileCfg.OAuthClientID,
OAuthClientSecret: fileCfg.OAuthClientSecret,
OAuthRedirectURIs: splitCSV(fileCfg.OAuthRedirectURIs),
GiteaBaseURL: normalizeRootURL(fileCfg.GiteaBaseURL),
GiteaToken: fileCfg.GiteaToken,
GiteaSyncEnabled: fileCfg.GiteaSyncEnabled,
LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action,
}
needDirs := action == "" || action == "install"
@@ -97,18 +114,29 @@ func Parse() (*Config, error) {
// 首次启动自动生成 app.ini便于像 Gitea 一样改文件而不记一长串参数
if !configExists {
dataRel := resolveDataRelForINI(workPath, absData)
if err := writeAppINI(configFile, port, dataRel, ""); err != nil {
if err := writeAppINI(configFile, fileSettings{
Port: port,
DataRel: dataRel,
}); err != nil {
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
}
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
} else if action == "install" {
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
dataRel := resolveDataRelForINI(workPath, absData)
iniJWT := ""
iniJWT := fileCfg.JWTSecret
if strings.TrimSpace(*jwtFlag) != "" {
iniJWT = jwtSecret
}
if err := writeAppINI(configFile, port, dataRel, iniJWT); err != nil {
if err := writeAppINI(configFile, fileSettings{
Port: port,
DataRel: dataRel,
JWTSecret: iniJWT,
RootURL: fileCfg.RootURL,
OAuthClientID: fileCfg.OAuthClientID,
OAuthClientSecret: fileCfg.OAuthClientSecret,
OAuthRedirectURIs: fileCfg.OAuthRedirectURIs,
}); err != nil {
return nil, fmt.Errorf("更新配置文件失败: %w", err)
}
}
@@ -149,6 +177,7 @@ func ensureDataDirs(dataDir string) error {
for _, sub := range []string{
filepath.Join(dataDir, "uploads", "avatars"),
filepath.Join(dataDir, "uploads", "posts"),
filepath.Join(dataDir, "uploads", "site"),
} {
if err := os.MkdirAll(sub, 0755); err != nil {
return fmt.Errorf("创建上传目录失败: %w", err)
@@ -198,6 +227,11 @@ func (c *Config) PostImageUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "posts")
}
// SiteUploadDir 返回站点品牌资源Logo / Favicon目录
func (c *Config) SiteUploadDir() string {
return filepath.Join(c.DataDir, "uploads", "site")
}
// UploadDir 返回头像上传目录(兼容旧调用)
func (c *Config) UploadDir() string {
return c.AvatarUploadDir()
@@ -208,6 +242,29 @@ func (c *Config) FilterWordsPath() string {
return filepath.Join(c.DataDir, "filter_words.txt")
}
// OIDCEnabled 是否已配置可作为 OIDC Provider
func (c *Config) OIDCEnabled() bool {
return c.RootURL != "" && c.OAuthClientID != "" && c.OAuthClientSecret != "" && len(c.OAuthRedirectURIs) > 0
}
func normalizeRootURL(raw string) string {
u := strings.TrimSpace(raw)
u = strings.TrimRight(u, "/")
return u
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func generateRandomSecret(n int) string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n)

View File

@@ -18,9 +18,16 @@ const (
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
type fileSettings struct {
Port int
DataRel string
JWTSecret string
Port int
DataRel string
JWTSecret string
RootURL string
OAuthClientID string
OAuthClientSecret string
OAuthRedirectURIs string
GiteaBaseURL string
GiteaToken string
GiteaSyncEnabled bool
}
func defaultFileSettings() fileSettings {
@@ -50,6 +57,7 @@ func loadAppINI(path string) (fileSettings, error) {
}
out.Port = p
}
out.RootURL = strings.TrimSpace(sec.Key("ROOT_URL").String())
}
if sec, err := cfg.GetSection("paths"); err == nil {
@@ -62,11 +70,23 @@ func loadAppINI(path string) (fileSettings, error) {
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
}
if sec, err := cfg.GetSection("oauth"); err == nil {
out.OAuthClientID = strings.TrimSpace(sec.Key("CLIENT_ID").String())
out.OAuthClientSecret = strings.TrimSpace(sec.Key("CLIENT_SECRET").String())
out.OAuthRedirectURIs = strings.TrimSpace(sec.Key("REDIRECT_URIS").String())
}
if sec, err := cfg.GetSection("gitea"); err == nil {
out.GiteaBaseURL = strings.TrimSpace(sec.Key("BASE_URL").String())
out.GiteaToken = strings.TrimSpace(sec.Key("TOKEN").String())
out.GiteaSyncEnabled = sec.Key("SYNC_ENABLED").MustBool(false)
}
return out, nil
}
// writeAppINI 写入/覆盖 app.ini安装服务或首次生成时使用
func writeAppINI(path string, port int, dataRel, jwtSecret string) error {
func writeAppINI(path string, s fileSettings) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
@@ -80,17 +100,36 @@ func writeAppINI(path string, port int, dataRel, jwtSecret string) error {
b.WriteString("\n")
b.WriteString("[server]\n")
b.WriteString("HTTP_PORT = ")
b.WriteString(strconv.Itoa(port))
b.WriteString(strconv.Itoa(s.Port))
b.WriteString("\n")
b.WriteString("; 对外公网根地址无尾斜杠OIDC Issuer / Discovery 依赖此项\n")
b.WriteString("; 例https://bbs.iioio.com\n")
b.WriteString("ROOT_URL = ")
b.WriteString(s.RootURL)
b.WriteString("\n\n")
b.WriteString("[paths]\n")
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
b.WriteString("DATA = ")
b.WriteString(dataRel)
b.WriteString(s.DataRel)
b.WriteString("\n\n")
b.WriteString("[security]\n")
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret勿把生产密钥提交到仓库\n")
b.WriteString("JWT_SECRET = ")
b.WriteString(jwtSecret)
b.WriteString(s.JWTSecret)
b.WriteString("\n\n")
b.WriteString("[oauth]\n")
b.WriteString("; 作为 OIDC Provider 时,给 Gitea 等客户端使用的凭据P0 写死在配置)\n")
b.WriteString("; Gitea 认证源名称需与回调路径一致,例如名称 jiang13 对应:\n")
b.WriteString("; https://git.iioio.com/user/oauth2/jiang13/callback\n")
b.WriteString("CLIENT_ID = ")
b.WriteString(s.OAuthClientID)
b.WriteString("\n")
b.WriteString("CLIENT_SECRET = ")
b.WriteString(s.OAuthClientSecret)
b.WriteString("\n")
b.WriteString("; 多个回调用逗号分隔\n")
b.WriteString("REDIRECT_URIS = ")
b.WriteString(s.OAuthRedirectURIs)
b.WriteString("\n")
return os.WriteFile(path, []byte(b.String()), 0644)