diff --git a/Makefile b/Makefile index 9742b7b..41948f6 100644 --- a/Makefile +++ b/Makefile @@ -17,32 +17,37 @@ all: build frontend-build: cd frontend && npm install && npm run build -## 编译当前平台二进制 +## 编译当前平台二进制(纯 Go SQLite,无需 CGO) build: frontend-build @mkdir -p $(BUILD_DIR) - $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) + CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) @echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)" -## Windows amd64 -build-windows: +## Windows amd64(先打包前端再 embed) +build-windows: frontend-build @mkdir -p $(BUILD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) @echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe" -## Linux amd64 -build-linux: +## Linux amd64(先打包前端再 embed) +build-linux: frontend-build @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) @echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64" -## macOS arm64 (Apple Silicon) -build-darwin: +## macOS arm64 (Apple Silicon)(先打包前端再 embed) +build-darwin: frontend-build @mkdir -p $(BUILD_DIR) - GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG) + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG) @echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64" -## 跨平台全量编译 -build-all: build-windows build-linux build-darwin build +## 跨平台全量编译(frontend-build 只跑一次) +build-all: frontend-build + @mkdir -p $(BUILD_DIR) + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG) + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG) + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG) + CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG) @echo "✓ 全平台编译完成" ## 整理依赖 diff --git a/README.md b/README.md index f585354..7b70888 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@
@@ -78,7 +78,7 @@
| 特性 | 说明 |
|------|------|
-| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
+| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/最新评论 |
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
@@ -93,7 +93,7 @@
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
-- 点赞、收藏、热门帖、最新动态
+- 点赞、收藏、热门帖、最新评论
- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
@@ -174,15 +174,21 @@ cp app.ini.example /opt/jiang13/app.ini
```ini
[server]
HTTP_PORT = 3000
+ROOT_URL = https://bbs.iioio.com
[paths]
DATA = data
[security]
JWT_SECRET =
+
+[oauth]
+CLIENT_ID = gitea
+CLIENT_SECRET =
+REDIRECT_URIS = https://git.iioio.com/user/oauth2/jiang13/callback
```
-完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。
+完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。`ROOT_URL` 与 `[oauth]` 可作首次种子;日常请在管理后台「系统设置 → OIDC / SSO」配置(保存即生效)。
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
@@ -291,7 +297,7 @@ jiang13-forum/
├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑(认证、帖子、评论…)
├── handler/ # HTTP 处理器(前台 + 后台)
-├── middleware/ # JWT 鉴权、在线状态
+├── middleware/ # JWT 鉴权
├── router/ # 路由注册
├── embed_static/ # go:embed 内嵌的 SPA 与模板
├── frontend/ # React 源码(Vite 构建)
diff --git a/ROADMAP.md b/ROADMAP.md
index 383385f..05d3336 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -14,6 +14,7 @@
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
| 评论系统 | ✅ | 换行显示已修复 |
+| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(`ROOT_URL` + `[oauth]`) |
---
@@ -27,7 +28,7 @@ _当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.c
| 优先级 | 功能 | 说明 |
|--------|------|------|
-| 中 | 通知动态优化 | 右栏最新动态的展示与交互 |
+| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
@@ -48,6 +49,8 @@ _当前无公开认领任务。_
- [x] 浅色 / 暗色主题切换
- [x] 移动端响应式适配
- [x] 用户注册登录、JWT 鉴权
+- [x] OIDC Provider(对接 Gitea SSO:Discovery / Authorize / Token / UserInfo)
+- [x] OAuth 应用管理(密钥哈希、多客户端、登出端点、groups 映射)
- [x] 板块管理、发帖、TipTap 富文本编辑
- [x] 帖子正文图片本地上传
- [x] 帖子修订历史与 diff 对比
diff --git a/app.ini.example b/app.ini.example
index fceaa87..967b9ec 100644
--- a/app.ini.example
+++ b/app.ini.example
@@ -4,6 +4,9 @@
[server]
HTTP_PORT = 3000
+; 对外公网根地址(无尾斜杠);也可在管理后台 OIDC 设置中填写
+; 例:https://bbs.iioio.com
+ROOT_URL = https://bbs.iioio.com
[paths]
; 相对路径相对于工作目录(默认可执行文件所在目录)
@@ -12,3 +15,22 @@ DATA = data
[security]
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
JWT_SECRET =
+
+[oauth]
+; 可选:启动时若管理后台尚未配置,会用此处种子写入数据库一次
+; 日常请优先在管理后台「系统设置 → OIDC / SSO」修改(保存即生效)
+; Gitea 认证源名称须与回调路径一致,例如名称 jiang13 对应:
+; https://git.iioio.com/user/oauth2/jiang13/callback
+CLIENT_ID = gitea
+CLIENT_SECRET = 请替换为足够长的随机字符串
+; 多个回调用逗号分隔
+REDIRECT_URIS = https://git.iioio.com/user/oauth2/jiang13/callback
+
+[gitea]
+; 可选:同步会员公开仓库到侧栏 /projects(优先在管理后台配置)
+; BASE_URL 例:https://git.iioio.com
+BASE_URL =
+; 只读 Access Token(需能列出用户公开仓库)
+TOKEN =
+; 首次种子时若 BASE_URL+TOKEN 齐全且此项为 true,则写入并启用同步
+SYNC_ENABLED = false
diff --git a/build.ps1 b/build.ps1
index 8585ee6..c6ba415 100644
--- a/build.ps1
+++ b/build.ps1
@@ -39,16 +39,30 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
+ # 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
+ $prevCgo = $env:CGO_ENABLED
+ $env:CGO_ENABLED = '0'
+
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
$OutFile = "$OutFile.exe"
}
$outPath = Join-Path $BuildDir $OutFile
- Write-Host "[go] build -> $outPath" -ForegroundColor Cyan
- go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
- if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
- Write-Host "[ok] $outPath" -ForegroundColor Green
+ Write-Host "[go] build -> $outPath (CGO_ENABLED=0)" -ForegroundColor Cyan
+ try {
+ go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
+ if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
+ Write-Host "[ok] $outPath" -ForegroundColor Green
+ } finally {
+ if ($null -eq $prevCgo) {
+ Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
+ } else {
+ $env:CGO_ENABLED = $prevCgo
+ }
+ Remove-Item Env:GOOS -ErrorAction SilentlyContinue
+ Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
+ }
}
switch ($Target) {
@@ -103,6 +117,7 @@ switch ($Target) {
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
}
'build-linux' {
+ Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
Build-Frontend
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
}
diff --git a/config/config.go b/config/config.go
index a5a9333..5368015 100644
--- a/config/config.go
+++ b/config/config.go
@@ -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)
diff --git a/config/ini.go b/config/ini.go
index d448499..7d2b4dc 100644
--- a/config/ini.go
+++ b/config/ini.go
@@ -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)
diff --git a/embed_static/embed.go b/embed_static/embed.go
index c584d95..1b22d2d 100644
--- a/embed_static/embed.go
+++ b/embed_static/embed.go
@@ -53,7 +53,9 @@ func IsSPARoute(path string) bool {
strings.HasPrefix(path, "/admin") ||
strings.HasPrefix(path, "/uploads") ||
strings.HasPrefix(path, "/legacy") ||
- strings.HasPrefix(path, "/assets") {
+ strings.HasPrefix(path, "/assets") ||
+ strings.HasPrefix(path, "/oauth") ||
+ strings.HasPrefix(path, "/.well-known") {
return false
}
return true
diff --git a/embed_static/templates/admin/dashboard.html b/embed_static/templates/admin/dashboard.html
index 62bc765..5ffc993 100644
--- a/embed_static/templates/admin/dashboard.html
+++ b/embed_static/templates/admin/dashboard.html
@@ -36,13 +36,6 @@
| ID | 用户名 | 昵称 | 角色 | 状态 | 注册时间 | 操作 | |||
|---|---|---|---|---|---|---|---|---|---|
| ID | 用户名 | 昵称 | 邮箱 | 角色 | 状态 | 上次登录 | 登录 IP | 注册时间 | 操作 | {{.ID}} | {{.Username}} | {{.Nickname}} | +{{if .Email}}{{.Email}}{{else}}—{{end}} | {{if eq .Role "admin"}} 管理员 @@ -27,6 +28,8 @@ | {{if .Banned}}已禁言{{else}}正常{{end}} | +{{if .LastLoginAt}}{{.LastLoginAt.Format "2006-01-02 15:04"}}{{else}}—{{end}} | +{{if .LastLoginIP}}{{.LastLoginIP}}{{else}}—{{end}} | {{.CreatedAt.Format "2006-01-02"}} | {{if eq .Role "admin"}} @@ -39,7 +42,7 @@ | {{else}} -
| 暂无用户 | |||||||||
| 暂无用户 | |||||||||
@{{.ProfileUser.Username}}
+{{if .ProfileUser.Email}}{{.ProfileUser.Email}}{{else}}未设置邮箱{{end}}