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 @@ 浅色主题首页
浅色主题
- 左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线 + 左栏板块导航 · Feed 排序切换 · 右栏热门/评论 暗色主题首页 @@ -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 @@
评论总数
-
-
线
-
-
{{.OnlineCount}}
-
当前浏览
-
-
diff --git a/embed_static/templates/admin/users.html b/embed_static/templates/admin/users.html index 92d4bb3..a614d6c 100644 --- a/embed_static/templates/admin/users.html +++ b/embed_static/templates/admin/users.html @@ -11,7 +11,7 @@
- + {{range .Users}} @@ -19,6 +19,7 @@ + + + {{else}} - + {{end}}
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 @@
暂无用户
暂无用户
diff --git a/embed_static/templates/profile.html b/embed_static/templates/profile.html index 7034170..bc68f6c 100644 --- a/embed_static/templates/profile.html +++ b/embed_static/templates/profile.html @@ -5,6 +5,7 @@ avatar

{{.ProfileUser.Nickname}}

@{{.ProfileUser.Username}}

+

{{if .ProfileUser.Email}}{{.ProfileUser.Email}}{{else}}未设置邮箱{{end}}

diff --git a/embed_static/templates/register.html b/embed_static/templates/register.html index a6b8539..5dd0b42 100644 --- a/embed_static/templates/register.html +++ b/embed_static/templates/register.html @@ -5,20 +5,31 @@

注册账号

-

本站首个注册用户将自动成为管理员

+

- +
+
+ + +
+

已有账号?去登录

@@ -29,6 +40,26 @@ {{end}} {{define "scripts"}}