merge(wavelet): sync upstream changes

This commit is contained in:
ryan
2026-09-03 09:29:28 +08:00
220 changed files with 12924 additions and 15487 deletions
+28 -92
View File
@@ -13,102 +13,38 @@ description: "Wavelet 项目专用:当新增或修改自定义业务 API、新
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
1. **禁止修改框架级路由文件**:
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
- `internal/router/router.go`(核心入口委派)
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
- `internal/router/root/frontend.go`(前端静态服务)
- `internal/router/v1/v1.go`(V1 分发层协调器)
- `internal/router/v1/admin.go`(框架管理员端管理接口)
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
2. **仅允许在 `custom.go` 中注册业务接口**:
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
### 插件目录标准结构 (`backend/openflare/plugins/<name>/` 或 `backend/plugins/domain/<name>/`)
---
## 路由归属判定表 (Where should I register my new API?)
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
| :--- | :--- | :--- | :--- |
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
---
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
### 1. 根路径自定义包:`root/custom.go`
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
* **用法示例**:
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
```go
package root
import (
"OpenFlare/internal/apps/custom"
"github.com/gin-gonic/gin"
)
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
func RegisterCustomRootRoutes(r *gin.Engine) {
// 挂载到根路径下,如 GET /my-custom-webhook
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
}
```
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
### 2. V1 API 自定义包:`v1/custom.go`
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
* **用法示例**:
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
```go
package v1
import (
"OpenFlare/internal/apps/custom"
"github.com/gin-gonic/gin"
)
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
customRouter := apiV1Router.Group("/custom")
{
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
customRouter.POST("/action", custom.DoActionHandler)
}
}
```
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
---
## 建议创建/修改的文件结构 (Recommended Directory Structure)
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
所有标准插件与下游定制插件,**统一以 `backend/downstream/plugins/custom_example` 为基准模板**,严格采用物理子包隔离的分层架构:
```text
internal/
├── router/
│ ├── root/
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
│ └── v1/
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
└── apps/
└── custom/
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
backend/openflare/plugins/<name>/ (或 backend/plugins/domain/<name>/)
├── plugin.go # 插件根入口:实现 core.Plugin,装配各子包并向 Cordis 注册
│
├── consts/ # package consts:常量、配置键名与错误码定义
│ └── consts.go
│
├── controller/ # package controller:HTTP 控制器与路由声明 (参数绑定、会话获取、信封响应)
│ └── hello/ # 业务分组/实体子包
│ └── hello.go # 接口处理 Handler(直接以业务命名,禁止 controller_hello.go)
│
├── service/ # package service:业务逻辑层(用例编排、事务控制、事件发布)
│ └── order.go # 订单业务用例实现(纯 Go 逻辑,禁止依赖 *gin.Context)
│
├── dao/ # package dao:数据访问持久化层 DAL (GORM CRUD、SQL 转义防注入)
│ └── order.go # 订单数据访问实现(直接以业务命名,禁止 dao_order.go)
│
├── model/ # package model:纯数据实体与 DTO(无外部依赖)
│ ├── entity/ # 数据库映射实体 (TableName() 带插件专属前缀)
│ │ └── order.go
│ └── do/ # 请求 Request DTO 与响应 Response DTO、领域对象
│ └── order.go
│
└── migrations/ # 专属嵌入式 Goose SQL 双方言迁移脚本 (//go:embed)
├── postgres/ # PostgreSQL 迁移脚本
└── sqlite/ # SQLite 迁移脚本
```
> ⚠️ **严禁**:严禁在根目录平铺 `handlers_*.go`、`service_*.go`、`dao_*.go` 等前缀文件,子包内文件直接按业务实体命名。严格约束 `controller -> service -> dao -> model` 单向依赖。
---
+3 -4
View File
@@ -14,10 +14,9 @@ description: "Wavelet 项目专用:当需要开发或接入新的系统通知
Wavelet 的消息推送机制采用了**元数据驱动 + 统一触发器 + 异步任务派发**的解耦设计,其分层及职责划分如下:
| 目录/包名 | 职责定位 | 包含内容与设计细节 |
| :--- | :--- | :--- |
| **`pkg/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher`、单例 `PusherPool` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. [events.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/events.go):定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. [tasks.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/tasks.go):定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. [routers.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/routers.go):管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 [register.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/admin/push/custom_events/register.go) 统一装配,禁止 `init()` 副作用。 |
| **`backend/plugins/domain/msg_gateway/push/`** | 推送基础设施层 | 静态定义、不依赖系统数据库和任何框架。定义了统一接口 `Pusher` 和多实现(Lark, Webhook, Email 等),提供配置验证及发送功能。 |
| **`internal/apps/admin/push/`** | 通知服务与后台任务层 | 包含以下核心文件:<br>1. `events.go`:定义通知事件的结构模型(`NotificationMessage`, `EventMetadata`)、内置事件的动态注册中心(`BuiltInEvents` 及 `RegisterBuiltInEvent` 函数)以及统一触发器类 `EventTrigger`(包括其底层的派发引擎逻辑)。<br>2. `tasks.go`:定义 Asynq 后台异步发送任务、处理器 `PushHandler` 及其校验逻辑,并记录推送历史审计。<br>3. `routers.go`:管理端接口,负责获取事件配置列表和更新配置。 |
| **`internal/apps/admin/push/custom_events/`** | 自定义通知事件包 | 事件元数据定义与 push 侧处理逻辑;**一个 Go 文件代表一个事件**。在 `register.go` 统一装配,禁止 `init()` 副作用。 |
| **`internal/listener/`** | 域事件分发层 | 核心域发射事件(如 `EmitAdminLoggedIn`),push 在 bootstrap 阶段通过 `OnAdminLoggedIn` 订阅,避免 auth/user 直接依赖 push。 |
| **`internal/platform/bootstrap/`** | 应用装配根 | `RegisterPushDomainEvents()` 调用 `custom_events.Register()`;`Init` 中执行 `SyncEvents` 将内置事件元数据同步到数据库。 |
| **数据库审计表** | 状态与历史审计 | `w_push_events` 存放每个通知事件的启用状态、启用渠道、发送目标和自定义渲染模板。<br>`w_push_histories` 存放消息发送记录用于审计。 |
+5
View File
@@ -94,3 +94,8 @@ backend/plugins/**/dist/
backend/core/**/dist/
backend/pkg/**/uploads/
/backend/openflare/plugins/server/upload/filesrv/uploads/
/backend/plugins/domain/upload/filesrv/uploads/
/backend/plugins/domain/upload/task/uploads/
/backend/data/
/backend/plugins/drivers/driver_http/dist/
/backend/uploads/
+35
View File
@@ -94,6 +94,41 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
- 上游暂缺而确属通用能力时,可先在本仓库实现并登记到 `backend/openflare/upstream-patches.md`
(merge 上游后请确认补丁仍在),回流 Wavelet 后删除登记并重新 merge。
### Cordis 架构核心防线与分层规范
- **微内核 (`backend/core/`)**:
- 上下文总线(`Context`)、泛型依赖注入(`Container`)、生命周期编排(`Lifecycle`)、扩展点定义(`extpoints/`)与领域事件总线(`EventBus`)。
- **严禁**包含任何具体业务逻辑,**严禁** import `gin`、`gorm`、`asynq` 等具体运行时依赖。
- **服务契约 (`backend/core/contracts/`)**:
- 跨插件通信的统一公开 Go Interface(如 `AuthService`、`UserService`、`CacheService`、`DBService`、`StorageService`)与公共 DTO。
- **严禁**包含任何具体业务实现或 SQL 操作。
- **自包含插件 (`backend/plugins/`)**:
- 所有业务功能与驱动实现均以插件形式存在(`backend/plugins/drivers/`、`backend/plugins/infra/`、`backend/plugins/domain/` 或下游 `backend/openflare/plugins/`)。
- 每个插件实现 `core.Plugin`(`Name() string` 与 `Apply(ctx *core.Context) error`)。
- **统一插件分层架构与标准模板**:
- **开发模板唯一基准**:所有插件统一以 `backend/downstream/plugins/custom_example` 为基准模板构建。
- **物理子包隔离规范**:统一采用物理子包结构(`plugin.go`, `consts/`, `controller/`, `service/`, `dao/`, `model/` [含 `entity/`, `do/`], `migrations/` [含 `postgres/`, `sqlite/`])。**严禁在根包平铺 `handlers_*`、`service_*`、`dao_*` 等前缀文件**,子包内文件直接按业务实体命名(如 `hello.go`, `user.go`),严格约束 `controller -> service -> dao -> model` 单向依赖。
- **插件通信与依赖隔离**:
- **严禁跨包 import internal/私有实现**:插件之间严禁直接 import 对方具体实现包代码。
- **单向服务契约调用**:调用方仅面向 `backend/core/contracts` 编程,在 `Apply` 中通过 `core.Provide[contracts.XxxService](ctx, svc)` 注册服务,通过 `core.Inject[contracts.XxxService](ctx)` 或 `ctx.Using(func(svc contracts.XxxService) { ... })` 声明式解析。
- **事件总线广播**:状态联动与解耦通信统一通过强类型事件 `ctx.Events().Emit()` 广播,由感兴趣的插件通过 `ctx.Events().On()` 订阅,消除双向依赖与循环引用。
- **扩展点自包含注册**:
- **HTTP 路由与白名单机制**:
- 插件自包含在 `Apply` 中通过 `ctx.Router().Group(...)` 挂载路由与中间件,禁止跨插件散落注册。
- **白名单机制**:`driver_http` 与微内核扩展点提供路由白名单支持(`ctx.Router().RegisterWhitelist(patterns...)`),支持精确路径与通配符(如 `/api/v1/oauth/*`)。
- **所有权主动声明**:认证域(`auth` 插件)与各业务插件必须在 `Apply` 中主动注册其公开/免鉴权接口(如 `/api/v1/user/login`、`/api/v1/oauth/callback`、`/api/v1/cap/*` 等)。
- **鉴权中间件放行防线**:`auth` 提供的登录鉴权中间件(`LoginRequired`)必须先执行白名单匹配并自动放行,彻底杜绝免鉴权接口被全局或组级鉴权中间件误拦截(返回 401 Unauthorized)。
- **异步与定时任务**:插件自包含在 `Apply` 中通过 `ctx.Task().Register(...)` 与 `ctx.Schedule().RegisterCron(...)` 声明。
- **静态启动配置**:插件自包含在 `Apply` 中通过 `ctx.Config().Bind("<prefix>", &cfg)` 读取**自己声明**的配置,字段以 tag 表达来源:`config`(yaml 路径)、`env`(覆盖变量名)、`default`、`autoEnable`(该变量存在即置真)、`secret`(导出脱敏)。需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明并实现 `core.ConfigGatedPlugin`。新增基础设施 key 保持顶层命名(`redis.*`),插件私有配置归 `plugins.<name>.*`。**严禁**再造全局配置单例或在 `backend/pkg/` 读取配置。
- **动态设置**:插件自包含在 `Apply` 中通过 `ctx.Settings().Register(core.SettingSchema{...})` 声明可热更新的管理台设置模式(与上面的静态启动配置分属两层)。
- **数据迁移**:插件自包含在内部维护 `migrations/*.sql`,通过 `//go:embed` 打包并在 `Apply` 中通过 `ctx.Migrations().Register(pluginID, embedFS)` 注入。
- **表单一所有者原则 (Single Owner Principle)**:
- 每张数据表有且仅由一个所有者插件声明与维护(表名使用插件前缀如 `w_order_*`)。
- 严禁插件 B 跨过所有者插件 A 直接 DDL/DML 旁路读写表 A,必须调用插件 A 暴露的 `contracts` 接口或订阅事件。
- **平台服务复用**:
- 文件摄取统一使用 `upload.Ingest` / `contracts.StorageService`,禁止绕过存储域直接操作底层 Bucket 或直写文件表。
- 业务缓存统一使用 `ctx.Cache()`(`contracts.CacheService`)或标准缓存框架,禁止自研不带失效广播的本地 map。
- 数据库操作通过 `ctx.DB()`(`contracts.DBService`)获取受事务与 Trace 保护的连接。
- 禁止删除 `frontend/node_modules`。
- `backend/pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `backend/openflare/plugins/server/oauth/session.go`)。
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
+3 -5
View File
@@ -10,8 +10,7 @@ import (
"Wavelet/openflare/plugins/server/migrate"
"Wavelet/plugins/domain/admin"
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/domain/cap"
"Wavelet/plugins/domain/message_gateway"
"Wavelet/plugins/domain/msg_gateway"
"Wavelet/plugins/domain/risk_control"
"Wavelet/plugins/domain/system"
"Wavelet/plugins/domain/upload"
@@ -103,15 +102,14 @@ func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App {
driver_inproc_cron.New(),
)
// 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist)
// 3. Register all 7 domain business plugins (admin first to ensure schema and base config tables exist)
app.Use(
admin.New(),
user.New(),
auth.New(),
message_gateway.New(),
msg_gateway.New(),
risk_control.New(),
upload.New(),
cap.New(),
system.New(),
)
+1 -1
View File
@@ -36,7 +36,7 @@ func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
for _, p := range app.Plugins() {
names[p.Name()] = true
}
for _, n := range []string{"user", "auth", "cap", "admin", "server"} {
for _, n := range []string{"user", "auth", "admin", "server"} {
if !names[n] {
t.Errorf("missing plugin %s", n)
}
+44 -14
View File
@@ -13,18 +13,20 @@ import (
// Container manages service registration and resolution using Go reflection and generics.
type Container struct {
mu sync.RWMutex
parent *Container
services map[reflect.Type]any
listeners map[reflect.Type][]func(any)
mu sync.RWMutex
parent *Container
services map[reflect.Type]any
interfaceCache map[reflect.Type]any
listeners map[reflect.Type][]func(any)
}
// NewContainer creates a new IoC container instance with an optional parent container.
func NewContainer(parent *Container) *Container {
return &Container{
parent: parent,
services: make(map[reflect.Type]any),
listeners: make(map[reflect.Type][]func(any)),
parent: parent,
services: make(map[reflect.Type]any),
interfaceCache: make(map[reflect.Type]any),
listeners: make(map[reflect.Type][]func(any)),
}
}
@@ -45,6 +47,7 @@ func (c *Container) remove(targetType reflect.Type) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.services, targetType)
c.interfaceCache = make(map[reflect.Type]any)
}
// Provide registers a typed service implementation into the Context hierarchy's root IoC container.
@@ -88,6 +91,7 @@ func ProvideScoped[T any](ctx *Context, service T) {
func (c *Container) provide(targetType reflect.Type, service any) {
c.mu.Lock()
c.services[targetType] = service
c.interfaceCache = make(map[reflect.Type]any)
// Collect any matching listeners to invoke outside the lock
var callbacks []func(any)
@@ -131,17 +135,14 @@ func (c *Container) resolve(targetType reflect.Type) (any, error) {
c.mu.RUnlock()
return val, nil
}
c.mu.RUnlock()
// 2. Interface assignment scan
// 2. Interface assignment scan & cache
if targetType.Kind() == reflect.Interface {
for _, val := range c.services {
if reflect.TypeOf(val).Implements(targetType) {
c.mu.RUnlock()
return val, nil
}
if val, found := c.resolveInterface(targetType); found {
return val, nil
}
}
c.mu.RUnlock()
// 3. Fallback to parent container
if c.parent != nil {
@@ -151,6 +152,35 @@ func (c *Container) resolve(targetType reflect.Type) (any, error) {
return nil, fmt.Errorf("%w: %v", ErrServiceNotFound, targetType)
}
func (c *Container) resolveInterface(targetType reflect.Type) (any, bool) {
c.mu.RLock()
if val, ok := c.interfaceCache[targetType]; ok {
c.mu.RUnlock()
return val, true
}
var matched any
for _, val := range c.services {
if reflect.TypeOf(val).Implements(targetType) {
matched = val
break
}
}
c.mu.RUnlock()
if matched == nil {
return nil, false
}
c.mu.Lock()
if c.interfaceCache == nil {
c.interfaceCache = make(map[reflect.Type]any)
}
c.interfaceCache[targetType] = matched
c.mu.Unlock()
return matched, true
}
// MustInject resolves a service of type T or panics if the service is not found.
func MustInject[T any](ctx *Context) T {
s, err := Inject[T](ctx)
+47 -1
View File
@@ -571,10 +571,16 @@ func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) {
root := core.NewContext(context.Background())
child := root.Fork()
// Register route, task, schedule, setting, event on child
// Register route, task, schedule, setting, event, middleware, whitelist on child
child.Router().GET("/test-route", func() {})
assert.Equal(t, 1, len(root.Router().Routes()))
child.Router().Use("scoped_middleware")
assert.Equal(t, 1, len(root.Router().Middlewares()))
child.Router().RegisterWhitelist("/api/v1/scoped/*")
assert.True(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
child.Tasks().Register("test:task", func() {})
assert.Equal(t, 1, len(root.Tasks().Tasks()))
@@ -593,8 +599,48 @@ func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) {
// All child effects should be cleanly revoked in LIFO order
assert.Equal(t, 0, len(root.Router().Routes()))
assert.Equal(t, 0, len(root.Router().Middlewares()))
assert.False(t, root.Router().IsWhitelisted("/api/v1/scoped/test"))
assert.Equal(t, 0, len(root.Tasks().Tasks()))
assert.Equal(t, 0, len(root.Schedules().Schedules()))
assert.Equal(t, 0, len(root.Settings().Schemas()))
assert.Equal(t, 0, root.Events().Listeners("test:event"))
}
func TestContainer_InterfaceResolutionCache(t *testing.T) {
ctx := core.NewContext(context.Background())
svc := &sampleServiceImpl{prefix: "Cached:"}
core.Provide[SampleService](ctx, svc)
// 1. Initial resolution populates interfaceCache
res1, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Cached: Alice", res1.Greet("Alice"))
// 2. Subsequent resolutions hit interfaceCache
res2, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Same(t, res1, res2)
// 3. Concurrent lookups
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
r, e := core.Inject[SampleService](ctx)
assert.NoError(t, e)
assert.Equal(t, "Cached: Bob", r.Greet("Bob"))
}()
}
wg.Wait()
// 4. Overriding/providing another service invalidates cache
svc2 := &sampleServiceImpl{prefix: "Updated:"}
core.Provide[SampleService](ctx, svc2)
res3, err := core.Inject[SampleService](ctx)
require.NoError(t, err)
assert.Equal(t, "Updated: Alice", res3.Greet("Alice"))
}
+5
View File
@@ -151,3 +151,8 @@ type UserDeletedEvent struct {
CurrentUserID uint64 `json:"current_user_id,string"`
TargetUserID uint64 `json:"target_user_id,string"`
}
// SystemCleanupEvent fires when a periodic system cleanup is triggered.
type SystemCleanupEvent struct {
TriggeredAt string `json:"triggered_at"`
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package contracts defines unified service interfaces and DTOs for cross-plugin communication.
package contracts
import (
"context"
"time"
)
// Rate specifies a rate limit of Limit events permitted within a Period.
type Rate struct {
Limit int `json:"limit"`
Period time.Duration `json:"period"`
}
// RateLimitResult holds the outcome of a rate limit check.
type RateLimitResult struct {
Allowed bool `json:"allowed"`
Remaining int `json:"remaining"`
ResetAfter time.Duration `json:"reset_after"`
RetryAfter time.Duration `json:"retry_after"`
}
// LimiterService defines the rate limiting service contract for cross-plugin communication.
type LimiterService interface {
// Allow checks whether 1 event for the given key is permitted under the specified rate.
Allow(ctx context.Context, key string, rate Rate) (*RateLimitResult, error)
// AllowN checks whether n events for the given key are permitted under the specified rate.
AllowN(ctx context.Context, key string, rate Rate, n int) (*RateLimitResult, error)
// Reset clears the rate limit state for the given key.
Reset(ctx context.Context, key string) error
}
+3
View File
@@ -6,6 +6,7 @@ package contracts
import "context"
// PushNotificationTemplate defines notification message template payload.
type PushNotificationTemplate struct {
Title string
Content string
@@ -13,6 +14,7 @@ type PushNotificationTemplate struct {
Ext map[string]any
}
// PushEventMeta defines metadata for a system push event.
type PushEventMeta struct {
Key string
Name string
@@ -20,6 +22,7 @@ type PushEventMeta struct {
DefaultTemplate PushNotificationTemplate
}
// PushRegistry defines the interface for registering built-in events.
type PushRegistry interface {
RegisterBuiltInEvent(meta PushEventMeta)
SyncEvents(ctx context.Context) error
+18 -9
View File
@@ -283,7 +283,8 @@ func (b *EventBus) Waterfall(ctx context.Context, topic string, initialPayload a
}
// Parallel executes all subscribers of the topic concurrently in separate goroutines.
// It waits for all handlers to complete and collects any errors via errors.Join.
// It waits for all handlers to complete or returns immediately if ctx is cancelled/timed out,
// collecting any handler errors via errors.Join.
//
//nolint:contextcheck
func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) error {
@@ -325,17 +326,25 @@ func (b *EventBus) Parallel(ctx context.Context, topic string, payload any) erro
}(l)
}
wg.Wait()
close(errCh)
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
var errs []error
for err := range errCh {
if err != nil {
errs = append(errs, err)
select {
case <-done:
close(errCh)
var errs []error
for err := range errCh {
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
case <-ctx.Done():
return ctx.Err()
}
return errors.Join(errs...)
}
// Serial executes subscribers strictly in sequence.
+20
View File
@@ -11,6 +11,7 @@ import (
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -368,6 +369,25 @@ func TestEventBusParallel(t *testing.T) {
assert.Equal(t, int64(20), counter.Load())
}
func TestEventBusParallelContextTimeout(t *testing.T) {
bus := core.NewEventBus()
bus.On("test:timeout", func(ctx context.Context) error {
select {
case <-time.After(200 * time.Millisecond):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
err := bus.Parallel(ctx, "test:timeout", nil)
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
func TestEventBusSerial(t *testing.T) {
bus := core.NewEventBus()
+1 -1
View File
@@ -181,7 +181,7 @@ func formatEntryValue(value any, secret bool) string {
return ""
}
rv := reflect.ValueOf(value)
if rv.Kind() == reflect.Ptr {
if rv.Kind() == reflect.Pointer {
if rv.IsNil() {
return ""
}
+2 -2
View File
@@ -33,7 +33,7 @@ func convertValue(raw any, typ reflect.Type) (any, error) {
return convertSlice(raw, typ)
case reflect.Struct:
return convertStruct(raw, typ)
case reflect.Ptr:
case reflect.Pointer:
return convertPointer(raw, typ)
default:
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
@@ -44,7 +44,7 @@ func convertValue(raw any, typ reflect.Type) (any, error) {
// Nested pointers are rejected so configuration tags stay one level deep.
func convertPointer(raw any, typ reflect.Type) (any, error) {
elemType := typ.Elem()
if elemType.Kind() == reflect.Ptr {
if elemType.Kind() == reflect.Pointer {
return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ)
}
elem, err := convertValue(raw, elemType)
+15 -1
View File
@@ -379,7 +379,7 @@ func TestContextExtensionPointsIntegration(t *testing.T) {
func TestExtensionPointsUnregister(t *testing.T) {
ctx := core.NewContext(context.Background())
// 1. Router unregister
// 1. Router unregister (routes, middlewares, whitelist)
rd := ctx.Router().GET("/temp", "temp_handler")
assert.Greater(t, rd.ID, uint64(0))
assert.Len(t, ctx.Router().Routes(), 1)
@@ -391,6 +391,20 @@ func TestExtensionPointsUnregister(t *testing.T) {
assert.True(t, ctx.Router().UnregisterByID(rd2.ID))
assert.Len(t, ctx.Router().Routes(), 0)
ctx.Router().Use("mw1")
assert.Len(t, ctx.Router().Middlewares(), 1)
if reg, ok := ctx.Router().(*extpoints.RouterRegistry); ok {
ids := reg.UseWithID("mw2")
assert.Len(t, ctx.Router().Middlewares(), 2)
assert.True(t, ctx.Router().UnregisterMiddlewareByID(ids[0]))
assert.Len(t, ctx.Router().Middlewares(), 1)
}
ctx.Router().RegisterWhitelist("/api/v1/temp/*")
assert.True(t, ctx.Router().IsWhitelisted("/api/v1/temp/item"))
ctx.Router().UnregisterWhitelist("/api/v1/temp/*")
assert.False(t, ctx.Router().IsWhitelisted("/api/v1/temp/item"))
// 2. Task unregister
ctx.Task().Register("temp:task", "handler")
assert.Len(t, ctx.Task().Tasks(), 1)
+84 -6
View File
@@ -39,17 +39,26 @@ type RouterExtension interface {
Middlewares() []any
Unregister(method, path string) bool
UnregisterByID(id uint64) bool
UnregisterMiddlewareByID(id uint64) bool
RegisterWhitelist(patterns ...string)
UnregisterWhitelist(patterns ...string)
Whitelist() []string
IsWhitelisted(path string) bool
}
// middlewareDefinition holds an assigned ID and handler for registered middleware.
type middlewareDefinition struct {
ID uint64
Handler any
}
// RouterRegistry implements RouterExtension as the root route and middleware collector.
type RouterRegistry struct {
mu sync.RWMutex
nextID uint64
nextMWID uint64
routes []RouteDefinition
middlewares []any
middlewares []middlewareDefinition
whitelist PathWhitelist
}
@@ -60,17 +69,48 @@ func NewRouterRegistry() *RouterRegistry {
// Use registers global middlewares to the router.
func (r *RouterRegistry) Use(middlewares ...any) {
r.mu.Lock()
defer r.mu.Unlock()
r.middlewares = append(r.middlewares, middlewares...)
r.UseWithID(middlewares...)
}
// Middlewares returns a copy of registered root middlewares.
// UseWithID registers global middlewares to the router and returns their assigned IDs.
func (r *RouterRegistry) UseWithID(middlewares ...any) []uint64 {
r.mu.Lock()
defer r.mu.Unlock()
ids := make([]uint64, 0, len(middlewares))
for _, mw := range middlewares {
r.nextMWID++
r.middlewares = append(r.middlewares, middlewareDefinition{
ID: r.nextMWID,
Handler: mw,
})
ids = append(ids, r.nextMWID)
}
return ids
}
// UnregisterMiddlewareByID removes a registered global middleware by its unique ID.
func (r *RouterRegistry) UnregisterMiddlewareByID(id uint64) bool {
r.mu.Lock()
defer r.mu.Unlock()
for i, mw := range r.middlewares {
if mw.ID == id {
r.middlewares = append(r.middlewares[:i], r.middlewares[i+1:]...)
return true
}
}
return false
}
// Middlewares returns a copy of registered root middleware handlers.
func (r *RouterRegistry) Middlewares() []any {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]any, len(r.middlewares))
copy(res, r.middlewares)
for i, mw := range r.middlewares {
res[i] = mw.Handler
}
return res
}
@@ -196,6 +236,11 @@ func (r *RouterRegistry) RegisterWhitelist(patterns ...string) {
r.whitelist.Add(patterns...)
}
// UnregisterWhitelist removes path patterns from the whitelist.
func (r *RouterRegistry) UnregisterWhitelist(patterns ...string) {
r.whitelist.Remove(patterns...)
}
// Whitelist returns a copy of all registered whitelist path patterns.
func (r *RouterRegistry) Whitelist() []string {
return r.whitelist.Patterns()
@@ -267,6 +312,11 @@ func (g *RouterGroup) UnregisterByID(id uint64) bool {
return g.registry.UnregisterByID(id)
}
// UnregisterMiddlewareByID removes a middleware by ID via the root registry.
func (g *RouterGroup) UnregisterMiddlewareByID(id uint64) bool {
return g.registry.UnregisterMiddlewareByID(id)
}
// GET registers a GET route in this group.
func (g *RouterGroup) GET(path string, handlers ...any) RouteDefinition {
return g.Handle("GET", path, handlers...)
@@ -331,6 +381,13 @@ func (g *RouterGroup) RegisterWhitelist(patterns ...string) {
}
}
// UnregisterWhitelist removes path patterns under this group prefix from the whitelist.
func (g *RouterGroup) UnregisterWhitelist(patterns ...string) {
for _, p := range patterns {
g.registry.UnregisterWhitelist(joinPaths(g.prefix, p))
}
}
// Whitelist returns a copy of all registered whitelist path patterns.
func (g *RouterGroup) Whitelist() []string {
return g.registry.Whitelist()
@@ -465,6 +522,27 @@ func (w *PathWhitelist) Replace(patterns ...string) {
w.patterns = compiled
}
// Remove removes matching patterns from the whitelist.
func (w *PathWhitelist) Remove(patterns ...string) {
if len(patterns) == 0 {
return
}
targets := make(map[string]struct{}, len(patterns))
for _, p := range patterns {
targets[cleanPath(p)] = struct{}{}
}
w.mu.Lock()
defer w.mu.Unlock()
filtered := w.patterns[:0]
for _, p := range w.patterns {
if _, remove := targets[p.raw]; !remove {
filtered = append(filtered, p)
}
}
w.patterns = filtered
}
// Match reports whether path matches any registered pattern. Equivalent to calling
// MatchPathPattern for every pattern, except the path is normalised and split once.
func (w *PathWhitelist) Match(path string) bool {
+27
View File
@@ -22,6 +22,19 @@ func newScopedRouterExtension(ctx *Context, underlying extpoints.RouterExtension
}
func (s *scopedRouterExtension) Use(middlewares ...any) {
if len(middlewares) == 0 {
return
}
if reg, ok := s.underlying.(*extpoints.RouterRegistry); ok {
ids := reg.UseWithID(middlewares...)
s.ctx.OnDispose(func() error {
for _, id := range ids {
reg.UnregisterMiddlewareByID(id)
}
return nil
})
return
}
s.underlying.Use(middlewares...)
}
@@ -107,8 +120,22 @@ func (s *scopedRouterExtension) UnregisterByID(id uint64) bool {
return s.underlying.UnregisterByID(id)
}
func (s *scopedRouterExtension) UnregisterMiddlewareByID(id uint64) bool {
return s.underlying.UnregisterMiddlewareByID(id)
}
func (s *scopedRouterExtension) RegisterWhitelist(patterns ...string) {
s.underlying.RegisterWhitelist(patterns...)
if len(patterns) > 0 {
s.ctx.OnDispose(func() error {
s.underlying.UnregisterWhitelist(patterns...)
return nil
})
}
}
func (s *scopedRouterExtension) UnregisterWhitelist(patterns ...string) {
s.underlying.UnregisterWhitelist(patterns...)
}
func (s *scopedRouterExtension) Whitelist() []string {
+651 -603
View File
File diff suppressed because it is too large Load Diff
+648 -603
View File
File diff suppressed because it is too large Load Diff
+439 -409
View File
File diff suppressed because it is too large Load Diff
+122 -52
View File
@@ -1,77 +1,147 @@
# Downstream Custom Plugins
# 下游插件开发指南 (Downstream Custom Plugins)
This directory is the designated location for downstream (deployment-specific) Cordis plugins.
本目录为 Wavelet 下游业务定制插件(Deployment-specific plugins)的专属开发目录。
## Architecture
所有插件开发均以标准模板 [`custom_example`](./plugins/custom_example) 为基准进行构建。
```
---
## 目录结构
```text
downstream/
├── README.md
└── plugins/
└── custom_example/ # Example plugin — copy & rename to get started
└── plugin.go
└── custom_example/ # 标准插件开发基准模板(可直接复制并重命名开发新插件)
├── plugin.go # 插件入口:实现 core.Plugin (Name & Apply)
├── consts/ # 常量与错误码定义
│ └── consts.go
├── controller/ # 控制器层 (HTTP API 接口、参数绑定与信封响应)
│ └── hello/
│ └── hello.go
├── service/ # 业务逻辑层 (用例编排、事务控制与领域事件)
├── dao/ # 数据访问层 (GORM CRUD、SQL 防注入与转义)
├── model/ # 数据模型与实体定义
│ ├── do/ # Domain Object 领域对象与 DTO
│ └── entity/ # 数据表映射实体 (TableName() 带专属前缀)
└── migrations/ # Goose SQL 双方言独立数据库迁移
├── postgres/ # PostgreSQL 迁移脚本
└── sqlite/ # SQLite 迁移脚本
```
Downstream plugins follow the same `core.Plugin` contract as platform plugins:
---
```go
type Plugin interface {
Name() string
Apply(ctx *core.Context) error
}
## 插件开发规范与分层职责
每个下游插件均需遵循标准分层架构(`controller -> service -> dao -> model`):
1. **`plugin.go` (插件装配入口)**:
- 实现 `core.Plugin` 接口(`Name() string` 与 `Apply(ctx *core.Context) error`)。
- 负责在 `Apply` 中注册路由组(`ctx.Router()`)、异步任务(`ctx.Task()`)、定时调度(`ctx.Schedule()`)与数据库迁移(`ctx.Migrations()`)。
- 依赖注入统一使用 `core.Inject` 或 `ctx.Using` 获取平台服务(如 `contracts.AuthService`、`contracts.DBService`、`contracts.CacheService`)。
2. **`controller/` (控制器层 / Handler)**:
- 负责 HTTP API 请求参数绑定(`c.ShouldBindJSON` / `c.ShouldBindQuery`)、用户会话获取(`oauth.GetCurrentUser`)。
- 调用 Service 层处理业务,严禁直接包含复杂业务逻辑或直接执行 SQL 操作。
- 统一使用 `response.OK` 或 `response.Abort*` 返回标准信封响应。
3. **`service/` (业务逻辑层)**:
- 纯 Go 业务用例,方法入参首位统一为 `context.Context`。
- 严禁依赖 `*gin.Context` 或 HTTP 相关对象,确保逻辑具备可移植性与可测试性。
- 涉及数据修改可通过 `ctx.Events().Emit()` 发射强类型领域事件。
4. **`dao/` (数据访问层 / Repository)**:
- 负责底层数据库交互,通过 `contracts.DBService` 获取受保护的 GORM 数据库句柄。
- 模糊查询必须调用 `pkg/util.EscapeLike` 并显式声明 `ESCAPE '\\'` 防注入。
- 遵循**表单一所有者原则**:严禁跨过其他所有者插件直读或修改其他插件的数据表。
5. **`model/` (模型与 DTO)**:
- `model/entity/`:数据库表映射结构体,`TableName()` 必须带有插件专属前缀(如 `w_custom_*`)。
- `model/do/`:业务领域对象、入参校验 Request DTO 与响应 Response DTO。
6. **`consts/` (常量与错误定义)**:
- 定义插件内部常量、配置键名及驼峰式(camelCase)错误标识字符串。
7. **`migrations/` (双方言 SQL 迁移)**:
- 包含 `postgres/` 与 `sqlite/` 双方言 Goose SQL 迁移文件,使用 `//go:embed` 打包并在 `Apply()` 中注册。
---
## 快速上手
### 1. 基于模板复制创建新插件
```bash
cp -r backend/downstream/plugins/custom_example backend/downstream/plugins/my_plugin
```
## Rules
1. **Naming**: Each plugin directory name becomes its import path and plugin ID (kebab-case recommended).
2. **Dependencies**: Downstream plugins may import `core/`, `core/contracts/`, `pkg/`, and `plugins/infra/` packages from the platform. They MUST NOT import domain plugin internal packages — use `core.Inject[contracts.XxxService](ctx)` instead.
3. **Registration**: Add your downstream plugin to `cmd/app.go` before the platform plugins or after, depending on which services it needs:
```go
// newWaveletApp in cmd/app.go
app.Use(
database.New(),
cache.New(),
logger.New(),
storage.New(),
// ... platform domain plugins ...
custom_hello.New(), // your downstream plugin
driver_http.New(),
driver_asynq_worker.New(),
driver_asynq_cron.New(),
)
```
4. **Migration**: If your plugin needs database tables, embed SQL files in a `migrations/` directory and register via `ctx.Migrations().Register(...)` in `Apply()`.
## Quick Start
### 2. 实现插件入口 (`plugin.go`)
```go
package custom_example
package my_plugin
import (
"github.com/Rain-kl/Wavelet/core"
"github.com/Rain-kl/Wavelet/core/contracts"
"github.com/gin-gonic/gin"
"Wavelet/core"
"Wavelet/core/contracts"
"net/http"
"github.com/gin-gonic/gin"
)
type Plugin struct{}
func New() *Plugin { return &Plugin{} }
func New() *Plugin {
return &Plugin{}
}
func (p *Plugin) Name() string { return "custom_example" }
func (p *Plugin) Name() string {
return "my_plugin"
}
func (p *Plugin) Apply(ctx *core.Context) error {
// Example: register a route that uses AuthService
var authSvc contracts.AuthService
if err := ctx.Using(func(svc contracts.AuthService) { authSvc = svc }); err != nil {
return err
}
// 通过容器解析认证服务
var authSvc contracts.AuthService
if err := core.Using[contracts.AuthService](ctx, func(svc contracts.AuthService) { authSvc = svc }); err != nil {
return err
}
g := ctx.Router().Group("/api/v1/custom", authSvc.RequireAuthMiddleware().(gin.HandlerFunc))
g.GET("/hello", func(c *gin.Context) {
user, _ := authSvc.GetCurrentUser(c.Request.Context())
c.JSON(200, gin.H{"message": "Hello " + user.Username})
})
// 注册带鉴权中间件的路由组
g := ctx.Router().Group("/api/v1/my-plugin", authSvc.RequireAuthMiddleware().(gin.HandlerFunc))
g.GET("/hello", func(c *gin.Context) {
user, err := authSvc.GetCurrentUser(c.Request.Context())
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Hello " + user.Username})
})
return nil
return nil
}
```
```
### 3. 在应用装配入口注册插件 (`cmd/app.go`)
在 `cmd/app.go` 中的 `newWaveletApp` 函数内注册你的新插件:
```go
app.Use(
database.New(),
cache.New(),
logger.New(),
storage.New(),
// ... 官方平台插件 ...
my_plugin.New(), // 注册下游定制插件
driver_http.New(),
driver_asynq_worker.New(),
driver_asynq_cron.New(),
)
```
---
## 严格红线与规范 (Guardrails)
- **严禁跨插件直接 import**:下游插件可依赖 `core/`、`core/contracts/`、`pkg/`、`plugins/infra/`,**严禁直接 import `plugins/domain/*` 内部私有实现**,一律通过 `contracts` 接口或事件总线调用。
- **禁止 GORM AutoMigrate**:数据表结构定义必须通过 `migrations/` 下嵌入的 Goose SQL 管理。
- **Goroutine 并发安全**:严禁裸 `go func()`,后台并发任务统一使用 `backend/pkg/util.Go`。
@@ -0,0 +1,5 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package consts defines constants and error codes for custom_example plugin.
package consts
@@ -0,0 +1,5 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package hello provides HTTP API handlers for the custom_example plugin.
package hello
+60 -57
View File
@@ -1,14 +1,14 @@
module Wavelet
go 1.25.7
go 1.25.10
require (
github.com/ClickHouse/clickhouse-go/v2 v2.48.0
github.com/alicebob/miniredis/v2 v2.38.0
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3
github.com/aws/aws-sdk-go-v2 v1.43.4
github.com/aws/aws-sdk-go-v2/config v1.32.35
github.com/aws/aws-sdk-go-v2/credentials v1.19.34
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5
github.com/bodgit/sevenzip v1.6.5
github.com/bwmarrin/snowflake v0.3.0
@@ -20,13 +20,13 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/go-acme/lego/v4 v4.35.2
github.com/go-jose/go-jose/v4 v4.1.4
github.com/google/go-cmp v0.7.0
github.com/go-redis/redis_rate/v10 v10.0.1
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.3
github.com/hibiken/asynq v0.26.0
github.com/maypok86/otter/v2 v2.3.0
github.com/nikoksr/notify v1.6.0
github.com/oschwald/maxminddb-golang v1.13.1
github.com/peterbourgon/diskv/v3 v3.0.1
github.com/pressly/goose/v3 v3.27.3
@@ -36,7 +36,7 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
github.com/studio-b12/gowebdav v0.13.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
@@ -44,6 +44,7 @@ require (
github.com/tencent-connect/botgo v0.2.1
github.com/ulikunitz/xz v0.5.16
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2
github.com/wneessen/go-mail v0.8.1
github.com/yuin/gopher-lua v1.1.2
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.70.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0
@@ -52,20 +53,19 @@ require (
go.opentelemetry.io/otel/sdk v1.45.0
go.opentelemetry.io/otel/trace v1.45.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.54.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.44.0
golang.org/x/mod v0.38.0
golang.org/x/net v0.57.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/telebot.v4 v4.0.0-beta.10
gorm.io/driver/clickhouse v0.7.0
gorm.io/driver/postgres v1.6.2
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
gorm.io/plugin/dbresolver v1.6.2
gorm.io/plugin/opentelemetry v0.1.16
gorm.io/plugin/opentelemetry v0.1.14
)
require (
@@ -74,62 +74,67 @@ require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect
github.com/aws/smithy-go v1.27.6 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/blinkbean/dingtalk v1.1.3 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/boj/redistore v1.4.2 // indirect
github.com/boj/redistore v1.4.1 // indirect
github.com/bwmarrin/discordgo v0.29.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.2 // indirect
github.com/bytedance/sonic/loader v0.5.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/glebarez/go-sqlite v1.23.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.8.0 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-lark/lark v1.16.0 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/spec v0.22.9 // indirect
github.com/go-openapi/swag/conv v0.28.0 // indirect
github.com/go-openapi/swag/jsonutils v0.28.0 // indirect
github.com/go-openapi/swag/loading v0.28.0 // indirect
github.com/go-openapi/swag/pools v0.28.0 // indirect
github.com/go-openapi/swag/stringutils v0.28.0 // indirect
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/go-openapi/jsonpointer v0.22.1 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.1 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-sql-driver/mysql v1.10.0 // indirect
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gomodule/redigo v1.9.3 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/btree v1.0.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/gregdel/pushover v1.4.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -140,11 +145,10 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/klauspost/compress v1.19.2 // indirect
github.com/klauspost/compress v1.19.1 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/leodido/go-urn v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -152,8 +156,7 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/paulmach/orb v0.13.0 // indirect
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
github.com/pierrec/lz4/v4 v4.1.28 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.61.0 // indirect
github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 // indirect
@@ -161,21 +164,24 @@ require (
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.4.0 // indirect
github.com/slack-go/slack v0.29.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stangelandcl/ppmd v0.1.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.9.3 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/tidwall/gjson v1.19.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.2 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect
go.mongodb.org/mongo-driver/v2 v2.8.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/log v0.6.0 // indirect
go.opentelemetry.io/otel/log v0.12.2 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
@@ -184,19 +190,16 @@ require (
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/arch v0.29.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // indirect
gorm.io/driver/mysql v1.6.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/libc v1.74.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.56.0 // indirect
modernc.org/sqlite v1.54.0 // indirect
)
exclude github.com/gomodule/redigo v2.0.0+incompatible
+125 -104
View File
@@ -84,58 +84,62 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16/go.mod h1:nG/LOlmox9BDe9HvQnXWzgcK8uKbgBMZ/Hp5pVt/21I=
github.com/aws/aws-sdk-go-v2/config v1.32.35 h1:UEzXuET8E42lxBPijuACu/tEK7v5lFPlk0Q+GT5WD9E=
github.com/aws/aws-sdk-go-v2/config v1.32.35/go.mod h1:KaMtJpFa2JlL2BStjjHQVwQpzZEmw+ND/EgVrfFoo2g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.34 h1:y6GkSmcv5myd1ngrYbGmiLlwQqB6TQhOuN/tbSSuWDY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.34/go.mod h1:w3dTcnDVoQIewjo7JG45hduAToikiIFLC4FIO7fndvw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35 h1:+S7kbJoLDDQ5tE+lHrUBgMkzC8NLgsaioS2F3dVoFAE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.35/go.mod h1:Ak7xXviIARfFdNUJ9Etb0bdVDt/KAvKjMGJVLWXDzik=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3YM0rSmk4opFXKqg/k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15 h1:JJLBQxwY+AFwuPAi5ivGc1ChnTdUt4cXMv7e76m2c/Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.15/go.mod h1:lQknBIe78MVL0cQOQDlag8KGflMbMEVFx9mB6O8ENvk=
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28 h1:Q1TF1J9jVD+vFo0LzNnmNdQ9EAt52TS+MQlq9Ir+Yxo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.28/go.mod h1:4KqXXC/p1hrotmouDFbrRoWaLy962b9PMUReCG6+uWo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35 h1:BBEElKh4a+rKshvjrfpajTe9CbpZvrbb4Jkg2PB7RzA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.35/go.mod h1:zaZk983w//8beSruBVec/mr4CmDwgZitW/qzGhAAX0g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36 h1:EUIwBoN+q7UmhAejxgD27APiRjh1vwCFo53gSqdT0BM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.36/go.mod h1:6u00gmlTGR6W0b2k9NBrld7MnOEmf1Spqx0VVt6AqyE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5 h1:HpN6GgZ3T8pSvRp81ZsgumNjlvRsa+9M0ZL2o6W4uLY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.106.5/go.mod h1:5FTZoQxhmLEiCAtYVk6V+t0iS/B5yGZVLZ3Wq5FDJZI=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4 h1:cOJELVNrq5Q3Udry2GLuHUM7MhwpeaQRdYaoa6GI/yI=
github.com/aws/aws-sdk-go-v2/service/signin v1.5.4/go.mod h1:f4LxzKBtaTxD7xh3PiVg3CE1tchQemfmghaJr+NbK2c=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4 h1:AMW7a7S8iQaHjBYZdU3PCq4GKRPijTPRAc7e6XtEThY=
github.com/aws/aws-sdk-go-v2/service/sso v1.33.4/go.mod h1:QQNsFV1DVXoXcZt18FS8lI8rtUrlDyAuWZLQ5shunv4=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4 h1:AsbZcJAQPRmHDJG8K1N0pof/1zPWjVT8TFlTWuGLSvo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.38.4/go.mod h1:6imqztH0//t0mKbl6yWl7swSEl7F/w32oAmqB3vP1ag=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 h1:w/AryDYMjSUANSQ2uoZxJovUsMTwWJNTv3IMex30Y+4=
github.com/aws/aws-sdk-go-v2/service/sts v1.45.4/go.mod h1:WeBiAa67azG7Su9Vf+ChGDBLiAozJCXzdjXiPBUwtbc=
github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA=
github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/blinkbean/dingtalk v1.1.3 h1:MbidFZYom7DTFHD/YIs+eaI7kRy52kmWE/sy0xjo6E4=
github.com/blinkbean/dingtalk v1.1.3/go.mod h1:9BaLuGSBqY3vT5hstValh48DbsKO7vaHaJnG9pXwbto=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.5 h1:7H7BxgmeX0j6UX42lH+KXQ92WgMQJ49DoocFdfHbCng=
github.com/bodgit/sevenzip v1.6.5/go.mod h1:GhuB6Lq1xCpP1sps+horjZ8lgiKPJcy2zUX3prla9wc=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/boj/redistore v1.4.2 h1:44FVJnBTdzDV9VpaByCOaQs0ND8hzABD2xBHcAIbX9s=
github.com/boj/redistore v1.4.2/go.mod h1:jjh65GXAH+5lj29pPRnQHdRNOt/lmP0LOaK+fiG2Fu8=
github.com/boj/redistore v1.4.1 h1:lP9ZZWqKMq2RIqexlZX1w1ODSnegL+puxGIujkU5tIw=
github.com/boj/redistore v1.4.1/go.mod h1:c0Tvw6aMjslog4jHIAcNv6EtJM849YoOAhMY7JBbWpI=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
@@ -214,8 +218,8 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -227,16 +231,16 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.23.0 h1:FyhIq4jqmgphQAUlY79zPldYGwISEZikaDfhiGWkkaI=
github.com/glebarez/go-sqlite v1.23.0/go.mod h1:IIYrOH3L0rHY3jb4IXOHoWdklNajSGUN2eJcvK8WrnI=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.8.0 h1:9T9eJrM+72dFk7n4DfhuaDDe6cyuFCSW2oNUkN77Yqc=
github.com/go-faster/errors v0.8.0/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -245,6 +249,8 @@ github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-lark/lark v1.16.0 h1:U6BwkLM9wrZedSM7cIiMofganr8PCvJN+M75w2lf2Gg=
github.com/go-lark/lark v1.16.0/go.mod h1:6ltbSztPZRT6IaO9ZIQyVaY5pVp/KeMizDYtfZkU+vM=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@@ -253,33 +259,29 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w=
github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0=
github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw=
github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8=
github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k=
github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4=
github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY=
github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU=
github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k=
github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU=
github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU=
github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY=
github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY=
github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0=
github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo=
github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug=
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
@@ -293,6 +295,8 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-redis/redis_rate/v10 v10.0.1 h1:calPxi7tVlxojKunJwQ72kwfozdy25RjA0bCj1h0MUo=
github.com/go-redis/redis_rate/v10 v10.0.1/go.mod h1:EMiuO9+cjRkR7UvdvwMO7vbgqJkltQHtwbdIQvaBKIU=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.2 h1:FQW5oHYcIlkCNrMD2lloGScxcHJ0gkjshV3qcQAyHQk=
github.com/go-resty/resty/v2 v2.17.2/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA=
@@ -300,6 +304,10 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU=
github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
@@ -347,9 +355,8 @@ github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW
github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8=
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -389,8 +396,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -413,10 +420,12 @@ github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2e
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gregdel/pushover v1.4.0 h1:P77WAJ2zPG+b0mEsmMjWGrPMuvhkh9k3v7OviwsoveE=
github.com/gregdel/pushover v1.4.0/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -468,6 +477,11 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -482,8 +496,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
@@ -548,6 +562,8 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nikoksr/notify v1.6.0 h1:H9pyvNyo4/47vn7uPTBbb+ld6Y/KmpXo9Ghf8cGqAlU=
github.com/nikoksr/notify v1.6.0/go.mod h1:GBrx8S2GI0ZtXdobxNmXJjPJN4P6qXbjPW78zlLSg6s=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -568,16 +584,14 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU=
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
github.com/pierrec/lz4/v4 v4.1.28 h1:pPEPwRJ4kybBTfGt28q7lQsRJQHhC08axprdLD5Ppio=
github.com/pierrec/lz4/v4 v4.1.28/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk=
github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA=
@@ -637,6 +651,8 @@ github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+D
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/slack-go/slack v0.29.0 h1:ohhMNgp9DmPKiLhH/pNZV4NxhOXKgNy0SH8FzVHNerI=
github.com/slack-go/slack v0.29.0/go.mod h1:UEe+jmo9WLlwHB04qsOrTDvqM7Aa4rQL3O5wF3n0hx4=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -675,8 +691,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/studio-b12/gowebdav v0.13.0 h1:OcwSg6IQHOFNdYHn3bPOHwSE8looG8N56Y5xTT1asqQ=
github.com/studio-b12/gowebdav v0.13.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
@@ -688,25 +704,32 @@ github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk=
github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI=
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.16 h1:ld6NyySjx5lowVKwJvMRLnW5nxKX/xnpSiFYZ/Lxur0=
github.com/ulikunitz/xz v0.5.16/go.mod h1:H9Rt/W6/Qj27PGauhQc6nfCDy7vHpzsOThBSaYDoEhw=
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 h1:3/aHKUq7qaFMWxyQV0W2ryNgg8x8rVeKVA20KJUkfS0=
github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2/go.mod h1:Zit4b8AQXaXvA68+nzmbyDzqiyFRISyw1JiD5JqUBjw=
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2 h1:cj/Z6FKTTYBnstI0Lni9PA+k2foounKIPUmj1LBwNiQ=
github.com/uptrace/opentelemetry-go-extra/otelzap v0.3.2/go.mod h1:LDaXk90gKEC2nC7JH3Lpnhfu+2V7o/TsqomJJmqA39o=
github.com/wneessen/go-mail v0.8.1 h1:tVcncj02/QySVFw3zr/kXOzZcuFQqBNT6K+Rbgm/pcM=
github.com/wneessen/go-mail v0.8.1/go.mod h1:dWZ61zadzCIyvB4y1/YzC5O7MrbbzBfPkARmbosdf8w=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -748,8 +771,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0 h1:fG5MC
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.45.0/go.mod h1:BmAYTn+3ysbRe+IU2msxmf5Rx3g6DHvex+tWI3LdhYI=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo=
go.opentelemetry.io/otel/log v0.6.0 h1:nH66tr+dmEgW5y+F9LanGJUBYPrRgP4g2EkmPE3LeK8=
go.opentelemetry.io/otel/log v0.6.0/go.mod h1:KdySypjQHhP069JX0z/t26VHwa8vSwzgaKmXtIB3fJM=
go.opentelemetry.io/otel/log v0.12.2 h1:yob9JVHn2ZY24byZeaXpTVoPS6l+UrrxmxmPKohXTwc=
go.opentelemetry.io/otel/log v0.12.2/go.mod h1:ShIItIxSYxufUMt+1H5a2wbckGli3/iCfuEbVZi/98E=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
@@ -793,8 +816,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -891,8 +914,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1038,8 +1061,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1239,8 +1262,8 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP
google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d h1:FarXi840EJWSHYTN3ERkADbPWjl307+FGrA22KAVjjc=
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d/go.mod h1:K/+WGbmBY7aNW1HDw1fJnKYo10i0DkAX6pows00dLig=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d h1:IL4hdHzcUv2l/gcg98/Rj3FbtE6axwqslOW8SW0C+S0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1271,8 +1294,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
@@ -1288,13 +1311,12 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
@@ -1314,7 +1336,6 @@ gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
@@ -1328,8 +1349,8 @@ gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
gorm.io/plugin/opentelemetry v0.1.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY=
gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1349,8 +1370,8 @@ modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/libc v1.74.3 h1:a4J+Z8aVaxPyjyxRAdJzw246PqpcFGvVPnfT/AuM5Ws=
modernc.org/libc v1.74.3/go.mod h1:4H7h/MJ8wnjL8RAbp9v3OXgnk22X7MouHIhDbvP3gj4=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -1359,8 +1380,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -15,6 +15,7 @@ import (
"Wavelet/openflare/plugins/flared/sync"
"Wavelet/openflare/plugins/flared/wsclient"
edgerunner "Wavelet/openflare/share/edge/runner"
"Wavelet/pkg/util"
)
// Runner is the top-level orchestrator for the flared agent. It wires together
@@ -31,8 +32,8 @@ type Runner struct {
// Run starts all background services and enters the WebSocket reconnect loop.
// It blocks until ctx is cancelled or an unrecoverable error occurs.
func (r *Runner) Run(ctx context.Context) error {
go r.HeartbeatService.Run(ctx)
go r.SyncService.Run(ctx)
util.Go(func() { r.HeartbeatService.Run(ctx) })
util.Go(func() { r.SyncService.Run(ctx) })
return edgerunner.RunWSReconnectLoop(ctx, edgerunner.WSReconnectConfig{
ComponentName: "flared",
@@ -21,6 +21,7 @@ import (
"time"
service "Wavelet/openflare/share/protocol"
"Wavelet/pkg/util"
)
const (
@@ -143,7 +144,7 @@ func (m *Manager) UpdateConfig(ctx context.Context, cfg *service.RelayConfig) {
m.lastError = err.Error()
return
}
go m.supervise(ctx, generation)
util.Go(func() { m.supervise(ctx, generation) })
}
return
}
@@ -167,7 +168,7 @@ func (m *Manager) UpdateConfig(ctx context.Context, cfg *service.RelayConfig) {
return
}
go m.supervise(ctx, generation)
util.Go(func() { m.supervise(ctx, generation) })
}
func (m *Manager) renderConfig(cfg *service.RelayConfig) error {
@@ -17,6 +17,7 @@ import (
"Wavelet/openflare/plugins/relay/wsclient"
edgerunner "Wavelet/openflare/share/edge/runner"
service "Wavelet/openflare/share/protocol"
"Wavelet/pkg/util"
)
// Runner manages the relay process.
@@ -31,7 +32,7 @@ type Runner struct {
// Run starts the relay process by initiating the heartbeat and WS reconnection loop.
func (r *Runner) Run(ctx context.Context) error {
go r.HeartbeatService.Run(ctx)
util.Go(func() { r.HeartbeatService.Run(ctx) })
return edgerunner.RunWSReconnectLoop(ctx, edgerunner.WSReconnectConfig{
ComponentName: "relay",
@@ -12,6 +12,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"Wavelet/pkg/util"
)
const (
@@ -64,7 +66,7 @@ func ServeAgent(c *gin.Context, nodeID string, onStatus AgentStatusHandler) {
slog.Debug("agent ws connected", "node_id", nodeID, "remote", client.remoteAddr)
go client.writePump()
util.Go(client.writePump)
client.readPump()
}
@@ -8,6 +8,8 @@ import (
"sync"
"github.com/gin-gonic/gin"
"Wavelet/pkg/util"
)
const (
@@ -51,7 +53,7 @@ func ServeFlared(c *gin.Context, nodeID string) {
slog.Debug("flared ws connected", "node_id", nodeID, "remote", c.Request.RemoteAddr)
go client.writePump()
util.Go(client.writePump)
client.readPump()
}
@@ -8,6 +8,8 @@ import (
"sync"
"github.com/gin-gonic/gin"
"Wavelet/pkg/util"
)
// RelayWSConnectedLastSeenValue is the sentinel last_seen_at value when relay WS is connected.
@@ -45,7 +47,7 @@ func ServeRelay(c *gin.Context, nodeID string) {
slog.Debug("relay ws connected", "node_id", nodeID, "remote", c.Request.RemoteAddr)
go client.writePump()
util.Go(client.writePump)
client.readPump()
}
@@ -16,6 +16,8 @@ import (
"strings"
"sync"
"time"
"Wavelet/pkg/util"
)
const emitAckTimeout = 10 * time.Second
@@ -137,7 +139,7 @@ func (c *SocketIOClient) Connect() error {
_ = respConnect.Body.Close()
slog.Debug("Namespace connected successfully to Uptime Kuma", "sid", c.sid)
go c.pollLoop()
util.Go(c.pollLoop)
return nil
}
@@ -12,10 +12,10 @@ import (
"mime/multipart"
"strings"
"Wavelet/openflare/plugins/server/kernel/repository"
"Wavelet/openflare/plugins/server/kernel/model"
"Wavelet/openflare/plugins/server/kernel/repository"
"Wavelet/openflare/plugins/server/kernel/task"
"Wavelet/pkg/util"
)
// CertificateInput TLS 证书创建/更新请求。
@@ -202,10 +202,10 @@ func ApplyCertificate(ctx context.Context, input ApplyInput) (*model.TLSCertific
returned := sanitizeCertificateForResponse(cert)
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
go func(c *model.TLSCertificate) {
util.Go(func() {
asyncCtx := context.WithoutCancel(ctx)
_ = obtainFn(asyncCtx, c)
}(cert)
_ = obtainFn(asyncCtx, cert)
})
return returned, nil
}
@@ -233,10 +233,10 @@ func UpdateACMECertificate(ctx context.Context, id uint, input ApplyInput) (*mod
returned := sanitizeCertificateForResponse(cert)
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
go func(c *model.TLSCertificate) {
util.Go(func() {
asyncCtx := context.WithoutCancel(ctx)
_ = obtainFn(asyncCtx, c)
}(cert)
_ = obtainFn(asyncCtx, cert)
})
return returned, nil
}
@@ -266,12 +266,12 @@ func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*
}
obtainFn := obtainTLSCertificate // 捕获当前实现,避免 goroutine 内读可变包变量(测试热替换)
go func(c *model.TLSCertificate) {
util.Go(func() {
asyncCtx := context.WithoutCancel(ctx)
if err := obtainFn(asyncCtx, c); err != nil {
if err := obtainFn(asyncCtx, cert); err != nil {
return
}
latest, err := repository.GetTLSCertificateByID(asyncCtx, c.ID)
latest, err := repository.GetTLSCertificateByID(asyncCtx, cert.ID)
if err != nil {
return
}
@@ -279,7 +279,7 @@ func ConvertCertificateToACME(ctx context.Context, id uint, input ApplyInput) (*
latest.ApplyStatus = tlsApplyStatusReady
latest.ApplyMessage = ""
_ = repository.SaveTLSCertificate(asyncCtx, latest)
}(cert)
})
return sanitizeCertificateForResponse(cert), nil
}
@@ -22,7 +22,7 @@ func New(_ *core.Context) *Provider {
}
// PublicConfig returns visibility=1 keys as map[string]string.
func (p *Provider) PublicConfig(ctx context.Context) (any, error) {
func (p *Provider) PublicConfig(ctx context.Context) (map[string]string, error) {
configs, err := repository.ListVisibleSystemConfigs(ctx)
if err != nil {
return nil, err
@@ -23,11 +23,7 @@ func TestPublicConfigSeesSaveOrUpdateThroughAdminCache(t *testing.T) {
if err != nil {
t.Fatalf("PublicConfig() warm error = %v", err)
}
firstMap, ok := first.(map[string]string)
if !ok {
t.Fatalf("PublicConfig() = %T, want map[string]string", first)
}
if got := firstMap[model.ConfigKeySiteName]; got != "OpenFlare" {
if got := first[model.ConfigKeySiteName]; got != "OpenFlare" {
t.Fatalf("PublicConfig()[%q] = %q, want %q", model.ConfigKeySiteName, got, "OpenFlare")
}
@@ -47,14 +43,10 @@ func TestPublicConfigSeesSaveOrUpdateThroughAdminCache(t *testing.T) {
if err != nil {
t.Fatalf("PublicConfig() after save error = %v", err)
}
secondMap, ok := second.(map[string]string)
if !ok {
t.Fatalf("PublicConfig() after save = %T, want map[string]string", second)
}
if got := secondMap[model.ConfigKeySiteName]; got == "OpenFlare" {
if got := second[model.ConfigKeySiteName]; got == "OpenFlare" {
t.Fatalf("PublicConfig() after save [%q] stayed stale at %q", model.ConfigKeySiteName, got)
}
if got := secondMap[model.ConfigKeySiteName]; got != "Updated" {
if got := second[model.ConfigKeySiteName]; got != "Updated" {
t.Fatalf("PublicConfig() after save [%q] = %q, want %q", model.ConfigKeySiteName, got, "Updated")
}
}
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package limiter provides in-memory rate limiting utilities without external project dependencies.
package limiter
import (
"context"
"sync"
"time"
)
// Rate specifies a rate limit of Limit events permitted within a Period.
type Rate struct {
Limit int
Period time.Duration
}
// Result holds the outcome of an Allow check.
type Result struct {
Allowed bool
Remaining int
ResetAfter time.Duration
RetryAfter time.Duration
}
type memoryEntry struct {
timestamps []time.Time
lastSeen time.Time
}
func (e *memoryEntry) prune(cutoff time.Time) {
validIdx := len(e.timestamps)
for i, ts := range e.timestamps {
if ts.After(cutoff) {
validIdx = i
break
}
}
if validIdx > 0 && validIdx <= len(e.timestamps) {
e.timestamps = e.timestamps[validIdx:]
}
}
func (e *memoryEntry) calcBlockedResult(limit int, period time.Duration, now time.Time) *Result {
currentCount := len(e.timestamps)
if currentCount == 0 {
return &Result{
Allowed: false,
Remaining: limit,
ResetAfter: period,
RetryAfter: 0,
}
}
oldest := e.timestamps[0]
retryAfter := max(0, oldest.Add(period).Sub(now))
newest := e.timestamps[currentCount-1]
resetAfter := max(0, newest.Add(period).Sub(now))
return &Result{
Allowed: false,
Remaining: limit - currentCount,
ResetAfter: resetAfter,
RetryAfter: retryAfter,
}
}
// MemoryLimiter implements an in-memory sliding window rate limiter.
type MemoryLimiter struct {
mu sync.Mutex
entries map[string]*memoryEntry
}
// NewMemoryLimiter creates a new in-memory rate limiter.
func NewMemoryLimiter() *MemoryLimiter {
return &MemoryLimiter{
entries: make(map[string]*memoryEntry),
}
}
// Allow checks whether 1 event for key is permitted under rate.
func (m *MemoryLimiter) Allow(ctx context.Context, key string, rate Rate) (*Result, error) {
return m.AllowN(ctx, key, rate, 1)
}
// AllowN checks whether n events for key are permitted under rate.
func (m *MemoryLimiter) AllowN(_ context.Context, key string, rate Rate, n int) (*Result, error) {
if rate.Limit <= 0 || rate.Period <= 0 || n <= 0 {
return &Result{Allowed: true}, nil
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rate.Period)
entry, ok := m.entries[key]
if !ok {
entry = &memoryEntry{}
m.entries[key] = entry
}
entry.lastSeen = now
entry.prune(cutoff)
if len(entry.timestamps)+n > rate.Limit {
return entry.calcBlockedResult(rate.Limit, rate.Period, now), nil
}
for i := 0; i < n; i++ {
entry.timestamps = append(entry.timestamps, now)
}
remaining := max(0, rate.Limit-len(entry.timestamps))
return &Result{
Allowed: true,
Remaining: remaining,
ResetAfter: rate.Period,
RetryAfter: 0,
}, nil
}
// Reset clears rate limit state for key.
func (m *MemoryLimiter) Reset(_ context.Context, key string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.entries, key)
return nil
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package limiter_test
import (
"Wavelet/pkg/limiter"
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMemoryLimiter_Basic(t *testing.T) {
ctx := context.Background()
lim := limiter.NewMemoryLimiter()
rate := limiter.Rate{
Limit: 3,
Period: 100 * time.Millisecond,
}
// 1st request
res, err := lim.Allow(ctx, "test_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
assert.Equal(t, 2, res.Remaining)
// 2nd request
res, err = lim.Allow(ctx, "test_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
assert.Equal(t, 1, res.Remaining)
// 3rd request
res, err = lim.Allow(ctx, "test_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
assert.Equal(t, 0, res.Remaining)
// 4th request - should be blocked
res, err = lim.Allow(ctx, "test_key", rate)
require.NoError(t, err)
assert.False(t, res.Allowed)
assert.Equal(t, 0, res.Remaining)
assert.Greater(t, res.RetryAfter, time.Duration(0))
// Reset
err = lim.Reset(ctx, "test_key")
require.NoError(t, err)
// Immediately allowed after reset
res, err = lim.Allow(ctx, "test_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
assert.Equal(t, 2, res.Remaining)
}
func TestMemoryLimiter_WindowSlide(t *testing.T) {
ctx := context.Background()
lim := limiter.NewMemoryLimiter()
rate := limiter.Rate{
Limit: 2,
Period: 50 * time.Millisecond,
}
res, err := lim.Allow(ctx, "slide_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
res, err = lim.Allow(ctx, "slide_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
res, err = lim.Allow(ctx, "slide_key", rate)
require.NoError(t, err)
assert.False(t, res.Allowed)
// Wait for window to slide
time.Sleep(60 * time.Millisecond)
res, err = lim.Allow(ctx, "slide_key", rate)
require.NoError(t, err)
assert.True(t, res.Allowed)
}
func TestMemoryLimiter_Concurrency(t *testing.T) {
ctx := context.Background()
lim := limiter.NewMemoryLimiter()
rate := limiter.Rate{
Limit: 100,
Period: time.Second,
}
var wg sync.WaitGroup
allowedCount := int32(0)
var mu sync.Mutex
for i := 0; i < 200; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, err := lim.Allow(ctx, "concurrent_key", rate)
if err == nil && res.Allowed {
mu.Lock()
allowedCount++
mu.Unlock()
}
}()
}
wg.Wait()
assert.Equal(t, int32(100), allowedCount)
}
+3 -8
View File
@@ -5,12 +5,7 @@
package mail
const (
errDialTLSFailed = "dial tls failed: %w"
errSMTPClientCreationFailed = "smtp client creation failed: %w"
errSMTPAuthFailed = "smtp auth failed: %w"
errSMTPMailCommandFailed = "smtp mail command failed: %w"
errSMTPRcptCommandFailed = "smtp rcpt command failed: %w"
errSMTPDataCommandFailed = "smtp data command failed: %w"
errSMTPWritingBodyFailed = "smtp writing body failed: %w" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errSendMailFailed = "send mail failed: %w"
errCreateMailMessageFailed = "create mail message failed: %w"
errCreateMailClientFailed = "create mail client failed: %w"
errSendMailFailed = "send mail failed: %w"
)
+117 -205
View File
@@ -8,33 +8,39 @@ import (
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"time"
gomail "github.com/wneessen/go-mail"
golog "github.com/wneessen/go-mail/log"
)
const (
smtpSSLPort = 465 // SMTP SSL 端口
smtpDialTimeout = 5 * time.Second // SMTP 连接超时
smtpSessionDeadline = 10 * time.Second // SMTP 会话截止时间
smtpSSLPort = 465 // SMTP SSL 端口
smtpDialTimeout = 5 * time.Second // SMTP 连接超时
)
// Config represents SMTP mail configuration
type Config struct {
Host string
Port int
Username string
Password string
Host string
Port int
Username string
Password string
FromName string // 可选发件人显示名称
InsecureSkipVerify bool // 是否跳过证书校验 (默认跳过以兼容自签名证书)
}
// sanitizeHeaderValue removes CR/LF bytes so untrusted values cannot inject
// additional email headers (email header injection).
func sanitizeHeaderValue(v string) string {
v = strings.ReplaceAll(v, "\r", "")
v = strings.ReplaceAll(v, "\n", "")
return v
// Option modifies internal mail send options
type Option func(*clientOptions)
type clientOptions struct {
debugLogger golog.Logger
}
func withLogger(l golog.Logger) Option {
return func(co *clientOptions) {
co.debugLogger = l
}
}
// SendMail sends an HTML email using the provided config and message details
@@ -44,206 +50,112 @@ func SendMail(ctx context.Context, cfg Config, to, subject, body string) error {
// SendMailHTML sends an HTML format email
func SendMailHTML(ctx context.Context, cfg Config, to, subject, body string) error {
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
return send(ctx, cfg, to, subject, body)
}
// Header & MIME settings for HTML email
header := make(map[string]string)
header["From"] = sanitizeHeaderValue(cfg.Username)
header["To"] = sanitizeHeaderValue(to)
header["Subject"] = sanitizeHeaderValue(subject)
header["MIME-Version"] = "1.0"
header["Content-Type"] = "text/html; charset=UTF-8"
// SendMailWithLog sends a test email and records a detailed SMTP connection log
func SendMailWithLog(ctx context.Context, cfg Config, to, subject, body string) (string, error) {
var logBuf bytes.Buffer
logger := &bufferLogger{buf: &logBuf}
message := ""
for k, v := range header {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + body
fmt.Fprintf(&logBuf, "[System] Connecting to %s:%d...\n", cfg.Host, cfg.Port)
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
// If using SSL port 465, we connection via TLS dial
if cfg.Port == smtpSSLPort {
return sendMailViaSSL(ctx, addr, auth, cfg, to, message)
}
// For standard port (587 / 25), use smtp.SendMail directly (handles STARTTLS automatically if server supports it)
err := smtp.SendMail(addr, auth, cfg.Username, []string{to}, []byte(message))
err := send(ctx, cfg, to, subject, body, withLogger(logger))
if err != nil {
fmt.Fprintf(&logBuf, "[Error] Mail sending failed: %v\n", err)
return logBuf.String(), err
}
fmt.Fprintf(&logBuf, "[System] Mail sent successfully!\n")
return logBuf.String(), nil
}
func send(ctx context.Context, cfg Config, to, subject, body string, opts ...Option) error {
var co clientOptions
for _, opt := range opts {
opt(&co)
}
msg := gomail.NewMsg()
var err error
if cfg.FromName != "" {
err = msg.FromFormat(cfg.FromName, cfg.Username)
} else {
err = msg.From(cfg.Username)
}
if err != nil {
return fmt.Errorf(errCreateMailMessageFailed, err)
}
if err = msg.To(to); err != nil {
return fmt.Errorf(errCreateMailMessageFailed, err)
}
msg.Subject(subject)
msg.SetBodyString(gomail.TypeTextHTML, body)
clientOpts := []gomail.Option{
gomail.WithPort(cfg.Port),
gomail.WithTimeout(smtpDialTimeout),
}
tlsConfig := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host,
}
clientOpts = append(clientOpts, gomail.WithTLSConfig(tlsConfig))
if cfg.Port == smtpSSLPort {
clientOpts = append(clientOpts, gomail.WithSSL())
} else {
clientOpts = append(clientOpts, gomail.WithTLSPolicy(gomail.TLSOpportunistic))
}
if cfg.Username != "" && cfg.Password != "" {
clientOpts = append(clientOpts,
gomail.WithSMTPAuth(gomail.SMTPAuthPlain),
gomail.WithUsername(cfg.Username),
gomail.WithPassword(cfg.Password),
)
}
if co.debugLogger != nil {
clientOpts = append(clientOpts, gomail.WithDebugLog(), gomail.WithLogger(co.debugLogger))
}
client, err := gomail.NewClient(cfg.Host, clientOpts...)
if err != nil {
return fmt.Errorf(errCreateMailClientFailed, err)
}
defer func() { _ = client.Close() }()
if err = client.DialAndSendWithContext(ctx, msg); err != nil {
return fmt.Errorf(errSendMailFailed, err)
}
return nil
}
// sendMailViaSSL 通过 TLS 直接连接 SMTP SSL 端口发送邮件
func sendMailViaSSL(ctx context.Context, addr string, auth smtp.Auth, cfg Config, to, message string) error {
tlsConfig := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host,
}
dialer := &net.Dialer{Timeout: smtpDialTimeout}
tlsDialer := &tls.Dialer{
NetDialer: dialer,
Config: tlsConfig,
}
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
if err != nil {
return fmt.Errorf(errDialTLSFailed, err)
}
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(smtpSessionDeadline))
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf(errSMTPClientCreationFailed, err)
}
defer func() { _ = client.Close() }()
if err = client.Auth(auth); err != nil {
return fmt.Errorf(errSMTPAuthFailed, err)
}
if err = client.Mail(cfg.Username); err != nil {
return fmt.Errorf(errSMTPMailCommandFailed, err)
}
if err = client.Rcpt(to); err != nil {
return fmt.Errorf(errSMTPRcptCommandFailed, err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf(errSMTPDataCommandFailed, err)
}
defer func() { _ = w.Close() }()
_, err = w.Write([]byte(message))
if err != nil {
return fmt.Errorf(errSMTPWritingBodyFailed, err)
}
return nil
type bufferLogger struct {
buf *bytes.Buffer
}
// SendMailWithLog sends a test email and records a detailed SMTP connection log
func SendMailWithLog(ctx context.Context, cfg Config, to, subject, body string) (string, error) {
var logBuf bytes.Buffer
logLine := func(dir, format string, args ...interface{}) {
fmt.Fprintf(&logBuf, "[%s] %s\n", dir, fmt.Sprintf(format, args...))
func (l *bufferLogger) log(level string, entry golog.Log) {
msg := fmt.Sprintf(entry.Format, entry.Messages...)
msg = strings.TrimRight(msg, "\r\n")
var dir string
switch entry.Direction {
case golog.DirClientToServer:
dir = "C"
case golog.DirServerToClient:
dir = "S"
default:
dir = level
}
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
logLine("System", "Connecting to %s...", addr)
var conn net.Conn
var err error
dialer := &net.Dialer{Timeout: smtpDialTimeout}
if cfg.Port == smtpSSLPort {
tlsConfig := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host,
}
tlsDialer := &tls.Dialer{
NetDialer: dialer,
Config: tlsConfig,
}
conn, err = tlsDialer.DialContext(ctx, "tcp", addr)
} else {
conn, err = dialer.DialContext(ctx, "tcp", addr)
}
if err != nil {
logLine("Error", "Connection failed: %v", err)
return logBuf.String(), err
}
defer func() { _ = conn.Close() }()
logLine("System", "Connected successfully.")
// Set a 10-second session deadline for read/write operations
_ = conn.SetDeadline(time.Now().Add(smtpSessionDeadline))
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
logLine("Error", "SMTP client handshake failed: %v", err)
return logBuf.String(), err
}
defer func() { _ = client.Close() }()
// If not 465, support STARTTLS if available
if cfg.Port != smtpSSLPort {
if ok, _ := client.Extension("STARTTLS"); ok {
logLine("C", "STARTTLS")
tlsConfig := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec // SMTP servers might use self-signed certificates
ServerName: cfg.Host,
}
if err = client.StartTLS(tlsConfig); err != nil {
logLine("Error", "STARTTLS failed: %v", err)
return logBuf.String(), err
}
logLine("S", "220 Ready to start TLS")
}
}
// Authentication
if cfg.Username != "" && cfg.Password != "" {
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
logLine("C", "AUTH PLAIN **********")
if err = client.Auth(auth); err != nil {
logLine("Error", "Authentication failed: %v", err)
return logBuf.String(), err
}
logLine("S", "235 Authentication successful")
}
// Mail command
logLine("C", "MAIL FROM:<%s>", cfg.Username)
if err = client.Mail(cfg.Username); err != nil {
logLine("Error", "MAIL FROM command failed: %v", err)
return logBuf.String(), err
}
logLine("S", "250 OK")
// Rcpt command
logLine("C", "RCPT TO:<%s>", to)
if err = client.Rcpt(to); err != nil {
logLine("Error", "RCPT TO command failed: %v", err)
return logBuf.String(), err
}
logLine("S", "250 OK")
// Data command
logLine("C", "DATA")
w, err := client.Data()
if err != nil {
logLine("Error", "DATA command failed: %v", err)
return logBuf.String(), err
}
logLine("S", "354 Start mail input")
// Header & MIME settings for HTML email
header := make(map[string]string)
header["From"] = sanitizeHeaderValue(cfg.Username)
header["To"] = sanitizeHeaderValue(to)
header["Subject"] = sanitizeHeaderValue(subject)
header["MIME-Version"] = "1.0"
header["Content-Type"] = "text/html; charset=UTF-8"
message := ""
for k, v := range header {
message += fmt.Sprintf("%s: %s\r\n", k, v)
}
message += "\r\n" + body
logLine("System", "Sending message body...")
if _, err = w.Write([]byte(message)); err != nil {
_ = w.Close()
logLine("Error", "Writing message body failed: %v", err)
return logBuf.String(), err
}
_ = w.Close()
logLine("S", "250 OK")
logLine("C", "QUIT")
_ = client.Quit()
logLine("System", "Mail sent successfully!")
return logBuf.String(), nil
fmt.Fprintf(l.buf, "[%s] %s\n", dir, msg)
}
func (l *bufferLogger) Debugf(e golog.Log) { l.log("Debug", e) }
func (l *bufferLogger) Infof(e golog.Log) { l.log("Info", e) }
func (l *bufferLogger) Warnf(e golog.Log) { l.log("Warn", e) }
func (l *bufferLogger) Errorf(e golog.Log) { l.log("Error", e) }
+101 -69
View File
@@ -8,74 +8,105 @@ import (
"context"
"net"
"net/textproto"
"strings"
"testing"
)
func TestSendMailMock(t *testing.T) {
// Start a mock SMTP server
func startMockSMTPServer(t *testing.T) (int, func()) {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to start mock smtp server: %v", err)
}
defer func() { _ = l.Close() }()
port := l.Addr().(*net.TCPAddr).Port
go func() {
conn, err := l.Accept()
for {
conn, err := l.Accept()
if err != nil {
return
}
go handleMockSMTPConn(conn)
}
}()
return port, func() { _ = l.Close() }
}
func handleMockSMTPConn(conn net.Conn) {
defer func() { _ = conn.Close() }()
writer := bufio.NewWriter(conn)
reader := bufio.NewReader(conn)
tp := textproto.NewReader(reader)
// 220 Ready
_, _ = writer.WriteString("220 mock.smtp.com SMTP Ready\r\n")
_ = writer.Flush()
for {
line, err := tp.ReadLine()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
writer := bufio.NewWriter(conn)
reader := bufio.NewReader(conn)
tp := textproto.NewReader(reader)
// 220 Ready
_, _ = writer.WriteString("220 mock.smtp.com SMTP Ready\r\n")
_ = writer.Flush()
// Read HELO/EHLO
_, _ = tp.ReadLine()
_, _ = writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n")
_ = writer.Flush()
// Read AUTH PLAIN
_, _ = tp.ReadLine()
_, _ = writer.WriteString("235 Authentication successful\r\n")
_ = writer.Flush()
// Read MAIL FROM
_, _ = tp.ReadLine()
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
// Read RCPT TO
_, _ = tp.ReadLine()
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
// Read DATA
_, _ = tp.ReadLine()
_, _ = writer.WriteString("354 Start mail input\r\n")
_ = writer.Flush()
// Read body lines until dot
for {
line, err := tp.ReadLine()
if err != nil || line == "." {
break
upper := strings.ToUpper(line)
switch {
case strings.HasPrefix(upper, "EHLO") || strings.HasPrefix(upper, "HELO"):
_, _ = writer.WriteString("250-mock.smtp.com\r\n250 AUTH PLAIN\r\n")
_ = writer.Flush()
case strings.HasPrefix(upper, "AUTH PLAIN"):
_, _ = writer.WriteString("235 2.7.0 Authentication successful\r\n")
_ = writer.Flush()
case strings.HasPrefix(upper, "MAIL FROM:"):
_, _ = writer.WriteString("250 2.1.0 Ok\r\n")
_ = writer.Flush()
case strings.HasPrefix(upper, "RCPT TO:"):
_, _ = writer.WriteString("250 2.1.5 Ok\r\n")
_ = writer.Flush()
case strings.HasPrefix(upper, "DATA"):
_, _ = writer.WriteString("354 Start mail input; end with <CRLF>.<CRLF>\r\n")
_ = writer.Flush()
for {
dataLine, err := tp.ReadLine()
if err != nil || dataLine == "." {
break
}
}
_, _ = writer.WriteString("250 2.0.0 Ok: queued\r\n")
_ = writer.Flush()
case strings.HasPrefix(upper, "QUIT"):
_, _ = writer.WriteString("221 2.0.0 Bye\r\n")
_ = writer.Flush()
return
default:
_, _ = writer.WriteString("250 Ok\r\n")
_ = writer.Flush()
}
_, _ = writer.WriteString("250 OK\r\n")
_ = writer.Flush()
}
}
// Read QUIT
_, _ = tp.ReadLine()
_, _ = writer.WriteString("221 Bye\r\n")
_ = writer.Flush()
}()
func TestSendMailMock(t *testing.T) {
port, cleanup := startMockSMTPServer(t)
defer cleanup()
cfg := Config{
Host: "127.0.0.1",
Port: port,
Username: "test@example.com",
Password: "password",
FromName: "Wavelet Notifier",
}
err := SendMail(context.Background(), cfg, "recipient@example.com", "Test Subject", "<h1>Test Body</h1>")
if err != nil {
t.Fatalf("failed to send mail: %v", err)
}
}
func TestSendMailWithLog(t *testing.T) {
port, cleanup := startMockSMTPServer(t)
defer cleanup()
cfg := Config{
Host: "127.0.0.1",
@@ -84,28 +115,29 @@ func TestSendMailMock(t *testing.T) {
Password: "password",
}
err = SendMail(context.Background(), cfg, "recipient@example.com", "Test Subject", "<h1>Test Body</h1>")
logs, err := SendMailWithLog(context.Background(), cfg, "recipient@example.com", "Test Subject", "<p>Test Log</p>")
if err != nil {
t.Errorf("failed to send mail: %v", err)
t.Fatalf("failed to send mail with log: %v, log output:\n%s", err, logs)
}
if !strings.Contains(logs, "[System] Connecting to") {
t.Errorf("expected connection log in output, got: %s", logs)
}
if !strings.Contains(logs, "[System] Mail sent successfully!") {
t.Errorf("expected success log in output, got: %s", logs)
}
}
func TestSanitizeHeaderValue(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"plain", "System Notification", "System Notification"},
{"crlf stripped", "alert\r\nBcc: attacker@example.com", "alertBcc: attacker@example.com"},
{"cr stripped", "a\rb", "ab"},
{"lf stripped", "a\nb", "ab"},
func TestSendMailInvalidAddress(t *testing.T) {
cfg := Config{
Host: "127.0.0.1",
Port: 25,
Username: "test@example.com",
Password: "password",
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeHeaderValue(tt.input); got != tt.want {
t.Errorf("sanitizeHeaderValue(%q) = %q, want %q", tt.input, got, tt.want)
}
})
err := SendMail(context.Background(), cfg, "invalid address with \n newline", "Subject", "Body")
if err == nil {
t.Errorf("expected error for invalid address, got nil")
}
}
+12 -1
View File
@@ -21,6 +21,12 @@ import (
// SystemConfig aliases model.SystemConfig for external compatibility.
type SystemConfig = model.SystemConfig
// TaskExecution aliases model.TaskExecution for external compatibility.
type TaskExecution = model.TaskExecution
// Schedule aliases model.Schedule for external compatibility.
type Schedule = model.Schedule
//go:embed migrations/*/*.sql
var adminMigrations embed.FS
@@ -132,12 +138,17 @@ func (p *Plugin) Apply(ctx *core.Context) error {
ctx.Router().RegisterWhitelist("/robots.txt")
// 2. Register Background Tasks
const defaultCleanupRetry = 3
ctx.Task().Register(service.LogDBSwitchTask, &service.LogDBSwitchHandler{}, extpoints.WithTaskMeta(service.LogDBSwitchMeta))
ctx.Task().Register(service.SystemCleanupTask, &service.SystemCleanupHandler{}, extpoints.WithTaskMeta(service.SystemCleanupMeta), extpoints.WithTaskRetry(defaultCleanupRetry))
// 2.1 Register Cron Schedule
ctx.Schedule().RegisterCron("0 3 * * *", service.SystemCleanupTask, nil)
// 3. Register Settings Schemas
ctx.Settings().Register(extpoints.SettingSchema{
Key: "admin.system_cleanup_cron",
Default: "0 4 * * *",
Default: "0 3 * * *",
Description: "Cron expression for nightly system logs and expired tokens cleanup",
Type: "string",
Category: "maintenance",
+3 -1
View File
@@ -36,11 +36,13 @@ func TestAdminPluginUnit(t *testing.T) {
// Verify tasks
_, ok := ctx.Tasks().Get("logs:db_switch")
require.True(t, ok)
_, ok = ctx.Tasks().Get("system:cleanup")
require.True(t, ok)
// Verify settings
setting, ok := ctx.Settings().Get("admin.system_cleanup_cron")
require.True(t, ok)
assert.Equal(t, "0 4 * * *", setting.Default)
assert.Equal(t, "0 3 * * *", setting.Default)
provider, err := core.Inject[contracts.PublicConfigProvider](ctx)
require.NoError(t, err)
@@ -0,0 +1,70 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/admin/model"
"context"
"errors"
"fmt"
"time"
)
const (
// SystemCleanupTask 系统定期垃圾清理任务标识
SystemCleanupTask = "system:cleanup"
// TaskTypeSystemCleanup 系统定期垃圾清理管理类型
TaskTypeSystemCleanup = "system_cleanup"
taskQueueDefault = "default"
)
// SystemCleanupMeta describes the system-wide cleanup task metadata.
var SystemCleanupMeta = contracts.TaskMetaDTO{
Type: TaskTypeSystemCleanup,
AsynqTask: SystemCleanupTask,
Name: "系统垃圾清理",
DisplayName: "系统垃圾清理",
Description: "定期清理过期任务执行记录,并通过领域事件广播触发各业务域自治清理(临时文件、历史推送等)",
Category: "maintenance",
SupportsTime: false,
MaxRetry: 3,
Queue: taskQueueDefault,
Retryable: true,
}
// SystemCleanupHandler handles the system-wide garbage cleanup task.
type SystemCleanupHandler struct{}
// Execute executes system cleanup: clears old task executions and emits EventTopicSystemCleanup.
func (h *SystemCleanupHandler) Execute(ctx context.Context, _ []byte) (*contracts.TaskResultDTO, error) {
db := GetDB(ctx)
if db == nil {
return nil, errors.New("database service not available")
}
// 1. 清理自身域(admin 域)的过期任务执行记录(7 天前)
var deletedExecutions int64
sevenDaysAgo := time.Now().Add(-7 * 24 * time.Hour)
res := db.Where("created_at < ?", sevenDaysAgo).Delete(&model.TaskExecution{})
if err := res.Error; err != nil {
logger.WarnF(ctx, "清理过期任务执行日志失败: %v", err)
} else {
deletedExecutions = res.RowsAffected
logger.InfoF(ctx, "已清理 7 天前任务执行日志,共 %d 条", deletedExecutions)
}
// 2. 广播 EventTopicSystemCleanup 领域事件,由各业务域插件(upload, msg_gateway, user 等)自治执行各自的清理逻辑
nowStr := time.Now().Format(time.RFC3339)
if err := EmitEvent(ctx, contracts.EventTopicSystemCleanup, contracts.SystemCleanupEvent{
TriggeredAt: nowStr,
}); err != nil {
logger.WarnF(ctx, "广播系统清理领域事件失败: %v", err)
}
msg := fmt.Sprintf("系统垃圾清理完成,已清理过期任务执行日志 %d 条,并已广播领域清理事件", deletedExecutions)
logger.InfoF(ctx, "%s", msg)
return &contracts.TaskResultDTO{Message: msg}, nil
}
@@ -0,0 +1,78 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package service_test
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/admin/model"
"Wavelet/plugins/domain/admin/service"
"context"
"sync/atomic"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestSystemCleanupHandler_Execute(t *testing.T) {
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.TaskExecution{}))
service.SetDBService(&testDBService{db: sqliteDB})
defer service.ResetServices()
now := time.Now()
oldTime := now.Add(-10 * 24 * time.Hour)
recentTime := now.Add(-1 * time.Hour)
// Seed old execution (should be cleaned)
oldExec := model.TaskExecution{
ID: 1,
TaskID: "task-old",
TaskType: "sample_task",
Status: "success",
CreatedAt: oldTime,
}
require.NoError(t, sqliteDB.Create(&oldExec).Error)
// Seed recent execution (should remain)
recentExec := model.TaskExecution{
ID: 2,
TaskID: "task-recent",
TaskType: "sample_task",
Status: "success",
CreatedAt: recentTime,
}
require.NoError(t, sqliteDB.Create(&recentExec).Error)
var eventFired atomic.Bool
service.SetEventEmitter(func(ctx context.Context, topic string, payload any) error {
if topic == contracts.EventTopicSystemCleanup {
eventFired.Store(true)
}
return nil
})
handler := &service.SystemCleanupHandler{}
res, err := handler.Execute(context.Background(), nil)
require.NoError(t, err)
require.NotNil(t, res)
assert.Contains(t, res.Message, "系统垃圾清理完成")
// Verify old execution is deleted and recent remains
var count int64
sqliteDB.Model(&model.TaskExecution{}).Count(&count)
assert.Equal(t, int64(1), count)
var remaining model.TaskExecution
sqliteDB.First(&remaining)
assert.Equal(t, uint64(2), remaining.ID)
assert.True(t, eventFired.Load(), "EventTopicSystemCleanup must be emitted")
}
@@ -1,217 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core/contracts"
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
func isOIDCLoginEnabled(ctx context.Context) bool {
val, err := GetSystemConfigValue(ctx, "oidc_login_enabled")
if err != nil || val == "" {
return true
}
b, err := strconv.ParseBool(val)
if err != nil {
return true
}
return b
}
func resolveAuthSource(ctx context.Context, sourceName string) (*AuthSource, error) {
name := strings.TrimSpace(strings.ToLower(sourceName))
if name == "" {
sources, err := GetActiveAuthSourcesCached(ctx)
if err != nil {
return nil, err
}
if len(sources) == 0 {
return nil, errors.New(errNoActiveAuthSource)
}
src, err := GetAuthSourceByNameCached(ctx, sources[0].Name)
if err != nil {
return nil, err
}
return src, nil
}
src, err := GetAuthSourceByNameCached(ctx, name)
if err != nil {
return nil, err
}
return src, nil
}
func activeLoginSources(ctx context.Context) []AuthSourceView {
if !isOIDCLoginEnabled(ctx) {
return nil
}
dbSources, err := GetActiveAuthSourcesCached(ctx)
if err != nil {
return nil
}
sources := make([]AuthSourceView, 0, len(dbSources))
for _, source := range dbSources {
sources = append(sources, AuthSourceView{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
IsActive: source.IsActive,
IconURL: source.IconURL,
ClientSecretConfigured: source.ClientSecretConfigured,
})
}
return sources
}
func getFrontendLoginRedirectURL(ctx context.Context) (string, error) {
val, err := GetSystemConfigValue(ctx, "server_address")
if err != nil || strings.TrimSpace(val) == "" {
return "", errors.New(errServerAddressMissing)
}
return strings.TrimRight(val, "/") + "/login", nil
}
func buildOAuthConfig(ctx context.Context, source *AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if source == nil {
return nil, nil, errors.New(errAuthSourceRequired)
}
if source.OpenIDDiscoveryURL == "" {
return nil, nil, errors.New(errDiscoveryURLRequired)
}
// Clean the issuer URL
issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := globalOIDCProviderCache.get(ctx, issuer)
if err != nil {
return nil, nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID})
scopes := strings.Fields(source.Scopes)
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
if !containsScope(scopes, oidc.ScopeOpenID) {
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
return &oauth2.Config{
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: provider.Endpoint(),
}, verifier, nil
}
func containsScope(scopes []string, scope string) bool {
for _, item := range scopes {
if item == scope {
return true
}
}
return false
}
func buildOAuthUserInfo(ctx context.Context, source *AuthSource, code, nonce, redirectURL string) (*contracts.OAuthUserInfoDTO, error) {
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return nil, err
}
token, err := authConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
userInfo := &contracts.OAuthUserInfoDTO{Active: true}
if verifier != nil {
if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
return nil, verifyErr
}
}
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
return userInfo, nil
}
func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *contracts.OAuthUserInfoDTO) error {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil
}
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
return fmt.Errorf(errIDTokenVerifyFailedFormat, errIDTokenVerifyFailed, verifyErr)
}
if nonce != "" && idToken.Nonce != nonce {
return errors.New(errNonceMismatch)
}
if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
return claimsErr
}
return nil
}
func normalizeOAuthUserInfo(userInfo *contracts.OAuthUserInfoDTO) error {
userInfo.Username = strings.TrimSpace(userInfo.Username)
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
userInfo.Email = strings.TrimSpace(userInfo.Email)
userInfo.Name = strings.TrimSpace(userInfo.Name)
userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL)
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Username == "" {
return errors.New(errUsernameFromSourceFailed)
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
if !userInfo.Active {
userInfo.Active = true
}
return nil
}
func buildCallbackResult(user *contracts.UserDTO, status string) OAuthCallbackResult {
result := OAuthCallbackResult{Status: status}
if user != nil {
info := BuildBasicUserInfo(user, false)
result.User = &info
}
return result
}
@@ -1,7 +1,7 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cap
package auth
import (
"Wavelet/pkg/response"
@@ -14,12 +14,12 @@ import (
func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
restore := InstallTestRuntimeSettings(RuntimeSettings{LoginEnabled: true})
restore := InstallCapTestRuntimeSettings(CapRuntimeSettings{LoginEnabled: true})
t.Cleanup(restore)
engine := gin.New()
engine.Use(response.ErrorHandlerMiddleware())
engine.POST("/register", VerifyMiddleware(GetDefaultManager(), "register"), func(c *gin.Context) {
engine.POST("/register", VerifyCaptchaMiddleware(GetDefaultCapManager(), "register"), func(c *gin.Context) {
c.Status(http.StatusOK)
})
@@ -27,6 +27,6 @@ func TestVerifyMiddlewareMissingTokenIsBadRequest(t *testing.T) {
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("VerifyMiddleware() status = %d, want %d", rec.Code, http.StatusBadRequest)
t.Errorf("VerifyCaptchaMiddleware() status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
+3 -8
View File
@@ -3,12 +3,7 @@
package auth
import "Wavelet/plugins/domain/auth/service"
// SessionConfig defines the session configuration declared by the auth plugin.
type SessionConfig struct {
SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME" default:"wavelet_session"`
SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"`
SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"`
SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"`
SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY" default:"true"`
SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"`
}
type SessionConfig = service.SessionConfig
+52
View File
@@ -0,0 +1,52 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package consts defines constants, keys, and TTL values for the auth domain plugin.
package consts
import "time"
// CAP 默认参数
const (
DefaultCapChallengeCount = 1
DefaultCapChallengeSize = 32
DefaultCapChallengeDifficulty = 4
DefaultCapChallengeTTL = 10 * time.Minute
DefaultCapTokenTTL = 20 * time.Minute
RedeemTokenIDLength = 8 // 兑换 Token ID 字节长度
RedeemVerTokenLength = 15 // 兑换验证 Token 字节长度
TokenPartsCount = 2 // 兑换 Token 由两部分组成 (id:token)
ValuePartsCount = 2 // 存储值由 scope 和过期时间组成 (expNano|scope)
)
// CAP 动态配置键常量
const (
ConfigKeyCapLoginEnabled = "cap_login_enabled"
ConfigKeyCapChallengeCount = "cap_challenge_count"
ConfigKeyCapChallengeSize = "cap_challenge_size"
ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty"
ConfigKeyCapChallengeTTL = "cap_challenge_ttl"
// ConfigKeyCapTokenTTL 验证码 Token 过期时间键
// #nosec G101
ConfigKeyCapTokenTTL = "cap_token_ttl"
)
// HTTP 响应错误文案
const (
ErrCapTokenMissing = "验证码验证失败,缺少验证码凭证" //nolint:gosec // error message constant
ErrCapTokenInvalidOrExpired = "验证码校验失败或已过期,请重试" //nolint:gosec // error message constant
ErrCapNotConfigured = "captcha is not configured"
ErrChallengeGenerateFailed = "生成验证难题失败,请稍后再试"
ErrInvalidRequestParams = "无效的参数"
ErrSolutionVerifyFailed = "校验验证解答失败,请稍后再试"
)
// Redeem 结果码,属于 redeem 响应 JSON 的对外契约取值,禁止改写取值
const (
RedeemErrInvalidToken = "invalid_token"
RedeemErrNonceStoreFailed = "nonce_store_error"
RedeemErrAlreadyRedeemed = "already_redeemed"
RedeemErrSettingsLoad = "settings_load_error"
RedeemErrTokenStoreFailed = "token_store_error" //nolint:gosec // error code constant
)
@@ -1,11 +1,10 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
// Package consts defines constants, keys, and TTL values for the auth domain plugin.
package consts
import (
"time"
)
import "time"
// Session and Context Keys
const (
@@ -14,7 +13,7 @@ const (
UserObjKey = "user_obj"
TokenAuthKey = "token_auth" // 标记当前请求是否通过 Access Token 鉴权
TokenAdminKey = "token_admin" // Access Token 本身是否具有管理员权限
SessionTokenKey = "oauth_session_token" //nolint:gosec // false positive: this is a session key, not hardcoded credentials
SessionTokenKey = "oauth_session_token" //nolint:gosec // false positive: session state key
PasswordHashKey = "password_hash"
SystemUsername = "system"
)
@@ -23,8 +22,8 @@ const (
const (
OAuthStateCacheKeyFormat = "oauth:state:%s"
OAuthStateCacheKeyExpiration = 10 * time.Minute
oauthStateLimitKeyFormat = "oauth:state:limit:%s"
oauthStateLimitMax = 10
OAuthStateLimitKeyFormat = "oauth:state:limit:%s"
OAuthStateLimitMax = 10
)
// OAuth Purpose Constants
@@ -37,3 +36,9 @@ const (
const (
AuthSourceTypeOIDC = "oidc"
)
// Cache TTLs
const (
TokenCacheTTL = 5 * time.Minute
UserCacheTTL = 5 * time.Minute
)
@@ -0,0 +1,53 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package consts defines constants, keys, and TTL values for the auth domain plugin.
package consts
// OAuth and Auth error messages
const (
ErrInvalidState = "非法登录请求"
ErrIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // error message constant
ErrIDTokenVerifyFailedFormat = "%s: %w"
ErrNonceMismatch = "nonce 不匹配,可能存在重放攻击"
ErrNoActiveAuthSource = "未配置可用认证源"
ErrServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试"
ErrAuthSourceRequired = "认证源不能为空"
ErrDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
ErrUsernameGenerateFailed = "无法生成可用用户名"
ErrUsernameFromSourceFailed = "无法从认证源获取用户名"
ErrAuthSourceDisabled = "认证源未启用"
ErrInvalidExternalAccountBindingID = "绑定记录 ID 无效"
ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // error message constant
ErrOAuthStateRateLimited = "请求授权过于频繁,请稍后重试"
ErrAuthSourceNameRequired = "认证源名称不能为空"
ErrAuthSourceNameInvalid = "认证源名称格式不正确"
ErrAuthSourceTypeUnsupported = "不支持的认证源类型"
ErrAuthSourceDiscoveryURLRequired = "Discovery URL 不能为空"
//nolint:gosec // error message constant
ErrAuthSourceClientCredentialsRequired = "启用认证源时必须配置 Client ID 和 Client Secret"
ErrAuthSourceIDRequired = "认证源 ID 不能为空"
ErrUserIDRequired = "用户 ID 不能为空"
ErrExternalAccountBindingIncomplete = "外部帐号绑定信息不完整"
ErrExternalAccountAlreadyBoundToAnother = "该外部帐号已被其他用户绑定"
ErrExternalAccountBindingIDRequired = "外部帐号绑定记录 ID 不能为空"
ErrInsufficientPermission = "权限不足"
ErrBannedAccount = "账号已被封禁"
ErrUnAuthorized = "未登录"
)
// Service 层与鉴权中间件内部错误文案
const (
ErrUserNotInContext = "auth: user not found in context"
ErrEmptyToken = "auth: empty token" //nolint:gosec // error message constant
ErrSystemUserTokenNotAllowed = "auth: system user token not allowed" //nolint:gosec // error message constant
ErrUnauthorizedInternal = "unauthorized"
ErrSystemUserLoginNotAllowed = "system user is not allowed to login"
)
// OAuth 回调会话校验错误文案
const (
ErrInvalidSessionContext = "invalid session context"
ErrSessionMismatchForOAuth = "session mismatch for oauth state"
ErrUserContextMismatch = "user context mismatch for oauth binding"
)
@@ -1,11 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/plugins/domain/auth/model/dto"
"context"
"encoding/json"
@@ -17,7 +19,7 @@ func LogForAudit(ctx context.Context, user *contracts.UserDTO, c *gin.Context) {
if user == nil || c == nil {
return
}
auditLog := loginRequiredAuditLog{
auditLog := dto.LoginRequiredAuditLog{
UserID: user.ID,
Username: user.Username,
ClientIP: c.ClientIP(),
@@ -1,44 +1,59 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cap
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/model/dto"
"Wavelet/plugins/domain/auth/service"
"net/http"
"github.com/gin-gonic/gin"
)
// CaptchaHandler handles CAPTCHA challenge and redeem endpoints.
type CaptchaHandler struct {
capMgr *service.CaptchaManager
}
// NewCaptchaHandler creates a new CaptchaHandler.
func NewCaptchaHandler(mgr *service.CaptchaManager) *CaptchaHandler {
return &CaptchaHandler{
capMgr: mgr,
}
}
// Challenge 生成 PoW 人机验证难题
// @Summary 生成人机验证难题
// @Description 客户端获取 PoW 难题和签名的 JWT Token,并在后台计算。
// @Tags cap
// @Accept json
// @Produce json
// @Param request body challengeRequest false "可选范围限制参数"
// @Success 200 {object} response.Any{data=cap.ChallengeResponse} "成功返回 PoW 难题"
// @Param request body dto.ChallengeRequest false "可选范围限制参数"
// @Success 200 {object} response.Any{data=dto.ChallengeResponse} "成功返回 PoW 难题"
// @Failure 500 {object} response.Any "内部服务错误"
// @Router /api/v1/cap/challenge [get]
// @Router /api/v1/cap/challenge [post]
func Challenge(c *gin.Context) {
var req challengeRequest
func (h *CaptchaHandler) Challenge(c *gin.Context) {
var req dto.ChallengeRequest
_ = c.ShouldBind(&req) // 允许不传 body,默认使用 login scope
if req.Scope == "" {
req.Scope = "login"
}
mgr := GetDefaultManager()
if mgr == nil {
response.AbortInternal(c, errCapNotConfigured)
if h.capMgr == nil {
response.AbortInternal(c, consts.ErrCapNotConfigured)
return
}
resp, err := mgr.Generate(c.Request.Context(), req.Scope)
resp, err := h.capMgr.Generate(c.Request.Context(), req.Scope)
if err != nil {
logger.ErrorF(c.Request.Context(), "Generate cap challenge failed: %v", err)
response.AbortInternal(c, errChallengeGenerateFailed)
response.AbortInternal(c, consts.ErrChallengeGenerateFailed)
return
}
@@ -51,15 +66,15 @@ func Challenge(c *gin.Context) {
// @Tags cap
// @Accept json
// @Produce json
// @Param request body redeemRequest true "难题 Token 与解答 solutions 数组"
// @Success 200 {object} response.Any{data=cap.RedeemResponse} "核销成功,返回 X-Cap-Token"
// @Param request body dto.RedeemRequest true "难题 Token 与解答 solutions 数组"
// @Success 200 {object} response.Any{data=dto.RedeemResponse} "核销成功,返回 X-Cap-Token"
// @Failure 400 {object} response.Any "参数错误或核销失败"
// @Failure 500 {object} response.Any "内部服务错误"
// @Router /api/v1/cap/redeem [post]
func Redeem(c *gin.Context) {
var req redeemRequest
func (h *CaptchaHandler) Redeem(c *gin.Context) {
var req dto.RedeemRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, errInvalidRequestParams)
response.AbortBadRequest(c, consts.ErrInvalidRequestParams)
return
}
@@ -67,15 +82,14 @@ func Redeem(c *gin.Context) {
req.Scope = "login"
}
mgr := GetDefaultManager()
if mgr == nil {
response.AbortInternal(c, errCapNotConfigured)
if h.capMgr == nil {
response.AbortInternal(c, consts.ErrCapNotConfigured)
return
}
resp, err := mgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope)
resp, err := h.capMgr.Redeem(c.Request.Context(), req.Token, req.Solutions, req.Scope)
if err != nil {
logger.ErrorF(c.Request.Context(), "Redeem cap solutions failed: %v", err)
response.AbortInternal(c, errSolutionVerifyFailed)
response.AbortInternal(c, consts.ErrSolutionVerifyFailed)
return
}
@@ -0,0 +1,41 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/pkg/response"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/service"
"github.com/gin-gonic/gin"
)
// VerifyCaptchaMiddleware returns a Gin middleware that checks and consumes the X-Cap-Token header.
func VerifyCaptchaMiddleware(mgr *service.CaptchaManager, settingsMgr *service.CapSettingsManager, scope string) gin.HandlerFunc {
return func(c *gin.Context) {
if settingsMgr != nil && !settingsMgr.CapProtectionEnabled(c.Request.Context()) {
c.Next()
return
}
if mgr == nil {
response.AbortBadRequest(c, consts.ErrCapTokenInvalidOrExpired)
return
}
token := c.GetHeader("X-Cap-Token")
if token == "" {
response.AbortBadRequest(c, consts.ErrCapTokenMissing)
return
}
valid, err := mgr.VerifyToken(c.Request.Context(), token, scope)
if err != nil || !valid {
response.AbortBadRequest(c, consts.ErrCapTokenInvalidOrExpired)
return
}
c.Next()
}
}
@@ -0,0 +1,109 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/core/extpoints"
"Wavelet/plugins/domain/auth/service"
"github.com/gin-gonic/gin"
)
// Controller aggregates all HTTP handlers and middlewares for the auth plugin.
type Controller struct {
svc *service.Service
whitelist *extpoints.PathWhitelist
OAuth *OAuthHandler
UserInfo *UserInfoHandler
Captcha *CaptchaHandler
}
// New creates a new Controller instance.
func New(svc *service.Service) *Controller {
wl := extpoints.NewPathWhitelist()
oauthHandler := NewOAuthHandler(svc.OAuth, svc.Session, svc.DAO)
userInfoHandler := NewUserInfoHandler()
captchaHandler := NewCaptchaHandler(svc.CapManager)
c := &Controller{
svc: svc,
whitelist: wl,
OAuth: oauthHandler,
UserInfo: userInfoHandler,
Captcha: captchaHandler,
}
// Wire middlewares into AuthService
svc.AuthSvc.SetMiddlewareHandlers(
c.LoginRequired(),
c.AdminRequired(),
DisallowTokenAuth(),
CurrentUserIDFromRequestContext,
)
return c
}
// Whitelist returns the whitelist tracker.
func (c *Controller) Whitelist() *extpoints.PathWhitelist {
return c.whitelist
}
// RegisterWhitelist adds path patterns that bypass authentication.
func (c *Controller) RegisterWhitelist(patterns ...string) {
if c.whitelist != nil {
c.whitelist.Add(patterns...)
}
}
// LoginRequired returns the authentication middleware.
func (c *Controller) LoginRequired() gin.HandlerFunc {
return LoginRequiredMiddleware(c.whitelist, c.svc.DAO)
}
// AdminRequired returns the admin authorization middleware.
func (c *Controller) AdminRequired() gin.HandlerFunc {
return AdminRequiredMiddleware(c.svc.DAO)
}
// DisallowTokenAuth returns the token rejection middleware.
func (c *Controller) DisallowTokenAuth() gin.HandlerFunc {
return DisallowTokenAuth()
}
// VerifyCaptcha returns the captcha challenge verification middleware.
func (c *Controller) VerifyCaptcha(scope string) gin.HandlerFunc {
return VerifyCaptchaMiddleware(c.svc.CapManager, c.svc.CapSettings, scope)
}
// RegisterRoutes mounts all auth endpoints onto the router.
func (c *Controller) RegisterRoutes(router extpoints.RouterExtension) {
loginReq := c.LoginRequired()
// 1. OAuth endpoints
oauthGroup := router.Group("/api/v1/oauth")
{
oauthGroup.GET("/sources", c.OAuth.GetLoginSources)
oauthGroup.GET("/login", c.OAuth.GetLoginURL)
oauthGroup.GET("/:source/authorize", c.OAuth.Authorize)
oauthGroup.GET("/logout", c.OAuth.Logout)
oauthGroup.POST("/callback", c.OAuth.Callback)
oauthGroup.GET("/user-info", loginReq, c.UserInfo.UserInfo)
oauthGroup.GET("/external-accounts", loginReq, c.OAuth.ListExternalAccounts)
oauthGroup.POST("/external-accounts/:id/delete", loginReq, c.OAuth.DeleteExternalAccount)
}
// 2. Global user-info route alias
router.GET("/api/v1/user-info", loginReq, c.UserInfo.UserInfo)
// 3. CAPTCHA endpoints
capGroup := router.Group("/api/v1/cap")
{
capGroup.GET("/challenge", c.Captcha.Challenge)
capGroup.POST("/challenge", c.Captcha.Challenge)
capGroup.POST("/redeem", c.Captcha.Redeem)
}
}
@@ -0,0 +1,187 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/response"
"Wavelet/pkg/trace"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/dao"
"Wavelet/plugins/domain/auth/model/do"
"Wavelet/plugins/domain/auth/model/dto"
"Wavelet/plugins/domain/auth/service"
"context"
"errors"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// GetUserIDFromSession 从 Session 中提取用户 ID
func GetUserIDFromSession(s sessions.Session) uint64 {
val := s.Get(consts.UserIDKey)
return dto.ParseUserID(val)
}
// GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID
func GetUserIDFromContext(c *gin.Context) (uid uint64) {
defer func() {
_ = recover()
}()
session := sessions.Default(c)
return GetUserIDFromSession(session)
}
// CurrentUserIDFromRequestContext 是接入层向 Service 层暴露的登录态桥接。
func CurrentUserIDFromRequestContext(ctx context.Context) (uint64, bool) {
ginCtx, ok := ctx.(*gin.Context)
if !ok {
return 0, false
}
return GetUserIDFromContext(ginCtx), true
}
func getUserByToken(ctx context.Context, d *dao.DAO, tokenStr string) (*contracts.UserDTO, *do.CachedToken, error) {
tokenHash := service.HashToken(tokenStr)
tokenRecord, err := d.GetCachedToken(ctx, tokenHash)
if err != nil || tokenRecord == nil {
tokenRecord, err = d.GetAccessTokenByHash(ctx, tokenHash)
if err != nil {
return nil, nil, err
}
d.SetCachedToken(ctx, tokenHash, tokenRecord)
}
user, err := d.GetCachedUser(ctx, tokenRecord.UserID)
if err != nil || user == nil || !user.IsActive {
user, err = d.GetActiveUserByID(ctx, tokenRecord.UserID)
if err != nil {
return nil, nil, err
}
d.SetCachedUser(ctx, tokenRecord.UserID, user)
}
return user, tokenRecord, nil
}
// GetUserFromRequest 从请求中获取当前用户(优先 Access Token,其次 Session)
func GetUserFromRequest(c *gin.Context, d *dao.DAO) (*contracts.UserDTO, error) {
ctx := c.Request.Context()
var tokenStr string
tokenFromQuery := c.Query("token")
if tokenFromQuery != "" {
tokenStr = tokenFromQuery
} else {
authHeader := c.GetHeader("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenStr = authHeader[7:]
}
}
// 优先使用 Access Token 鉴权
if tokenStr != "" {
if user, tokenRecord, err := getUserByToken(ctx, d, tokenStr); err == nil {
if user.Username == consts.SystemUsername {
return nil, errors.New(consts.ErrSystemUserLoginNotAllowed)
}
ginutil.SetToContext(c, contracts.AuthTokenAuthKey, true)
ginutil.SetToContext(c, contracts.AuthTokenAdminKey, tokenRecord.IsAdmin)
return user, nil
}
}
// 降级使用 Session 鉴权
userID := GetUserIDFromContext(c)
if userID <= 0 {
return nil, errors.New(consts.ErrUnauthorizedInternal)
}
user, err := d.GetCachedUser(ctx, userID)
if err != nil || user == nil || !user.IsActive {
user, err = d.GetActiveUserByID(ctx, userID)
if err != nil {
return nil, err
}
d.SetCachedUser(ctx, userID, user)
}
ginutil.SetToContext(c, contracts.AuthTokenAuthKey, false)
ginutil.SetToContext(c, contracts.AuthTokenAdminKey, false)
if user.Username == consts.SystemUsername {
return nil, errors.New(consts.ErrSystemUserLoginNotAllowed)
}
return user, nil
}
// LoginRequiredMiddleware returns a Gin handler function for authentication check.
func LoginRequiredMiddleware(whitelist *extpoints.PathWhitelist, d *dao.DAO) gin.HandlerFunc {
return func(c *gin.Context) {
if whitelist != nil && whitelist.Match(c.Request.URL.Path) {
c.Next()
return
}
_, span := trace.Start(c.Request.Context(), "LoginRequired")
defer span.End()
user, err := GetUserFromRequest(c, d)
if err != nil {
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
return
}
LogForAudit(c.Request.Context(), user, c)
ginutil.SetToContext(c, contracts.AuthUserObjKey, user)
c.Next()
}
}
// AdminRequiredMiddleware returns a Gin handler function for admin authorization check.
func AdminRequiredMiddleware(d *dao.DAO) gin.HandlerFunc {
return func(c *gin.Context) {
_, span := trace.Start(c.Request.Context(), "AdminRequired")
defer span.End()
user, err := GetUserFromRequest(c, d)
if err != nil {
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
return
}
isTokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey)
isTokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey)
// Logged-in but lacking admin permission is 403, not 401/404.
if isTokenAuth && !isTokenAdmin && !user.IsAdmin {
response.AbortForbidden(c, consts.ErrInsufficientPermission)
return
}
if !isTokenAuth && !user.IsAdmin {
response.AbortForbidden(c, consts.ErrInsufficientPermission)
return
}
LogForAudit(c.Request.Context(), user, c)
ginutil.SetToContext(c, contracts.AuthUserObjKey, user)
c.Next()
}
}
// DisallowTokenAuth returns a middleware that rejects requests authenticated via access token.
func DisallowTokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if tokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey); tokenAuth {
response.AbortForbidden(c, consts.ErrTokenAuthNotAllowed)
return
}
c.Next()
}
}
@@ -0,0 +1,448 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/core/contracts"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/dao"
"Wavelet/plugins/domain/auth/model/do"
"Wavelet/plugins/domain/auth/model/dto"
"Wavelet/plugins/domain/auth/model/entity"
"Wavelet/plugins/domain/auth/service"
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// OAuthHandler handles OAuth authentication endpoints.
type OAuthHandler struct {
oauthSvc *service.OAuthService
sessionSvc *service.SessionService
dao *dao.DAO
}
// NewOAuthHandler creates a new OAuthHandler.
func NewOAuthHandler(oauthSvc *service.OAuthService, sessionSvc *service.SessionService, d *dao.DAO) *OAuthHandler {
return &OAuthHandler{
oauthSvc: oauthSvc,
sessionSvc: sessionSvc,
dao: d,
}
}
// GetLoginSources 获取可用登录源列表
// @Summary 获取可用登录源
// @Description 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用
// @Tags oauth
// @Produce json
// @Success 200 {object} response.Any{data=[]dto.AuthSourceView} "登录源列表"
// @Router /api/v1/oauth/sources [get]
func (h *OAuthHandler) GetLoginSources(c *gin.Context) {
sources, err := h.oauthSvc.ActiveLoginSources(c.Request.Context())
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(sources))
}
// GetLoginURL 获取登录授权地址
// @Summary 获取登录授权地址
// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。
// @Tags oauth
// @Produce json
// @Param source query string false "认证源名称,为空使用第一个启用的认证源"
// @Success 200 {object} response.Any{data=dto.OAuthAuthorizeResponse} "授权 URL"
// @Failure 400 {object} response.Any "认证源不存在或未配置"
// @Failure 500 {object} response.Any "构造 URL 失败"
// @Router /api/v1/oauth/login [get]
func (h *OAuthHandler) GetLoginURL(c *gin.Context) {
ctx := c.Request.Context()
if !h.oauthSvc.IsOIDCLoginEnabled(ctx) {
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
return
}
source, err := h.oauthSvc.ResolveAuthSource(ctx, c.Query("source"))
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
return
}
session := sessions.Default(c)
token, isNew := h.sessionSvc.EnsureSessionToken(session)
if isNew {
if err := session.Save(); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
userID := GetUserIDFromSession(session)
sessionHash := h.sessionSvc.HashSessionToken(token)
if err := h.oauthSvc.ReserveOAuthStateSlot(ctx, sessionHash); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
state := uuid.NewString()
payloadValue, err := (do.OAuthStatePayload{
SourceName: source.Name,
Purpose: consts.OAuthPurposeLogin,
UserID: userID,
SessionHash: sessionHash,
}).Encode()
if err != nil {
response.AbortInternal(c, err.Error())
return
}
stateKey := fmt.Sprintf(consts.OAuthStateCacheKeyFormat, state)
if cache := h.dao.Cache(); cache != nil {
if err := cache.Set(ctx, stateKey, payloadValue, consts.OAuthStateCacheKeyExpiration); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
authorizeURL, err := h.oauthSvc.BuildAuthorizeURL(ctx, source, state)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(dto.OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
// Authorize 发起指定认证源授权
// @Summary 发起指定认证源授权
// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
// @Tags oauth
// @Produce json
// @Param source path string true "认证源名称"
// @Param purpose query string false "授权目的:login(登录)或 bind(绑定账号),默认 login"
// @Success 200 {object} response.Any{data=dto.OAuthAuthorizeResponse} "授权 URL"
// @Failure 400 {object} response.Any "认证源不存在或未启用"
// @Failure 500 {object} response.Any "构造 URL 失败"
// @Router /api/v1/oauth/{source}/authorize [get]
func (h *OAuthHandler) Authorize(c *gin.Context) {
ctx := c.Request.Context()
if !h.oauthSvc.IsOIDCLoginEnabled(ctx) {
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
return
}
source, err := h.oauthSvc.ResolveAuthSource(ctx, c.Param("source"))
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
return
}
purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose")))
if purpose != consts.OAuthPurposeBind {
purpose = consts.OAuthPurposeLogin
}
session := sessions.Default(c)
userID := GetUserIDFromSession(session)
if purpose == consts.OAuthPurposeBind && userID == 0 {
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
return
}
token, isNew := h.sessionSvc.EnsureSessionToken(session)
if isNew {
if err := session.Save(); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
sessionHash := h.sessionSvc.HashSessionToken(token)
if err := h.oauthSvc.ReserveOAuthStateSlot(ctx, sessionHash); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
state := uuid.NewString()
payloadValue, err := (do.OAuthStatePayload{
SourceName: source.Name,
Purpose: purpose,
UserID: userID,
SessionHash: sessionHash,
}).Encode()
if err != nil {
response.AbortInternal(c, err.Error())
return
}
stateKey := fmt.Sprintf(consts.OAuthStateCacheKeyFormat, state)
if cache := h.dao.Cache(); cache != nil {
if err := cache.Set(ctx, stateKey, payloadValue, consts.OAuthStateCacheKeyExpiration); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
authorizeURL, err := h.oauthSvc.BuildAuthorizeURL(ctx, source, state)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(dto.OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
// Callback OAuth 回调处理
// @Summary OAuth 回调处理
// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。
// @Tags oauth
// @Accept json
// @Produce json
// @Param request body dto.CallbackRequest true "回调请求参数"
// @Success 200 {object} response.Any{data=dto.OAuthCallbackResult} "登录或绑定成功"
// @Failure 400 {object} response.Any "state 无效、参数错误或认证源错误"
// @Failure 401 {object} response.Any "绑定场景未登录"
// @Failure 500 {object} response.Any "OAuth 认证失败或内部错误"
// @Router /api/v1/oauth/callback [post]
func (h *OAuthHandler) Callback(c *gin.Context) {
var req dto.CallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
ctx := c.Request.Context()
stateKey := fmt.Sprintf(consts.OAuthStateCacheKeyFormat, req.State)
var payloadRaw string
cache := h.dao.Cache()
if cache == nil {
response.AbortBadRequest(c, consts.ErrInvalidState)
return
}
if err := cache.Get(ctx, stateKey, &payloadRaw); err != nil {
response.AbortBadRequest(c, consts.ErrInvalidState)
return
}
_ = cache.Delete(ctx, stateKey)
payload, err := do.DecodeOAuthStatePayload(payloadRaw)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
session := sessions.Default(c)
currentUserID := GetUserIDFromSession(session)
if payload.Purpose == consts.OAuthPurposeBind && currentUserID == 0 {
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
return
}
token, ok := session.Get(consts.SessionTokenKey).(string)
if !ok || token == "" {
response.AbortBadRequest(c, consts.ErrInvalidSessionContext)
return
}
if h.sessionSvc.HashSessionToken(token) != payload.SessionHash {
response.AbortBadRequest(c, consts.ErrSessionMismatchForOAuth)
return
}
if payload.Purpose == consts.OAuthPurposeBind && currentUserID != payload.UserID {
response.AbortBadRequest(c, consts.ErrUserContextMismatch)
return
}
if !h.oauthSvc.IsOIDCLoginEnabled(ctx) {
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
return
}
source, err := h.oauthSvc.ResolveAuthSource(ctx, payload.SourceName)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.AbortBadRequest(c, consts.ErrAuthSourceDisabled)
return
}
redirectURL, err := h.oauthSvc.GetFrontendLoginRedirectURL(ctx)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
userInfo, err := h.oauthSvc.BuildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
if err := h.oauthSvc.NormalizeOAuthUserInfo(userInfo); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if userInfo.Sub == "" {
userInfo.Sub = userInfo.Username
}
if payload.Purpose == consts.OAuthPurposeBind {
h.handleCallbackBind(ctx, c, source, userInfo)
return
}
h.handleCallbackLogin(ctx, c, source, userInfo)
}
func (h *OAuthHandler) handleCallbackBind(ctx context.Context, c *gin.Context, source *entity.AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
userID := GetUserIDFromContext(c)
if userID == 0 {
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
return
}
user, err := h.dao.GetUserByID(ctx, userID)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
if err := h.oauthSvc.BindExternalAccount(ctx, source.ID, user.ID, userInfo); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "bound")))
}
func (h *OAuthHandler) handleCallbackLogin(ctx context.Context, c *gin.Context, source *entity.AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
user, ok, err := h.oauthSvc.AuthenticateOrRegisterUser(ctx, source, userInfo)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
if !ok || user == nil {
c.JSON(http.StatusOK, response.OK(buildCallbackResult(nil, "need_bind")))
return
}
session := sessions.Default(c)
isSessionCookie, err := h.sessionSvc.ApplyLoginSession(ctx, session, user)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
if isSessionCookie {
h.sessionSvc.StripCookieMaxAgeAndExpires(c.Writer.Header(), h.sessionSvc.Config().SessionCookieName)
}
h.dao.SetCachedUser(ctx, user.ID, user)
logger.InfoF(ctx, "[LoginAudit] successful OAuth login via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP())
c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "logged_in")))
}
func buildCallbackResult(user *contracts.UserDTO, status string) dto.OAuthCallbackResult {
result := dto.OAuthCallbackResult{Status: status}
if user != nil {
info := dto.BuildBasicUserInfo(user, false)
result.User = &info
}
return result
}
// Logout 退出登录
// @Summary 退出登录
// @Description 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=string} "退出成功"
// @Failure 500 {object} response.Any "Session 清除失败"
// @Router /api/v1/oauth/logout [get]
func (h *OAuthHandler) Logout(c *gin.Context) {
session := sessions.Default(c)
userID := session.Get(consts.UserIDKey)
username := session.Get(consts.UserNameKey)
if userID != nil {
logger.InfoF(c.Request.Context(), "[LoginAudit] user logged out: %v, ID: %v, IP: %s", username, userID, c.ClientIP())
if id := dto.ParseUserID(userID); id > 0 {
h.dao.InvalidateCachedUser(c.Request.Context(), id)
}
}
session.Options(h.sessionSvc.GetSessionOptions(-1))
session.Clear()
if err := session.Save(); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
// @Summary 获取外部帐号列表
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any "外部帐号列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/oauth/external-accounts [get]
func (h *OAuthHandler) ListExternalAccounts(c *gin.Context) {
userID := GetUserIDFromContext(c)
accounts, err := h.oauthSvc.ListExternalAccounts(c.Request.Context(), userID)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(accounts))
}
// DeleteExternalAccount 解除外部帐号绑定
// @Summary 解除外部帐号绑定
// @Description 解除当前登录用户与指定外部帐号的绑定关系,需要登录
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "外部帐号绑定记录 ID"
// @Success 200 {object} response.Any{data=string} "解除绑定成功"
// @Failure 400 {object} response.Any "ID 无效或解除失败"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/oauth/external-accounts/{id}/delete [post]
func (h *OAuthHandler) DeleteExternalAccount(c *gin.Context) {
userID := GetUserIDFromContext(c)
if userID == 0 {
response.AbortUnauthorized(c, consts.ErrUnAuthorized)
return
}
rawID := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || id == 0 {
response.AbortBadRequest(c, consts.ErrInvalidExternalAccountBindingID)
return
}
if err := h.oauthSvc.DeleteExternalAccount(c.Request.Context(), id, userID); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package controller provides HTTP handlers and middlewares for the auth plugin.
package controller
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/auth/model/dto"
"net/http"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// UserInfoHandler handles current user info queries.
type UserInfoHandler struct{}
// NewUserInfoHandler creates a new UserInfoHandler.
func NewUserInfoHandler() *UserInfoHandler {
return &UserInfoHandler{}
}
// UserInfo 获取当前登录用户信息
// @Summary 获取当前登录用户信息
// @Description 返回当前登录用户的基本信息,需要登录。
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=dto.BasicUserInfo} "用户信息"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/oauth/user-info [get]
// @Router /api/v1/user-info [get]
func (h *UserInfoHandler) UserInfo(c *gin.Context) {
user, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
session := sessions.Default(c)
needChange := session.Get("need_change_password") == true || (user != nil && user.NeedChangePassword)
c.JSON(
http.StatusOK,
response.OK(dto.BuildBasicUserInfo(user, needChange)),
)
}
@@ -0,0 +1,61 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package dao provides data access objects and caching for the auth domain plugin.
package dao
import (
"Wavelet/plugins/domain/auth/model/entity"
"context"
)
// ListAllAuthSources 获取全部认证源(含未启用),按 ID 升序
func (d *DAO) ListAllAuthSources(ctx context.Context) ([]entity.AuthSource, error) {
var sources []entity.AuthSource
if err := d.DB(ctx).Order("id ASC").Find(&sources).Error; err != nil {
return nil, err
}
return sources, nil
}
// GetAuthSourceByID 根据 ID 获取认证源
func (d *DAO) GetAuthSourceByID(ctx context.Context, id uint64) (*entity.AuthSource, error) {
var src entity.AuthSource
if err := d.DB(ctx).First(&src, id).Error; err != nil {
return nil, err
}
return &src, nil
}
// GetAuthSourceByName 根据名称获取认证源
func (d *DAO) GetAuthSourceByName(ctx context.Context, name string) (*entity.AuthSource, error) {
var src entity.AuthSource
if err := d.DB(ctx).Where("name = ?", name).First(&src).Error; err != nil {
return nil, err
}
return &src, nil
}
// ListActiveAuthSources 获取所有启用的认证源
func (d *DAO) ListActiveAuthSources(ctx context.Context) ([]entity.AuthSource, error) {
var sources []entity.AuthSource
if err := d.DB(ctx).Where("is_active = ?", true).Order("id ASC").Find(&sources).Error; err != nil {
return nil, err
}
return sources, nil
}
// CreateAuthSource 新建认证源记录
func (d *DAO) CreateAuthSource(ctx context.Context, source *entity.AuthSource) error {
return d.DB(ctx).Create(source).Error
}
// SaveAuthSource 全量保存认证源记录
func (d *DAO) SaveAuthSource(ctx context.Context, source *entity.AuthSource) error {
return d.DB(ctx).Save(source).Error
}
// DeleteAuthSource 删除认证源记录
func (d *DAO) DeleteAuthSource(ctx context.Context, source *entity.AuthSource) error {
return d.DB(ctx).Delete(source).Error
}
@@ -1,30 +1,20 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
// Package dao provides data access objects and caching for the auth domain plugin.
package dao
import (
"Wavelet/core/contracts"
"Wavelet/pkg/cache/ram"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/model/do"
"context"
"fmt"
"time"
)
const (
tokenCacheTTL = 5 * time.Minute
userCacheTTL = 5 * time.Minute
)
// CachedToken represents the minimal cached representation of an access token.
type CachedToken struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
IsAdmin bool `json:"is_admin"`
}
var (
tokenRAM = ram.MustNew[string, *CachedToken](ram.Options{MaximumSize: 2048})
tokenRAM = ram.MustNew[string, *do.CachedToken](ram.Options{MaximumSize: 2048})
userRAM = ram.MustNew[uint64, *contracts.UserDTO](ram.Options{MaximumSize: 2048})
)
@@ -37,13 +27,15 @@ func userCacheKey(userID uint64) string {
}
// GetCachedToken 获取缓存的 Token
func GetCachedToken(ctx context.Context, tokenHash string) (*CachedToken, error) {
//
//nolint:dupl // token and user cache lookup pattern
func (d *DAO) GetCachedToken(ctx context.Context, tokenHash string) (*do.CachedToken, error) {
if val, ok := tokenRAM.GetIfPresent(tokenHash); ok {
return val, nil
}
if cache := getCache(ctx); cache != nil {
var token CachedToken
if cache := d.Cache(); cache != nil {
var token do.CachedToken
key := tokenCacheKey(tokenHash)
if err := cache.Get(ctx, key, &token); err == nil {
tokenRAM.Set(tokenHash, &token)
@@ -54,30 +46,32 @@ func GetCachedToken(ctx context.Context, tokenHash string) (*CachedToken, error)
}
// SetCachedToken 设置 Token 缓存
func SetCachedToken(ctx context.Context, tokenHash string, token *CachedToken) {
func (d *DAO) SetCachedToken(ctx context.Context, tokenHash string, token *do.CachedToken) {
tokenRAM.Set(tokenHash, token)
if cache := getCache(ctx); cache != nil {
if cache := d.Cache(); cache != nil {
key := tokenCacheKey(tokenHash)
_ = cache.Set(ctx, key, token, tokenCacheTTL)
_ = cache.Set(ctx, key, token, consts.TokenCacheTTL)
}
}
// InvalidateCachedToken 吊销/删除 token 缓存
func InvalidateCachedToken(ctx context.Context, tokenHash string) {
func (d *DAO) InvalidateCachedToken(ctx context.Context, tokenHash string) {
tokenRAM.Invalidate(tokenHash)
if cache := getCache(ctx); cache != nil {
if cache := d.Cache(); cache != nil {
key := tokenCacheKey(tokenHash)
_ = cache.Delete(ctx, key)
}
}
// GetCachedUser 获取缓存的 UserDTO
func GetCachedUser(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
//
//nolint:dupl // token and user cache lookup pattern
func (d *DAO) GetCachedUser(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
if val, ok := userRAM.GetIfPresent(userID); ok {
return val, nil
}
if cache := getCache(ctx); cache != nil {
if cache := d.Cache(); cache != nil {
var u contracts.UserDTO
key := userCacheKey(userID)
if err := cache.Get(ctx, key, &u); err == nil {
@@ -89,28 +83,25 @@ func GetCachedUser(ctx context.Context, userID uint64) (*contracts.UserDTO, erro
}
// SetCachedUser 设置 UserDTO 缓存
func SetCachedUser(ctx context.Context, userID uint64, u *contracts.UserDTO) {
func (d *DAO) SetCachedUser(ctx context.Context, userID uint64, u *contracts.UserDTO) {
userRAM.Set(userID, u)
if cache := getCache(ctx); cache != nil {
if cache := d.Cache(); cache != nil {
key := userCacheKey(userID)
_ = cache.Set(ctx, key, u, userCacheTTL)
_ = cache.Set(ctx, key, u, consts.UserCacheTTL)
}
}
// InvalidateCachedUser 吊销/失效 UserDTO 缓存
func InvalidateCachedUser(ctx context.Context, userID uint64) {
func (d *DAO) InvalidateCachedUser(ctx context.Context, userID uint64) {
userRAM.Invalidate(userID)
if cache := getCache(ctx); cache != nil {
if cache := d.Cache(); cache != nil {
key := userCacheKey(userID)
_ = cache.Delete(ctx, key)
}
}
// StopAuthCacheListener compatibility stub for tests
func StopAuthCacheListener() {}
// ResetAuthRAMCacheForTest clears only the process-local RAM cache.
func ResetAuthRAMCacheForTest() {
// ResetRAMCacheForTest clears only the process-local RAM cache.
func ResetRAMCacheForTest() {
tokenRAM.InvalidateAll()
userRAM.InvalidateAll()
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package dao provides data access objects and caching for the auth domain plugin.
package dao
import (
"Wavelet/core/contracts"
"context"
"gorm.io/gorm"
)
// DAO aggregates all data access objects for the auth domain plugin.
type DAO struct {
dbSvc contracts.DBService
cacheSvc contracts.CacheService
limiterSvc contracts.LimiterService
}
// New creates a new DAO aggregate.
func New(dbSvc contracts.DBService, cacheSvc contracts.CacheService, limiterSvc contracts.LimiterService) *DAO {
return &DAO{
dbSvc: dbSvc,
cacheSvc: cacheSvc,
limiterSvc: limiterSvc,
}
}
// SetDBService updates the DBService reference.
func (d *DAO) SetDBService(db contracts.DBService) {
d.dbSvc = db
}
// SetCacheService updates the CacheService reference.
func (d *DAO) SetCacheService(cache contracts.CacheService) {
d.cacheSvc = cache
}
// SetLimiterService updates the LimiterService reference.
func (d *DAO) SetLimiterService(limiter contracts.LimiterService) {
d.limiterSvc = limiter
}
// DB returns the GORM DB instance associated with the request context.
func (d *DAO) DB(ctx context.Context) *gorm.DB {
if d.dbSvc != nil {
return d.dbSvc.DB(ctx)
}
return nil
}
// Cache returns the CacheService instance.
func (d *DAO) Cache() contracts.CacheService {
return d.cacheSvc
}
// Limiter returns the LimiterService instance.
func (d *DAO) Limiter() contracts.LimiterService {
return d.limiterSvc
}
@@ -0,0 +1,38 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package dao provides data access objects and caching for the auth domain plugin.
package dao
import (
"Wavelet/plugins/domain/auth/model/entity"
"context"
)
// FindExternalAccount 查询指定认证源的外部账号绑定
func (d *DAO) FindExternalAccount(ctx context.Context, authSourceID uint64, externalID string) (*entity.ExternalAccount, error) {
var account entity.ExternalAccount
if err := d.DB(ctx).Where("auth_source_id = ? AND external_id = ?", authSourceID, externalID).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// BindExternalAccount 绑定外部账号
func (d *DAO) BindExternalAccount(ctx context.Context, account *entity.ExternalAccount) error {
return d.DB(ctx).Create(account).Error
}
// ListExternalAccountsByUserID 获取用户绑定的所有外部账号
func (d *DAO) ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]entity.ExternalAccount, error) {
var accounts []entity.ExternalAccount
if err := d.DB(ctx).Where("user_id = ?", userID).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
// UnbindExternalAccount 解绑外部账号
func (d *DAO) UnbindExternalAccount(ctx context.Context, id, userID uint64) error {
return d.DB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&entity.ExternalAccount{}).Error
}
@@ -0,0 +1,91 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package dao provides data access objects and caching for the auth domain plugin.
package dao
import (
"Wavelet/core/contracts"
"Wavelet/pkg/util"
"Wavelet/plugins/domain/auth/model/do"
"context"
"time"
)
// GetAccessTokenByHash 按令牌哈希读取访问令牌记录(仅取鉴权所需字段)
func (d *DAO) GetAccessTokenByHash(ctx context.Context, tokenHash string) (*do.CachedToken, error) {
var row struct {
ID uint64
UserID uint64
IsAdmin bool
}
if err := d.DB(ctx).Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&row).Error; err != nil {
return nil, err
}
return &do.CachedToken{
ID: row.ID,
UserID: row.UserID,
IsAdmin: row.IsAdmin,
}, nil
}
// GetActiveUserByID 读取仍处于启用状态的用户
func (d *DAO) GetActiveUserByID(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
var user contracts.UserDTO
if err := d.DB(ctx).Table("w_users").Where("id = ? AND is_active = ?", userID, true).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID 按 ID 读取用户(不限制启用状态)
func (d *DAO) GetUserByID(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
var user contracts.UserDTO
if err := d.DB(ctx).Table("w_users").Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// InsertUser 新建用户记录
func (d *DAO) InsertUser(ctx context.Context, user *contracts.UserDTO) error {
return d.DB(ctx).Table("w_users").Create(user).Error
}
// TouchUserLastLogin 刷新用户最后登录时间
func (d *DAO) TouchUserLastLogin(ctx context.Context, userID uint64, at time.Time) error {
return d.DB(ctx).Table("w_users").Where("id = ?", userID).Update("last_login_at", at).Error
}
// ListSimilarUsernames 查询与基础用户名相同或带 `-序号` 后缀的用户名(用于用户名去重)
func (d *DAO) ListSimilarUsernames(ctx context.Context, base string) ([]string, error) {
var existingUsernames []string
if err := d.DB(ctx).Table("w_users").
Where("username = ? OR username LIKE ? ESCAPE '\\'", base, util.EscapeLike(base)+"-%").
Pluck("username", &existingUsernames).Error; err != nil {
return nil, err
}
return existingUsernames, nil
}
// GetSystemConfigValue 读取系统配置项原始值
func (d *DAO) GetSystemConfigValue(ctx context.Context, key string) (string, error) {
var val string
if err := d.DB(ctx).Table("w_system_configs").Where("key = ?", key).Pluck("value", &val).Error; err != nil {
return "", err
}
return val, nil
}
// ListSystemConfigsByKeys 按键批量读取系统配置项
func (d *DAO) ListSystemConfigsByKeys(ctx context.Context, keys []string) ([]do.CapConfigRecord, error) {
var records []do.CapConfigRecord
db := d.DB(ctx)
if db == nil {
return nil, nil
}
if err := db.Table("w_system_configs").Where("key IN ?", keys).Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}
-52
View File
@@ -1,52 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
// OAuth and Auth error messages
const (
errInvalidState = "非法登录请求"
errIDTokenVerifyFailed = "ID Token 验证失败" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errIDTokenVerifyFailedFormat = "%s: %w"
errNonceMismatch = "nonce 不匹配,可能存在重放攻击"
errNoActiveAuthSource = "未配置可用认证源"
errServerAddressMissing = "服务器地址 (server_address) 未配置或配置为空,请在后台系统设置中配置后再试"
errAuthSourceRequired = "认证源不能为空"
errDiscoveryURLRequired = "OIDC 认证源必须配置 Discovery URL"
errUsernameGenerateFailed = "无法生成可用用户名"
errUsernameFromSourceFailed = "无法从认证源获取用户名"
errAuthSourceDisabled = "认证源未启用"
errInvalidExternalAccountBindingID = "绑定记录 ID 无效"
ErrTokenAuthNotAllowed = "该端点不允许使用访问令牌进行身份验证" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errOAuthStateRateLimited = "请求授权过于频繁,请稍后重试"
errAuthSourceNameRequired = "认证源名称不能为空"
errAuthSourceNameInvalid = "认证源名称格式不正确"
errAuthSourceTypeUnsupported = "不支持的认证源类型"
errAuthSourceDiscoveryURLRequired = "Discovery URL 不能为空"
//nolint:gosec // error message, not hardcoded credentials
errAuthSourceClientCredentialsRequired = "启用认证源时必须配置 Client ID 和 Client Secret"
errAuthSourceIDRequired = "认证源 ID 不能为空"
errUserIDRequired = "用户 ID 不能为空"
errExternalAccountBindingIncomplete = "外部帐号绑定信息不完整"
errExternalAccountAlreadyBoundToAnother = "该外部帐号已被其他用户绑定"
errExternalAccountBindingIDRequired = "外部帐号绑定记录 ID 不能为空"
errInsufficientPermission = "权限不足"
errBannedAccount = "账号已被封禁"
errUnAuthorized = "未登录"
)
// Service 层与鉴权中间件内部错误文案(保持与重构前逐字一致)
const (
errUserNotInContext = "auth: user not found in context"
errEmptyToken = "auth: empty token" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errSystemUserTokenNotAllowed = "auth: system user token not allowed" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
errUnauthorizedInternal = "unauthorized"
errSystemUserLoginNotAllowed = "system user is not allowed to login"
)
// OAuth 回调会话校验错误文案(保持与重构前逐字一致)
const (
errInvalidSessionContext = "invalid session context"
errSessionMismatchForOAuth = "session mismatch for oauth state"
errUserContextMismatch = "user context mismatch for oauth binding"
)
+338
View File
@@ -0,0 +1,338 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/controller"
"Wavelet/plugins/domain/auth/dao"
"Wavelet/plugins/domain/auth/model/do"
"Wavelet/plugins/domain/auth/model/dto"
"Wavelet/plugins/domain/auth/model/entity"
"Wavelet/plugins/domain/auth/service"
"context"
"net/http"
"sync"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
// Exported Type Aliases for Backward Compatibility
//
//nolint:revive // backward compatibility type aliases with legacy naming
type (
AuthSource = entity.AuthSource
ExternalAccount = entity.ExternalAccount
CachedToken = do.CachedToken
CapRuntimeSettings = do.CapRuntimeSettings
AuthSourceView = dto.AuthSourceView
BasicUserInfo = dto.BasicUserInfo
OAuthAuthorizeResponse = dto.OAuthAuthorizeResponse
OAuthCallbackResult = dto.OAuthCallbackResult
CallbackRequest = dto.CallbackRequest
ChallengeResponse = dto.ChallengeResponse
RedeemResponse = dto.RedeemResponse
CaptchaManager = service.CaptchaManager
)
// Exported Constant Aliases for Backward Compatibility
const (
UserNameKey = consts.UserNameKey
UserIDKey = consts.UserIDKey
UserObjKey = consts.UserObjKey
TokenAuthKey = consts.TokenAuthKey
TokenAdminKey = consts.TokenAdminKey
SessionTokenKey = consts.SessionTokenKey
PasswordHashKey = consts.PasswordHashKey
SystemUsername = consts.SystemUsername
OAuthStateCacheKeyFormat = consts.OAuthStateCacheKeyFormat
OAuthStateCacheKeyExpiration = consts.OAuthStateCacheKeyExpiration
OAuthPurposeLogin = consts.OAuthPurposeLogin
OAuthPurposeBind = consts.OAuthPurposeBind
AuthSourceTypeOIDC = consts.AuthSourceTypeOIDC
ErrTokenAuthNotAllowed = consts.ErrTokenAuthNotAllowed
)
var (
defaultMu sync.RWMutex
defaultDAO = dao.New(nil, nil, nil)
defaultService = service.New(defaultDAO, SessionConfig{SessionCookieName: "wavelet_session", SessionAge: 86400, SessionHTTPOnly: true}, nil)
defaultCtrl = controller.New(defaultService)
)
func setDefaultRuntime(d *dao.DAO, s *service.Service, c *controller.Controller) {
defaultMu.Lock()
defer defaultMu.Unlock()
defaultDAO = d
defaultService = s
defaultCtrl = c
}
func getDefaultRuntime() (*dao.DAO, *service.Service, *controller.Controller) {
defaultMu.RLock()
defer defaultMu.RUnlock()
return defaultDAO, defaultService, defaultCtrl
}
// ParseUserID parses a string, int, or float64 user ID representation.
func ParseUserID(v any) uint64 {
return dto.ParseUserID(v)
}
// BuildBasicUserInfo converts UserDTO to BasicUserInfo.
func BuildBasicUserInfo(user *contracts.UserDTO, needChange bool) BasicUserInfo {
return dto.BuildBasicUserInfo(user, needChange)
}
// SetSessionConfig updates the active session configuration.
func SetSessionConfig(cfg SessionConfig) {
_, s, _ := getDefaultRuntime()
s.Session.SetConfig(cfg)
}
// GetSessionConfig returns the active session configuration.
func GetSessionConfig() SessionConfig {
_, s, _ := getDefaultRuntime()
return s.Session.Config()
}
// GetSessionOptions builds session cookie options based on config and maxAge.
func GetSessionOptions(maxAge int) sessions.Options {
_, s, _ := getDefaultRuntime()
return s.Session.GetSessionOptions(maxAge)
}
// StripCookieMaxAgeAndExpires removes max-age and expires from cookie header.
func StripCookieMaxAgeAndExpires(header http.Header, cookieName string) {
_, s, _ := getDefaultRuntime()
s.Session.StripCookieMaxAgeAndExpires(header, cookieName)
}
// GetUserIDFromSession extracts user ID from session.
func GetUserIDFromSession(s sessions.Session) uint64 {
return controller.GetUserIDFromSession(s)
}
// GetUserIDFromContext extracts user ID from Gin context.
func GetUserIDFromContext(c *gin.Context) uint64 {
return controller.GetUserIDFromContext(c)
}
// SetLoginSession sets the login session for the authenticated user.
func SetLoginSession(ctx context.Context, c *gin.Context, user *contracts.UserDTO, extras ...map[string]any) error {
_, s, _ := getDefaultRuntime()
session := sessions.Default(c)
isSessionCookie, err := s.Session.ApplyLoginSession(ctx, session, user, extras...)
if err != nil {
return err
}
if isSessionCookie {
s.Session.StripCookieMaxAgeAndExpires(c.Writer.Header(), s.Session.Config().SessionCookieName)
}
return nil
}
// RegisterWhitelist adds whitelist path patterns.
func RegisterWhitelist(patterns ...string) {
_, _, c := getDefaultRuntime()
c.RegisterWhitelist(patterns...)
}
// IsWhitelisted checks if the path matches the auth whitelist.
func IsWhitelisted(path string) bool {
_, _, c := getDefaultRuntime()
if wl := c.Whitelist(); wl != nil {
return wl.Match(path)
}
return false
}
// GetUserFromRequest extracts user from Request (Token or Session).
func GetUserFromRequest(c *gin.Context) (*contracts.UserDTO, error) {
d, _, _ := getDefaultRuntime()
return controller.GetUserFromRequest(c, d)
}
// LoginRequired returns authentication required middleware.
func LoginRequired() gin.HandlerFunc {
_, _, c := getDefaultRuntime()
return c.LoginRequired()
}
// AdminRequired returns admin authorization middleware.
func AdminRequired() gin.HandlerFunc {
_, _, c := getDefaultRuntime()
return c.AdminRequired()
}
// LoginAdminRequired alias for AdminRequired.
func LoginAdminRequired() gin.HandlerFunc {
return AdminRequired()
}
// DisallowTokenAuth returns middleware rejecting access token requests.
func DisallowTokenAuth() gin.HandlerFunc {
return controller.DisallowTokenAuth()
}
// GetCachedToken reads cached access token.
func GetCachedToken(ctx context.Context, tokenHash string) (*CachedToken, error) {
d, _, _ := getDefaultRuntime()
return d.GetCachedToken(ctx, tokenHash)
}
// SetCachedToken stores access token into cache.
func SetCachedToken(ctx context.Context, tokenHash string, token *CachedToken) {
d, _, _ := getDefaultRuntime()
d.SetCachedToken(ctx, tokenHash, token)
}
// InvalidateCachedToken invalidates access token cache.
func InvalidateCachedToken(ctx context.Context, tokenHash string) {
d, _, _ := getDefaultRuntime()
d.InvalidateCachedToken(ctx, tokenHash)
}
// GetCachedUser reads cached user.
func GetCachedUser(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
d, _, _ := getDefaultRuntime()
return d.GetCachedUser(ctx, userID)
}
// SetCachedUser stores user into cache.
func SetCachedUser(ctx context.Context, userID uint64, u *contracts.UserDTO) {
d, _, _ := getDefaultRuntime()
d.SetCachedUser(ctx, userID, u)
}
// InvalidateCachedUser invalidates user cache.
func InvalidateCachedUser(ctx context.Context, userID uint64) {
d, _, _ := getDefaultRuntime()
d.InvalidateCachedUser(ctx, userID)
}
// ResetAuthRAMCacheForTest clears RAM caches.
func ResetAuthRAMCacheForTest() {
dao.ResetRAMCacheForTest()
}
// StopAuthCacheListener compatibility stub.
func StopAuthCacheListener() {}
// SetCapSecret sets CAPTCHA secret.
func SetCapSecret(secret []byte) {
_, s, _ := getDefaultRuntime()
s.CapManager.SetSecret(secret)
}
// GetDefaultCapManager returns the singleton CAPTCHA manager.
func GetDefaultCapManager() *CaptchaManager {
_, s, _ := getDefaultRuntime()
return s.CapManager
}
// CurrentCapSettings returns current CAPTCHA runtime settings.
func CurrentCapSettings(ctx context.Context) (CapRuntimeSettings, error) {
_, s, _ := getDefaultRuntime()
return s.CapSettings.Current(ctx)
}
// CapProtectionEnabled checks if CAPTCHA is enabled.
func CapProtectionEnabled(ctx context.Context) bool {
_, s, _ := getDefaultRuntime()
return s.CapSettings.CapProtectionEnabled(ctx)
}
// InvalidateCapRuntimeSettings invalidates runtime CAPTCHA settings cache.
func InvalidateCapRuntimeSettings() {
_, s, _ := getDefaultRuntime()
s.CapSettings.Invalidate()
}
// ResetCapRuntimeSettingsForTest clears test CAPTCHA settings.
func ResetCapRuntimeSettingsForTest() {
InvalidateCapRuntimeSettings()
}
// InstallCapTestRuntimeSettings installs a test snapshot.
func InstallCapTestRuntimeSettings(settings CapRuntimeSettings) func() {
_, s, _ := getDefaultRuntime()
return s.CapSettings.InstallTestSnapshot(settings)
}
// VerifyCaptchaMiddleware returns captcha verification middleware.
func VerifyCaptchaMiddleware(mgr *service.CaptchaManager, scope string) gin.HandlerFunc {
_, s, _ := getDefaultRuntime()
return controller.VerifyCaptchaMiddleware(mgr, s.CapSettings, scope)
}
// Challenge HTTP handler.
func Challenge(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.Captcha.Challenge(c)
}
// Redeem HTTP handler.
func Redeem(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.Captcha.Redeem(c)
}
// GetLoginSources HTTP handler.
func GetLoginSources(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.GetLoginSources(c)
}
// GetLoginURL HTTP handler.
func GetLoginURL(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.GetLoginURL(c)
}
// Authorize HTTP handler.
func Authorize(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.Authorize(c)
}
// Callback HTTP handler.
func Callback(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.Callback(c)
}
// Logout HTTP handler.
func Logout(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.Logout(c)
}
// UserInfo HTTP handler.
func UserInfo(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.UserInfo.UserInfo(c)
}
// ListExternalAccounts HTTP handler.
func ListExternalAccounts(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.ListExternalAccounts(c)
}
// DeleteExternalAccount HTTP handler.
func DeleteExternalAccount(c *gin.Context) {
_, _, ctrl := getDefaultRuntime()
ctrl.OAuth.DeleteExternalAccount(c)
}
// InvalidateOIDCProviderCache invalidates OIDC provider cache entry.
func InvalidateOIDCProviderCache(issuer string) {
_, s, _ := getDefaultRuntime()
s.OIDCProviderCache.Invalidate(issuer)
}
-572
View File
@@ -1,572 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core/contracts"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/idgen"
"Wavelet/pkg/logger"
"Wavelet/pkg/response"
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
// GetLoginSources 获取可用登录源列表
// @Summary 获取可用登录源
// @Description 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用
// @Tags oauth
// @Produce json
// @Success 200 {object} response.Any{data=[]auth.AuthSourceView} "登录源列表"
// @Router /api/v1/oauth/sources [get]
func GetLoginSources(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(activeLoginSources(c.Request.Context())))
}
// GetLoginURL 获取登录授权地址
// @Summary 获取登录授权地址
// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用第一个启用的认证源。
// @Tags oauth
// @Produce json
// @Param source query string false "认证源名称,为空使用第一个启用的认证源"
// @Success 200 {object} response.Any{data=auth.OAuthAuthorizeResponse} "授权 URL"
// @Failure 400 {object} response.Any "认证源不存在或未配置"
// @Failure 500 {object} response.Any "构造 URL 失败"
// @Router /api/v1/oauth/login [get]
func GetLoginURL(c *gin.Context) {
ctx := c.Request.Context()
if !isOIDCLoginEnabled(ctx) {
response.AbortBadRequest(c, errAuthSourceDisabled)
return
}
source, err := resolveAuthSource(ctx, c.Query("source"))
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.AbortBadRequest(c, errAuthSourceDisabled)
return
}
session := sessions.Default(c)
token, isNew := ensureSessionToken(session)
if isNew {
if err := session.Save(); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
userID := GetUserIDFromSession(session)
sessionHash := hashSessionToken(token)
if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
state := uuid.NewString()
payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{
SourceName: source.Name,
Purpose: OAuthPurposeLogin,
UserID: userID,
SessionHash: sessionHash,
})
if err != nil {
response.AbortInternal(c, err.Error())
return
}
stateKey := fmt.Sprintf(OAuthStateCacheKeyFormat, state)
if cache := getCache(ctx); cache != nil {
if err := cache.Set(ctx, stateKey, payloadValue, OAuthStateCacheKeyExpiration); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
func buildAuthorizeURL(ctx context.Context, source *AuthSource, state string) (string, error) {
redirectURL, err := getFrontendLoginRedirectURL(ctx)
if err != nil {
return "", err
}
authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return "", err
}
if verifier != nil {
return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil
}
return authConfig.AuthCodeURL(state), nil
}
func reserveOAuthStateSlot(ctx context.Context, sessionHash string) error {
if sessionHash == "" {
return nil
}
cache := getCache(ctx)
if cache == nil {
return nil
}
key := fmt.Sprintf(oauthStateLimitKeyFormat, sessionHash)
var count int
_ = cache.Get(ctx, key, &count)
count++
_ = cache.Set(ctx, key, count, OAuthStateCacheKeyExpiration)
if count > oauthStateLimitMax {
return errors.New(errOAuthStateRateLimited)
}
return nil
}
// Authorize 发起指定认证源授权
// @Summary 发起指定认证源授权
// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。
// @Tags oauth
// @Produce json
// @Param source path string true "认证源名称"
// @Param purpose query string false "授权目的:login(登录)或 bind(绑定账号),默认 login"
// @Success 200 {object} response.Any{data=auth.OAuthAuthorizeResponse} "授权 URL"
// @Failure 400 {object} response.Any "认证源不存在或未启用"
// @Failure 500 {object} response.Any "构造 URL 失败"
// @Router /api/v1/oauth/{source}/authorize [get]
func Authorize(c *gin.Context) {
ctx := c.Request.Context()
if !isOIDCLoginEnabled(ctx) {
response.AbortBadRequest(c, errAuthSourceDisabled)
return
}
source, err := resolveAuthSource(ctx, c.Param("source"))
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.AbortBadRequest(c, errAuthSourceDisabled)
return
}
purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose")))
if purpose != OAuthPurposeBind {
purpose = OAuthPurposeLogin
}
session := sessions.Default(c)
userID := GetUserIDFromSession(session)
if purpose == OAuthPurposeBind && userID == 0 {
response.AbortUnauthorized(c, errUnAuthorized)
return
}
token, isNew := ensureSessionToken(session)
if isNew {
if err := session.Save(); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
sessionHash := hashSessionToken(token)
if err := reserveOAuthStateSlot(ctx, sessionHash); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
state := uuid.NewString()
payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{
SourceName: source.Name,
Purpose: purpose,
UserID: userID,
SessionHash: sessionHash,
})
if err != nil {
response.AbortInternal(c, err.Error())
return
}
stateKey := fmt.Sprintf(OAuthStateCacheKeyFormat, state)
if cache := getCache(ctx); cache != nil {
if err := cache.Set(ctx, stateKey, payloadValue, OAuthStateCacheKeyExpiration); err != nil {
response.AbortInternal(c, err.Error())
return
}
}
authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL}))
}
// Callback OAuth 回调处理
// @Summary OAuth 回调处理
// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。
// @Tags oauth
// @Accept json
// @Produce json
// @Param request body auth.CallbackRequest true "回调请求参数"
// @Success 200 {object} response.Any{data=auth.OAuthCallbackResult} "登录或绑定成功"
// @Failure 400 {object} response.Any "state 无效、参数错误或认证源错误"
// @Failure 401 {object} response.Any "绑定场景未登录"
// @Failure 500 {object} response.Any "OAuth 认证失败或内部错误"
// @Router /api/v1/oauth/callback [post]
func Callback(c *gin.Context) {
var req CallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
ctx := c.Request.Context()
stateKey := fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)
var payloadRaw string
cache := getCache(ctx)
if cache == nil {
response.AbortBadRequest(c, errInvalidState)
return
}
if err := cache.Get(ctx, stateKey, &payloadRaw); err != nil {
response.AbortBadRequest(c, errInvalidState)
return
}
_ = cache.Delete(ctx, stateKey)
payload, err := decodeOAuthStatePayload(payloadRaw)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
session := sessions.Default(c)
currentUserID := GetUserIDFromSession(session)
if payload.Purpose == OAuthPurposeBind && currentUserID == 0 {
response.AbortUnauthorized(c, errUnAuthorized)
return
}
token, ok := session.Get(SessionTokenKey).(string)
if !ok || token == "" {
response.AbortBadRequest(c, errInvalidSessionContext)
return
}
if hashSessionToken(token) != payload.SessionHash {
response.AbortBadRequest(c, errSessionMismatchForOAuth)
return
}
if payload.Purpose == OAuthPurposeBind && currentUserID != payload.UserID {
response.AbortBadRequest(c, errUserContextMismatch)
return
}
if !isOIDCLoginEnabled(ctx) {
response.AbortBadRequest(c, errAuthSourceDisabled)
return
}
source, err := resolveAuthSource(ctx, payload.SourceName)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if !source.IsActive {
response.AbortBadRequest(c, errAuthSourceDisabled)
return
}
redirectURL, err := getFrontendLoginRedirectURL(ctx)
if err != nil {
response.AbortBadRequest(c, err.Error())
return
}
userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, redirectURL)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
if err := normalizeOAuthUserInfo(userInfo); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
if userInfo.Sub == "" {
userInfo.Sub = userInfo.Username
}
if payload.Purpose == OAuthPurposeBind {
handleCallbackBind(ctx, c, source, userInfo)
return
}
handleCallbackLogin(ctx, c, source, userInfo)
}
func handleCallbackBind(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
userID := GetUserIDFromContext(c)
if userID == 0 {
response.AbortUnauthorized(c, errUnAuthorized)
return
}
user, err := GetUserByID(ctx, userID)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
if err := BindExternalAccount(ctx, &ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: userInfo.Sub,
ExternalUsername: userInfo.Username,
Email: userInfo.Email,
}); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
user.LastLoginAt = time.Now()
_ = TouchUserLastLogin(ctx, user.ID, user.LastLoginAt)
c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "bound")))
}
func handleCallbackLogin(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) {
var user *contracts.UserDTO
account, err := FindExternalAccount(ctx, source.ID, userInfo.Sub)
switch {
case err == nil:
loaded, loadErr := GetUserByID(ctx, account.UserID)
if loadErr != nil {
response.AbortInternal(c, loadErr.Error())
return
}
user = loaded
case errors.Is(err, gorm.ErrRecordNotFound):
newUser, ok := handleCallbackRegister(ctx, c, source, userInfo)
if !ok {
return
}
user = &newUser
default:
response.AbortInternal(c, err.Error())
return
}
user.LastLoginAt = time.Now()
_ = TouchUserLastLogin(ctx, user.ID, user.LastLoginAt)
if err := SetLoginSession(ctx, c, user); err != nil {
response.AbortInternal(c, err.Error())
return
}
SetCachedUser(ctx, user.ID, user)
c.JSON(http.StatusOK, response.OK(buildCallbackResult(user, "logged_in")))
}
func uniqueUsername(ctx context.Context, base string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
base = "user"
}
existingUsernames, err := ListSimilarUsernames(ctx, base)
if err != nil {
return "", err
}
exists := make(map[string]bool, len(existingUsernames))
for _, u := range existingUsernames {
exists[strings.ToLower(u)] = true
}
if !exists[strings.ToLower(base)] {
return base, nil
}
for i := 1; i <= 1000; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if !exists[strings.ToLower(candidate)] {
return candidate, nil
}
}
return "", errors.New(errUsernameGenerateFailed)
}
func handleCallbackRegister(ctx context.Context, c *gin.Context, source *AuthSource, userInfo *contracts.OAuthUserInfoDTO) (contracts.UserDTO, bool) {
registrationEnabled := true
val, cfgErr := GetSystemConfigValue(ctx, "registration_enabled")
if cfgErr == nil && val != "" {
if b, err := strconv.ParseBool(val); err == nil {
registrationEnabled = b
}
}
if !registrationEnabled {
c.JSON(http.StatusOK, response.OK(buildCallbackResult(nil, "need_bind")))
return contracts.UserDTO{}, false
}
username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
if uniqueErr != nil {
response.AbortInternal(c, uniqueErr.Error())
return contracts.UserDTO{}, false
}
userInfo.Username = username
now := time.Now()
user := contracts.UserDTO{
ID: idgen.NextUint64ID(),
Username: userInfo.Username,
Nickname: userInfo.Name,
Email: userInfo.Email,
AvatarURL: userInfo.AvatarURL,
IsActive: userInfo.Active,
LastLoginAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := InsertUser(ctx, &user); err != nil {
response.AbortInternal(c, err.Error())
return contracts.UserDTO{}, false
}
if err := BindExternalAccount(ctx, &ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: userInfo.Sub,
ExternalUsername: userInfo.Username,
Email: userInfo.Email,
}); err != nil {
response.AbortBadRequest(c, err.Error())
return contracts.UserDTO{}, false
}
logger.InfoF(ctx, "[LoginAudit] successful OAuth registration via source: %s, external ID: %s, user: %s, ID: %d, IP: %s", source.Name, userInfo.Sub, user.Username, user.ID, c.ClientIP())
return user, true
}
// UserInfo 获取当前登录用户信息
// @Summary 获取当前登录用户信息
// @Description 返回当前登录用户的基本信息,需要登录。
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=auth.BasicUserInfo} "用户信息"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/oauth/user-info [get]
// @Router /api/v1/user-info [get]
func UserInfo(c *gin.Context) {
user, _ := ginutil.GetFromContext[*contracts.UserDTO](c, contracts.AuthUserObjKey)
session := sessions.Default(c)
needChange := session.Get("need_change_password") == true || (user != nil && user.NeedChangePassword)
c.JSON(
http.StatusOK,
response.OK(BuildBasicUserInfo(user, needChange)),
)
}
// Logout 退出登录
// @Summary 退出登录
// @Description 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=string} "退出成功"
// @Failure 500 {object} response.Any "Session 清除失败"
// @Router /api/v1/oauth/logout [get]
func Logout(c *gin.Context) {
session := sessions.Default(c)
userID := session.Get(UserIDKey)
username := session.Get(UserNameKey)
if userID != nil {
logger.InfoF(c.Request.Context(), "[LoginAudit] user logged out: %v, ID: %v, IP: %s", username, userID, c.ClientIP())
if id := ParseUserID(userID); id > 0 {
InvalidateCachedUser(c.Request.Context(), id)
}
}
session.Options(GetSessionOptions(-1))
session.Clear()
if err := session.Save(); err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
// @Summary 获取外部帐号列表
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any "外部帐号列表"
// @Failure 401 {object} response.Any "未登录"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/oauth/external-accounts [get]
func ListExternalAccounts(c *gin.Context) {
userID := GetUserIDFromContext(c)
accounts, err := ListExternalAccountsByUserID(c.Request.Context(), userID)
if err != nil {
response.AbortInternal(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OK(accounts))
}
// DeleteExternalAccount 解除外部帐号绑定
// @Summary 解除外部帐号绑定
// @Description 解除当前登录用户与指定外部帐号的绑定关系,需要登录
// @Tags oauth
// @Produce json
// @Security SessionCookie
// @Param id path uint64 true "外部帐号绑定记录 ID"
// @Success 200 {object} response.Any{data=string} "解除绑定成功"
// @Failure 400 {object} response.Any "ID 无效或解除失败"
// @Failure 401 {object} response.Any "未登录"
// @Router /api/v1/oauth/external-accounts/{id}/delete [post]
func DeleteExternalAccount(c *gin.Context) {
userID := GetUserIDFromContext(c)
if userID == 0 {
response.AbortUnauthorized(c, errUnAuthorized)
return
}
rawID := strings.TrimSpace(c.Param("id"))
id, err := strconv.ParseUint(rawID, 10, 64)
if err != nil || id == 0 {
response.AbortBadRequest(c, errInvalidExternalAccountBindingID)
return
}
if err := UnbindExternalAccount(c.Request.Context(), id, userID); err != nil {
response.AbortBadRequest(c, err.Error())
return
}
c.JSON(http.StatusOK, response.OKNil())
}
-195
View File
@@ -1,195 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/pkg/ginutil"
"Wavelet/pkg/response"
"Wavelet/pkg/trace"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"github.com/gin-gonic/gin"
)
// whitelist holds the no-auth route patterns. They are registered during Apply and
// matched on every request, so PathWhitelist parses them once up front.
var whitelist = extpoints.NewPathWhitelist()
// RegisterWhitelist registers route patterns that bypass mandatory authentication.
func RegisterWhitelist(patterns ...string) {
whitelist.Add(patterns...)
}
// IsWhitelisted checks if the specified path matches the auth whitelist.
func IsWhitelisted(path string) bool {
return whitelist.Match(path)
}
func hashToken(token string) string {
h := sha256.New()
h.Write([]byte(token))
return hex.EncodeToString(h.Sum(nil))
}
// currentUserIDFromRequestContext 是接入层向 Service 层暴露的登录态桥接。
//
// Session 读取必须依赖 *gin.Context,而 Service 层禁止 import gin,
// 因此该类型断言收敛在本(接入层)文件中。ok 为 false 表示 ctx 不是 *gin.Context。
func currentUserIDFromRequestContext(ctx context.Context) (uint64, bool) {
ginCtx, ok := ctx.(*gin.Context)
if !ok {
return 0, false
}
return GetUserIDFromContext(ginCtx), true
}
func getUserByToken(ctx context.Context, tokenStr string) (*contracts.UserDTO, *CachedToken, error) {
tokenHash := hashToken(tokenStr)
tokenRecord, err := GetCachedToken(ctx, tokenHash)
if err != nil || tokenRecord == nil {
tokenRecord, err = GetAccessTokenByHash(ctx, tokenHash)
if err != nil {
return nil, nil, err
}
SetCachedToken(ctx, tokenHash, tokenRecord)
}
user, err := GetCachedUser(ctx, tokenRecord.UserID)
if err != nil || user == nil || !user.IsActive {
user, err = GetActiveUserByID(ctx, tokenRecord.UserID)
if err != nil {
return nil, nil, err
}
SetCachedUser(ctx, tokenRecord.UserID, user)
}
return user, tokenRecord, nil
}
// GetUserFromRequest 从请求中获取当前用户(优先 Access Token,其次 Session)
func GetUserFromRequest(c *gin.Context) (*contracts.UserDTO, error) {
ctx := c.Request.Context()
var tokenStr string
tokenFromQuery := c.Query("token")
if tokenFromQuery != "" {
tokenStr = tokenFromQuery
} else {
authHeader := c.GetHeader("Authorization")
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
tokenStr = authHeader[7:]
}
}
// 优先使用 Access Token 鉴权
if tokenStr != "" {
if user, tokenRecord, err := getUserByToken(ctx, tokenStr); err == nil {
if user.Username == SystemUsername {
return nil, errors.New(errSystemUserLoginNotAllowed)
}
ginutil.SetToContext(c, contracts.AuthTokenAuthKey, true)
ginutil.SetToContext(c, contracts.AuthTokenAdminKey, tokenRecord.IsAdmin)
return user, nil
}
}
// 降级使用 Session 鉴权
userID := GetUserIDFromContext(c)
if userID <= 0 {
return nil, errors.New(errUnauthorizedInternal)
}
user, err := GetCachedUser(ctx, userID)
if err != nil || user == nil || !user.IsActive {
user, err = GetActiveUserByID(ctx, userID)
if err != nil {
return nil, err
}
SetCachedUser(ctx, userID, user)
}
ginutil.SetToContext(c, contracts.AuthTokenAuthKey, false)
ginutil.SetToContext(c, contracts.AuthTokenAdminKey, false)
if user.Username == "system" {
return nil, errors.New(errSystemUserLoginNotAllowed)
}
return user, nil
}
// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session
func LoginRequired() gin.HandlerFunc {
return func(c *gin.Context) {
if IsWhitelisted(c.Request.URL.Path) {
c.Next()
return
}
_, span := trace.Start(c.Request.Context(), "LoginRequired")
defer span.End()
user, err := GetUserFromRequest(c)
if err != nil {
response.AbortUnauthorized(c, errUnAuthorized)
return
}
LogForAudit(c.Request.Context(), user, c)
ginutil.SetToContext(c, contracts.AuthUserObjKey, user)
c.Next()
}
}
// AdminRequired 校验管理员权限(支持 Session 和 Token 鉴权)
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
_, span := trace.Start(c.Request.Context(), "AdminRequired")
defer span.End()
user, err := GetUserFromRequest(c)
if err != nil {
response.AbortUnauthorized(c, errUnAuthorized)
return
}
isTokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey)
isTokenAdmin, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAdminKey)
// Logged-in but lacking admin permission is 403, not 401/404.
if isTokenAuth && !isTokenAdmin && !user.IsAdmin {
response.AbortForbidden(c, errInsufficientPermission)
return
}
if !isTokenAuth && !user.IsAdmin {
response.AbortForbidden(c, errInsufficientPermission)
return
}
LogForAudit(c.Request.Context(), user, c)
ginutil.SetToContext(c, contracts.AuthUserObjKey, user)
c.Next()
}
}
// LoginAdminRequired is an alias for AdminRequired.
func LoginAdminRequired() gin.HandlerFunc {
return AdminRequired()
}
// DisallowTokenAuth 拒绝使用 Access Token 进行身份验证的请求访问该端点
func DisallowTokenAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if tokenAuth, _ := ginutil.GetFromContext[bool](c, contracts.AuthTokenAuthKey); tokenAuth {
response.AbortForbidden(c, ErrTokenAuthNotAllowed)
return
}
c.Next()
}
}
@@ -0,0 +1,12 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package do provides domain data objects for the auth plugin.
package do
// CachedToken represents the minimal cached representation of an access token.
type CachedToken struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
IsAdmin bool `json:"is_admin"`
}
@@ -0,0 +1,75 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package do provides domain data objects for the auth plugin.
package do
import (
"Wavelet/plugins/domain/auth/consts"
"strconv"
"time"
)
// CapRuntimeSettings is the parsed CAPTCHA runtime configuration loaded from system_configs.
type CapRuntimeSettings struct {
LoginEnabled bool
ChallengeCount int
ChallengeSize int
ChallengeDifficulty int
ChallengeTTL time.Duration
TokenTTL time.Duration
}
// CapConfigRecord maps the columns selected from the system config table.
type CapConfigRecord struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
// ParseCapRuntimeSettings parses system config key-value map into CapRuntimeSettings with fallback defaults.
func ParseCapRuntimeSettings(configs map[string]string) CapRuntimeSettings {
settings := CapRuntimeSettings{
ChallengeCount: consts.DefaultCapChallengeCount,
ChallengeSize: consts.DefaultCapChallengeSize,
ChallengeDifficulty: consts.DefaultCapChallengeDifficulty,
ChallengeTTL: consts.DefaultCapChallengeTTL,
TokenTTL: consts.DefaultCapTokenTTL,
}
if len(configs) == 0 {
return settings
}
if val, ok := configs[consts.ConfigKeyCapLoginEnabled]; ok {
if enabled, err := strconv.ParseBool(val); err == nil {
settings.LoginEnabled = enabled
}
}
if val, ok := configs[consts.ConfigKeyCapChallengeCount]; ok {
if count, err := strconv.Atoi(val); err == nil && count > 0 {
settings.ChallengeCount = count
}
}
if val, ok := configs[consts.ConfigKeyCapChallengeSize]; ok {
if size, err := strconv.Atoi(val); err == nil && size > 0 {
settings.ChallengeSize = size
}
}
if val, ok := configs[consts.ConfigKeyCapChallengeDifficulty]; ok {
if diff, err := strconv.Atoi(val); err == nil && diff > 0 {
settings.ChallengeDifficulty = diff
}
}
if val, ok := configs[consts.ConfigKeyCapChallengeTTL]; ok {
if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 {
settings.ChallengeTTL = time.Duration(ttlSeconds) * time.Second
}
}
if val, ok := configs[consts.ConfigKeyCapTokenTTL]; ok {
if ttlSeconds, err := strconv.Atoi(val); err == nil && ttlSeconds > 0 {
settings.TokenTTL = time.Duration(ttlSeconds) * time.Second
}
}
return settings
}
@@ -0,0 +1,43 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package do_test
import (
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/model/do"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestParseCapRuntimeSettings(t *testing.T) {
t.Run("Default fallback on empty config", func(t *testing.T) {
settings := do.ParseCapRuntimeSettings(nil)
assert.False(t, settings.LoginEnabled)
assert.Equal(t, consts.DefaultCapChallengeCount, settings.ChallengeCount)
assert.Equal(t, consts.DefaultCapChallengeSize, settings.ChallengeSize)
assert.Equal(t, consts.DefaultCapChallengeDifficulty, settings.ChallengeDifficulty)
assert.Equal(t, consts.DefaultCapChallengeTTL, settings.ChallengeTTL)
assert.Equal(t, consts.DefaultCapTokenTTL, settings.TokenTTL)
})
t.Run("Parsed custom configs", func(t *testing.T) {
configs := map[string]string{
consts.ConfigKeyCapLoginEnabled: "true",
consts.ConfigKeyCapChallengeCount: "3",
consts.ConfigKeyCapChallengeSize: "64",
consts.ConfigKeyCapChallengeDifficulty: "5",
consts.ConfigKeyCapChallengeTTL: "300",
consts.ConfigKeyCapTokenTTL: "600",
}
settings := do.ParseCapRuntimeSettings(configs)
assert.True(t, settings.LoginEnabled)
assert.Equal(t, 3, settings.ChallengeCount)
assert.Equal(t, 64, settings.ChallengeSize)
assert.Equal(t, 5, settings.ChallengeDifficulty)
assert.Equal(t, 300*time.Second, settings.ChallengeTTL)
assert.Equal(t, 600*time.Second, settings.TokenTTL)
})
}
@@ -0,0 +1,33 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package do provides domain data objects for the auth plugin.
package do
import "encoding/json"
// OAuthStatePayload represents the cached state verification payload for OAuth flow.
type OAuthStatePayload struct {
SourceName string `json:"source_name"`
Purpose string `json:"purpose"`
UserID uint64 `json:"user_id,omitempty"`
SessionHash string `json:"session_hash"`
}
// Encode converts OAuthStatePayload to a JSON string.
func (p OAuthStatePayload) Encode() (string, error) {
data, err := json.Marshal(p)
if err != nil {
return "", err
}
return string(data), nil
}
// DecodeOAuthStatePayload parses a JSON string into OAuthStatePayload.
func DecodeOAuthStatePayload(value string) (OAuthStatePayload, error) {
var payload OAuthStatePayload
if err := json.Unmarshal([]byte(value), &payload); err != nil {
return OAuthStatePayload{}, err
}
return payload, nil
}
@@ -0,0 +1,32 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package do_test
import (
"Wavelet/plugins/domain/auth/model/do"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOAuthStatePayload(t *testing.T) {
payload := do.OAuthStatePayload{
SourceName: "github",
Purpose: "login",
UserID: 12345,
SessionHash: "hash-abc-123",
}
encoded, err := payload.Encode()
require.NoError(t, err)
assert.NotEmpty(t, encoded)
decoded, err := do.DecodeOAuthStatePayload(encoded)
require.NoError(t, err)
assert.Equal(t, payload, decoded)
_, err = do.DecodeOAuthStatePayload("invalid-json")
assert.Error(t, err)
}
@@ -0,0 +1,45 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package dto provides data transfer objects and views for the auth plugin.
package dto
// AuthSourceView 登录源展示信息
type AuthSourceView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IconURL string `json:"icon_url"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
// OAuthAuthorizeResponse 授权 URL 响应
type OAuthAuthorizeResponse struct {
AuthorizeURL string `json:"authorize_url"`
}
// OAuthCallbackResult 回调处理结果
type OAuthCallbackResult struct {
Status string `json:"status"`
User *BasicUserInfo `json:"user,omitempty"`
}
// CallbackRequest OAuth 回调请求参数
type CallbackRequest struct {
State string `json:"state" binding:"required"`
Code string `json:"code" binding:"required"`
}
// ExternalAccountView 外部帐号绑定视图(脱敏展示用)
type ExternalAccountView struct {
ID uint64 `json:"id"`
AuthSourceID uint64 `json:"auth_source_id"`
AuthSourceName string `json:"auth_source_name"`
AuthSourceType string `json:"auth_source_type"`
AuthSourceLabel string `json:"auth_source_label"`
ExternalUsername string `json:"external_username"`
Email string `json:"email"`
CreatedAt string `json:"created_at"`
}
@@ -1,22 +1,23 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cap
// Package dto provides data transfer objects and views for the auth plugin.
package dto
import (
"Wavelet/plugins/domain/cap/pow"
"Wavelet/plugins/domain/auth/pow"
)
// ChallengeResponse is a local type alias for the pow.ChallengeResponse struct
type ChallengeResponse = pow.ChallengeResponse
// challengeRequest is the CAPTCHA challenge request payload.
type challengeRequest struct {
// ChallengeRequest is the CAPTCHA challenge request payload.
type ChallengeRequest struct {
Scope string `json:"scope" form:"scope"`
}
// redeemRequest is the CAPTCHA redeem request payload.
type redeemRequest struct {
// RedeemRequest is the CAPTCHA redeem request payload.
type RedeemRequest struct {
Token string `json:"token" binding:"required"`
Solutions []int `json:"solutions" binding:"required"`
Scope string `json:"scope" form:"scope"`
@@ -29,9 +30,3 @@ type RedeemResponse struct {
Expires int64 `json:"expires,omitempty"`
Error string `json:"error,omitempty"`
}
// configRecord maps the columns selected from the system config table.
type configRecord struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
@@ -0,0 +1,84 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package dto provides data transfer objects and views for the auth plugin.
package dto
import (
"Wavelet/core/contracts"
"strconv"
)
// BasicUserInfo 用户基本信息结构体
type BasicUserInfo struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
}
// BuildBasicUserInfo 将 UserDTO 转换为 BasicUserInfo
func BuildBasicUserInfo(user *contracts.UserDTO, needChange bool) BasicUserInfo {
if user == nil {
return BasicUserInfo{}
}
return BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
AvatarURL: user.AvatarURL,
IsAdmin: user.IsAdmin,
NeedChangePassword: needChange || user.NeedChangePassword,
Bio: user.Bio,
Phone: user.Phone,
Gender: user.Gender,
Website: user.Website,
Location: user.Location,
}
}
// LoginRequiredAuditLog 审计日志结构体
type LoginRequiredAuditLog struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
Path string `json:"path"`
RequestURI string `json:"request_uri"`
UserAgent string `json:"user_agent"`
Referer string `json:"referer"`
}
// ParseUserID parses a string, int, or float64 user ID representation.
func ParseUserID(v any) uint64 {
switch val := v.(type) {
case uint64:
return val
case int64:
if val > 0 {
return uint64(val)
}
case int:
if val > 0 {
return uint64(val)
}
case float64:
if val > 0 {
return uint64(val)
}
case string:
if id, err := strconv.ParseUint(val, 10, 64); err == nil {
return id
}
}
return 0
}
@@ -0,0 +1,83 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package entity provides database model entities for the auth domain plugin.
package entity
import (
"Wavelet/plugins/domain/auth/consts"
"errors"
"regexp"
"strings"
"time"
)
var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`)
// AuthSource 认证源实体
type AuthSource struct {
ID uint64 `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"`
Type string `json:"type" gorm:"size:20;not null"`
DisplayName string `json:"display_name" gorm:"size:100"`
IsActive bool `json:"is_active" gorm:"index;not null;default:false"`
ClientID string `json:"client_id" gorm:"size:255"`
ClientSecret string `json:"-" gorm:"size:1024"`
OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"`
Scopes string `json:"scopes" gorm:"size:255"`
IconURL string `json:"icon_url" gorm:"size:1024"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"`
}
// TableName 表名
func (AuthSource) TableName() string {
return "w_auth_sources"
}
// Normalize 对认证源字段进行标准化处理
func (source *AuthSource) Normalize() {
source.Type = strings.ToLower(strings.TrimSpace(source.Type))
source.Name = strings.TrimSpace(source.Name)
source.DisplayName = strings.TrimSpace(source.DisplayName)
source.ClientID = strings.TrimSpace(source.ClientID)
source.ClientSecret = strings.TrimSpace(source.ClientSecret)
source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL)
source.Scopes = strings.TrimSpace(source.Scopes)
source.IconURL = strings.TrimSpace(source.IconURL)
if source.DisplayName == "" {
source.DisplayName = source.Name
}
if source.Type == consts.AuthSourceTypeOIDC && source.Scopes == "" {
source.Scopes = "openid profile email"
}
}
// Validate 校验认证源字段合法性
func (source *AuthSource) Validate() error {
source.Normalize()
if source.Name == "" {
return errors.New(consts.ErrAuthSourceNameRequired)
}
if !authSourceNamePattern.MatchString(source.Name) {
return errors.New(consts.ErrAuthSourceNameInvalid)
}
if source.Type != consts.AuthSourceTypeOIDC {
return errors.New(consts.ErrAuthSourceTypeUnsupported)
}
if source.OpenIDDiscoveryURL == "" {
//nolint:staticcheck // descriptive error constant
return errors.New(consts.ErrAuthSourceDiscoveryURLRequired)
}
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
return errors.New(consts.ErrAuthSourceClientCredentialsRequired)
}
return nil
}
// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志
func (source *AuthSource) Sanitize() {
source.ClientSecretConfigured = source.ClientSecret != ""
source.ClientSecret = ""
}
@@ -0,0 +1,75 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package entity_test
import (
"Wavelet/plugins/domain/auth/model/entity"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthSourceValidation(t *testing.T) {
t.Run("Valid OIDC Source", func(t *testing.T) {
src := entity.AuthSource{
Name: "google",
Type: "oidc",
DisplayName: "Google Sign-In",
ClientID: "client-123",
ClientSecret: "secret-456",
OpenIDDiscoveryURL: "https://accounts.google.com",
IsActive: true,
}
require.NoError(t, src.Validate())
assert.Equal(t, "openid profile email", src.Scopes)
assert.Equal(t, "w_auth_sources", src.TableName())
src.Sanitize()
assert.True(t, src.ClientSecretConfigured)
assert.Empty(t, src.ClientSecret)
})
t.Run("Empty Name Fails", func(t *testing.T) {
src := entity.AuthSource{
Name: "",
Type: "oidc",
}
assert.Error(t, src.Validate())
})
t.Run("Invalid Name Format Fails", func(t *testing.T) {
src := entity.AuthSource{
Name: "invalid name with spaces!",
Type: "oidc",
}
assert.Error(t, src.Validate())
})
t.Run("Unsupported Type Fails", func(t *testing.T) {
src := entity.AuthSource{
Name: "ldap_source",
Type: "ldap",
}
assert.Error(t, src.Validate())
})
t.Run("Missing Discovery URL Fails", func(t *testing.T) {
src := entity.AuthSource{
Name: "google",
Type: "oidc",
}
assert.Error(t, src.Validate())
})
t.Run("Active Source Missing Credentials Fails", func(t *testing.T) {
src := entity.AuthSource{
Name: "google",
Type: "oidc",
OpenIDDiscoveryURL: "https://accounts.google.com",
IsActive: true,
}
assert.Error(t, src.Validate())
})
}
@@ -0,0 +1,26 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package entity provides database model entities for the auth domain plugin.
package entity
import (
"time"
)
// ExternalAccount 外部账号绑定实体
type ExternalAccount struct {
ID uint64 `json:"id" gorm:"primaryKey"`
AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"`
UserID uint64 `json:"user_id" gorm:"index;not null"`
ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"`
ExternalUsername string `json:"external_username" gorm:"size:255"`
Email string `json:"email" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (ExternalAccount) TableName() string {
return "w_external_accounts"
}
-241
View File
@@ -1,241 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core/contracts"
"encoding/json"
"errors"
"regexp"
"strconv"
"strings"
"time"
)
var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`)
// AuthSource 认证源实体
//
//nolint:revive // auth.AuthSource is standard domain entity name
type AuthSource struct {
ID uint64 `json:"id" gorm:"primaryKey"`
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"`
Type string `json:"type" gorm:"size:20;not null"`
DisplayName string `json:"display_name" gorm:"size:100"`
IsActive bool `json:"is_active" gorm:"index;not null;default:false"`
ClientID string `json:"client_id" gorm:"size:255"`
ClientSecret string `json:"-" gorm:"size:1024"`
OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"`
Scopes string `json:"scopes" gorm:"size:255"`
IconURL string `json:"icon_url" gorm:"size:1024"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"`
}
// TableName 表名
func (AuthSource) TableName() string {
return "w_auth_sources"
}
// Normalize 对认证源字段进行标准化处理
func (source *AuthSource) Normalize() {
source.Type = strings.ToLower(strings.TrimSpace(source.Type))
source.Name = strings.TrimSpace(source.Name)
source.DisplayName = strings.TrimSpace(source.DisplayName)
source.ClientID = strings.TrimSpace(source.ClientID)
source.ClientSecret = strings.TrimSpace(source.ClientSecret)
source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL)
source.Scopes = strings.TrimSpace(source.Scopes)
source.IconURL = strings.TrimSpace(source.IconURL)
if source.DisplayName == "" {
source.DisplayName = source.Name
}
if source.Type == AuthSourceTypeOIDC && source.Scopes == "" {
source.Scopes = "openid profile email"
}
}
// Validate 校验认证源字段合法性
func (source *AuthSource) Validate() error {
source.Normalize()
if source.Name == "" {
return errors.New(errAuthSourceNameRequired)
}
if !authSourceNamePattern.MatchString(source.Name) {
return errors.New(errAuthSourceNameInvalid)
}
if source.Type != AuthSourceTypeOIDC {
return errors.New(errAuthSourceTypeUnsupported)
}
if source.OpenIDDiscoveryURL == "" {
//nolint:staticcheck // descriptive error constant
return errors.New(errAuthSourceDiscoveryURLRequired)
}
if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") {
return errors.New(errAuthSourceClientCredentialsRequired)
}
return nil
}
// Sanitize 脱敏处理,将 ClientSecret 清空并设置 ClientSecretConfigured 标志
func (source *AuthSource) Sanitize() {
source.ClientSecretConfigured = source.ClientSecret != ""
source.ClientSecret = ""
}
// ExternalAccount 外部账号绑定实体
type ExternalAccount struct {
ID uint64 `json:"id" gorm:"primaryKey"`
AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"`
UserID uint64 `json:"user_id" gorm:"index;not null"`
ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"`
ExternalUsername string `json:"external_username" gorm:"size:255"`
Email string `json:"email" gorm:"size:255"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (ExternalAccount) TableName() string {
return "w_external_accounts"
}
// ExternalAccountView 外部帐号绑定视图(脱敏展示用)
type ExternalAccountView struct {
ID uint64 `json:"id"`
AuthSourceID uint64 `json:"auth_source_id"`
AuthSourceName string `json:"auth_source_name"`
AuthSourceType string `json:"auth_source_type"`
AuthSourceLabel string `json:"auth_source_label"`
ExternalUsername string `json:"external_username"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
// AuthSourceView 登录源展示信息
//
//nolint:revive // auth.AuthSourceView is standard domain presentation struct
type AuthSourceView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
DisplayName string `json:"display_name"`
IsActive bool `json:"is_active"`
IconURL string `json:"icon_url"`
ClientSecretConfigured bool `json:"client_secret_configured"`
}
// OAuthAuthorizeResponse 授权 URL 响应
type OAuthAuthorizeResponse struct {
AuthorizeURL string `json:"authorize_url"`
}
// OAuthCallbackResult 回调处理结果
type OAuthCallbackResult struct {
Status string `json:"status"`
User *BasicUserInfo `json:"user,omitempty"`
}
// CallbackRequest OAuth 回调请求参数
type CallbackRequest struct {
State string `json:"state" binding:"required"`
Code string `json:"code" binding:"required"`
}
// BasicUserInfo 用户基本信息结构体
type BasicUserInfo struct {
ID uint64 `json:"id,string"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password"`
Bio string `json:"bio"`
Phone string `json:"phone"`
Gender string `json:"gender"`
Website string `json:"website"`
Location string `json:"location"`
}
// BuildBasicUserInfo 将 UserDTO 转换为 BasicUserInfo
func BuildBasicUserInfo(user *contracts.UserDTO, needChange bool) BasicUserInfo {
if user == nil {
return BasicUserInfo{}
}
return BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
AvatarURL: user.AvatarURL,
IsAdmin: user.IsAdmin,
NeedChangePassword: needChange || user.NeedChangePassword,
Bio: user.Bio,
Phone: user.Phone,
Gender: user.Gender,
Website: user.Website,
Location: user.Location,
}
}
type oauthStatePayload struct {
SourceName string `json:"source_name"`
Purpose string `json:"purpose"`
UserID uint64 `json:"user_id,omitempty"`
SessionHash string `json:"session_hash"`
}
func encodeOAuthStatePayload(payload oauthStatePayload) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(data), nil
}
func decodeOAuthStatePayload(value string) (oauthStatePayload, error) {
var payload oauthStatePayload
if err := json.Unmarshal([]byte(value), &payload); err != nil {
return oauthStatePayload{}, err
}
return payload, nil
}
type loginRequiredAuditLog struct {
UserID uint64 `json:"user_id"`
Username string `json:"username"`
ClientIP string `json:"client_ip"`
Method string `json:"method"`
Path string `json:"path"`
RequestURI string `json:"request_uri"`
UserAgent string `json:"user_agent"`
Referer string `json:"referer"`
}
// ParseUserID parses a string or float64 user ID representation.
func ParseUserID(v any) uint64 {
switch val := v.(type) {
case uint64:
return val
case int64:
if val > 0 {
return uint64(val)
}
case int:
if val > 0 {
return uint64(val)
}
case float64:
if val > 0 {
return uint64(val)
}
case string:
if id, err := strconv.ParseUint(val, 10, 64); err == nil {
return id
}
}
return 0
}
@@ -0,0 +1,108 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth_test
import (
"Wavelet/core"
"Wavelet/pkg/response"
"Wavelet/plugins/domain/auth"
"Wavelet/plugins/infra/cache_memory"
database "Wavelet/plugins/infra/database"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOAuthRateLimiting(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx := core.NewContext(context.Background())
ctx.Config().SetSource(core.NewMapSource(nil))
require.NoError(t, ctx.Config().Resolve())
testDB := setupTestDB(t)
require.NoError(t, database.New(database.WithDB(testDB)).Apply(ctx))
require.NoError(t, cache_memory.New().Apply(ctx))
require.NoError(t, auth.New().Apply(ctx))
// Create an active OIDC source
authSrc := auth.AuthSource{
ID: 1,
Name: "google",
Type: "oidc",
DisplayName: "Google",
ClientID: "client-id-123",
ClientSecret: "client-secret-456",
OpenIDDiscoveryURL: "https://accounts.google.com",
IsActive: true,
}
require.NoError(t, testDB.Create(&authSrc).Error)
router := gin.New()
router.Use(response.ErrorHandlerMiddleware())
store := cookie.NewStore([]byte("test-session-secret-123"))
router.Use(sessions.Sessions("wavelet_session_id", store))
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(core.WithAppContext(c.Request.Context(), ctx.Root()))
c.Next()
})
for _, rd := range ctx.Router().Routes() {
handlers := make([]gin.HandlerFunc, 0, len(rd.Middlewares)+len(rd.Handlers))
for _, m := range rd.Middlewares {
if h, ok := m.(gin.HandlerFunc); ok {
handlers = append(handlers, h)
} else if fn, ok := m.(func(*gin.Context)); ok {
handlers = append(handlers, fn)
}
}
for _, raw := range rd.Handlers {
if h, ok := raw.(gin.HandlerFunc); ok {
handlers = append(handlers, h)
} else if fn, ok := raw.(func(*gin.Context)); ok {
handlers = append(handlers, fn)
}
}
router.Handle(rd.Method, rd.Path, handlers...)
}
// 10 state slots are allowed per session (oauthStateLimitMax = 10)
// We'll simulate 10 requests with the same cookie
var cookies []*http.Cookie
for i := 1; i <= 10; i++ {
req := httptest.NewRequest(http.MethodGet, "/api/v1/oauth/login?source=google", nil)
for _, ck := range cookies {
req.AddCookie(ck)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if len(w.Result().Cookies()) > 0 {
cookies = w.Result().Cookies()
}
}
// 11th request for the same session should be rate limited
{
req := httptest.NewRequest(http.MethodGet, "/api/v1/oauth/login?source=google", nil)
for _, ck := range cookies {
req.AddCookie(ck)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var resp map[string]any
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, "请求授权过于频繁,请稍后重试", resp["error_msg"])
}
}
+79 -37
View File
@@ -8,6 +8,9 @@ import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/core/extpoints"
"Wavelet/plugins/domain/auth/controller"
"Wavelet/plugins/domain/auth/dao"
"Wavelet/plugins/domain/auth/service"
"context"
"embed"
"reflect"
@@ -83,33 +86,60 @@ func (p *Plugin) DeclareConfig() []core.ConfigBinding {
// Apply registers the auth migrations, services, routes, and settings into the Context.
func (p *Plugin) Apply(ctx *core.Context) error {
var cfg SessionConfig
if err := ctx.Config().Bind("app", &cfg); err == nil {
SetSessionConfig(cfg)
if err := ctx.Config().Bind("app", &cfg); err != nil {
cfg = SessionConfig{
SessionCookieName: "wavelet_session",
SessionAge: 86400,
SessionHTTPOnly: true,
}
}
core.Bind[contracts.DBService](ctx, setDBService)
core.Bind[contracts.CacheService](ctx, setCacheService)
d := dao.New(nil, nil, nil)
core.Bind[contracts.DBService](ctx, d.SetDBService)
core.Bind[contracts.CacheService](ctx, d.SetCacheService)
core.Bind[contracts.LimiterService](ctx, d.SetLimiterService)
ctx.OnDispose(func() error {
setDBService(nil)
setCacheService(nil)
d.SetDBService(nil)
d.SetCacheService(nil)
d.SetLimiterService(nil)
return nil
})
var capSecret []byte
if cfg.SessionSecret != "" {
capSecret = []byte(cfg.SessionSecret)
}
svc := service.New(d, cfg, capSecret)
if p.authSvc != nil {
// Custom injected auth service override
core.Provide[contracts.AuthService](ctx, p.authSvc)
} else {
core.Provide[contracts.AuthService](ctx, svc.AuthSvc)
}
if p.authRegistry != nil {
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
} else {
core.Provide[contracts.AuthRegistry](ctx, svc.AuthRegistry)
}
ctrl := controller.New(svc)
setDefaultRuntime(d, svc, ctrl)
// Register CaptchaService
captchaSvc := service.NewCaptchaService(
svc.CapManager,
func(scope string) any { return ctrl.VerifyCaptcha(scope) },
ctrl.Captcha.Challenge,
ctrl.Captcha.Redeem,
)
core.Provide[contracts.CaptchaService](ctx, captchaSvc)
// 1. Register migrations
ctx.Migrations().Register("auth", authMigrations)
// 2. Initialize and provide AuthService & AuthRegistry
if p.authSvc == nil {
p.authSvc = newAuthService()
}
if p.authRegistry == nil {
p.authRegistry = newAuthRegistry()
}
core.Provide[contracts.AuthService](ctx, p.authSvc)
core.Provide[contracts.AuthRegistry](ctx, p.authRegistry)
// 2.1 Register Public / Auth Whitelist Endpoints
// 2. Register Public / Auth Whitelist Endpoints
publicEndpoints := []string{
"/api/v1/oauth/sources",
"/api/v1/oauth/login",
@@ -125,49 +155,61 @@ func (p *Plugin) Apply(ctx *core.Context) error {
"/api/healthz",
"/metrics",
}
RegisterWhitelist(publicEndpoints...)
ctrl.RegisterWhitelist(publicEndpoints...)
ctx.Router().RegisterWhitelist(publicEndpoints...)
// 3. Register HTTP Routes
oauthGroup := ctx.Router().Group("/api/v1/oauth")
{
oauthGroup.GET("/sources", GetLoginSources)
oauthGroup.GET("/login", GetLoginURL)
oauthGroup.GET("/:source/authorize", Authorize)
oauthGroup.GET("/logout", Logout)
oauthGroup.POST("/callback", Callback)
oauthGroup.GET("/user-info", LoginRequired(), UserInfo)
oauthGroup.GET("/external-accounts", LoginRequired(), ListExternalAccounts)
oauthGroup.POST("/external-accounts/:id/delete", LoginRequired(), DeleteExternalAccount)
}
ctx.Router().GET("/api/v1/user-info", LoginRequired(), UserInfo)
ctrl.RegisterRoutes(ctx.Router())
// 4. Register Settings Schemas
const (
settingTypeInteger = "integer"
settingCategorySecurity = "security"
)
ctx.Settings().Register(extpoints.SettingSchema{
Key: "auth.session_age",
Default: 86400 * 7,
Description: "Default session lifetime in seconds",
Type: "integer",
Category: "security",
Type: settingTypeInteger,
Category: settingCategorySecurity,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "auth.login_rate_limit_max_attempts",
Default: 5,
Description: "Max login failure attempts before temporary IP lock",
Type: "integer",
Category: "security",
Type: settingTypeInteger,
Category: settingCategorySecurity,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "cap.login_enabled",
Default: false,
Description: "Whether to require CAPTCHA verification for user login",
Type: "boolean",
Category: settingCategorySecurity,
})
ctx.Settings().Register(extpoints.SettingSchema{
Key: "cap.challenge_count",
Default: 1,
Description: "Number of PoW puzzle challenges to solve",
Type: settingTypeInteger,
Category: settingCategorySecurity,
})
// 5. Register Event Listeners for domain events
ctx.Events().On(contracts.EventTopicUserStatusChanged, func(c context.Context, e contracts.UserStatusChangedEvent) error {
InvalidateCachedUser(c, e.UserID)
svc.DAO.InvalidateCachedUser(c, e.UserID)
return nil
})
ctx.Events().On(contracts.EventTopicUserDeleted, func(c context.Context, e contracts.UserDeletedEvent) error {
InvalidateCachedUser(c, e.TargetUserID)
svc.DAO.InvalidateCachedUser(c, e.TargetUserID)
return nil
})
ctx.Events().On(contracts.EventTopicConfigChanged, func(_ any) {
svc.CapSettings.Invalidate()
})
return nil
}
@@ -62,6 +62,13 @@ func hashToken(token string) string {
return hex.EncodeToString(h.Sum(nil))
}
type testSystemConfig struct {
Key string `gorm:"primaryKey"`
Value string
}
func (testSystemConfig) TableName() string { return "w_system_configs" }
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "auth_test.db")
@@ -73,6 +80,7 @@ func setupTestDB(t *testing.T) *gorm.DB {
&testAccessToken{},
&auth.AuthSource{},
&auth.ExternalAccount{},
&testSystemConfig{},
))
return testDB
@@ -153,4 +161,25 @@ func TestAuthPluginUnit(t *testing.T) {
current, err := authSvc.GetCurrentUser(userCtx)
require.NoError(t, err)
assert.Equal(t, user.ID, current.ID)
// Test CaptchaService injection
capSvc, err := core.Inject[contracts.CaptchaService](ctx)
require.NoError(t, err)
assert.NotNil(t, capSvc)
assert.NotNil(t, capSvc.ChallengeHandler())
assert.NotNil(t, capSvc.RedeemHandler())
assert.NotNil(t, capSvc.VerifyMiddleware("login"))
// Verify CAPTCHA routes registered
var foundChallenge, foundRedeem bool
for _, rd := range ctx.Router().Routes() {
if rd.Path == "/api/v1/cap/challenge" {
foundChallenge = true
}
if rd.Path == "/api/v1/cap/redeem" {
foundRedeem = true
}
}
assert.True(t, foundChallenge, "expected /api/v1/cap/challenge route")
assert.True(t, foundRedeem, "expected /api/v1/cap/redeem route")
}
-211
View File
@@ -1,211 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core"
"Wavelet/core/contracts"
"Wavelet/pkg/util"
"context"
"sync"
"time"
"gorm.io/gorm"
)
var (
dbMu sync.RWMutex
dbSvc contracts.DBService
cacheMu sync.RWMutex
cacheSvc contracts.CacheService
)
func setDBService(s contracts.DBService) {
dbMu.Lock()
defer dbMu.Unlock()
dbSvc = s
}
func setCacheService(s contracts.CacheService) {
cacheMu.Lock()
defer cacheMu.Unlock()
cacheSvc = s
}
func getDB(ctx context.Context) *gorm.DB {
if s, err := core.InjectFrom[contracts.DBService](ctx); err == nil && s != nil {
return s.DB(ctx)
}
dbMu.RLock()
s := dbSvc
dbMu.RUnlock()
if s != nil {
return s.DB(ctx)
}
return nil
}
func getCache(ctx context.Context) contracts.CacheService {
if s, err := core.InjectFrom[contracts.CacheService](ctx); err == nil && s != nil {
return s
}
cacheMu.RLock()
s := cacheSvc
cacheMu.RUnlock()
return s
}
// GetAccessTokenByHash 按令牌哈希读取访问令牌记录(仅取鉴权所需字段)
func GetAccessTokenByHash(ctx context.Context, tokenHash string) (*CachedToken, error) {
var row struct {
ID uint64
UserID uint64
IsAdmin bool
}
if err := getDB(ctx).Table("w_access_tokens").Where("token_hash = ?", tokenHash).First(&row).Error; err != nil {
return nil, err
}
return &CachedToken{
ID: row.ID,
UserID: row.UserID,
IsAdmin: row.IsAdmin,
}, nil
}
// GetActiveUserByID 读取仍处于启用状态的用户
func GetActiveUserByID(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
var user contracts.UserDTO
if err := getDB(ctx).Table("w_users").Where("id = ? AND is_active = ?", userID, true).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID 按 ID 读取用户(不限制启用状态)
func GetUserByID(ctx context.Context, userID uint64) (*contracts.UserDTO, error) {
var user contracts.UserDTO
if err := getDB(ctx).Table("w_users").Where("id = ?", userID).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// InsertUser 新建用户记录
func InsertUser(ctx context.Context, user *contracts.UserDTO) error {
return getDB(ctx).Table("w_users").Create(user).Error
}
// TouchUserLastLogin 刷新用户最后登录时间
func TouchUserLastLogin(ctx context.Context, userID uint64, at time.Time) error {
return getDB(ctx).Table("w_users").Where("id = ?", userID).Update("last_login_at", at).Error
}
// ListSimilarUsernames 查询与基础用户名相同或带 `-序号` 后缀的用户名(用于用户名去重)
func ListSimilarUsernames(ctx context.Context, base string) ([]string, error) {
var existingUsernames []string
if err := getDB(ctx).Table("w_users").
Where("username = ? OR username LIKE ? ESCAPE '\\'", base, util.EscapeLike(base)+"-%").
Pluck("username", &existingUsernames).Error; err != nil {
return nil, err
}
return existingUsernames, nil
}
// GetSystemConfigValue 读取系统配置项原始值
func GetSystemConfigValue(ctx context.Context, key string) (string, error) {
var val string
if err := getDB(ctx).Table("w_system_configs").Where("key = ?", key).Pluck("value", &val).Error; err != nil {
return "", err
}
return val, nil
}
// ListAllAuthSources 获取全部认证源(含未启用),按 ID 升序
func ListAllAuthSources(ctx context.Context) ([]AuthSource, error) {
var sources []AuthSource
if err := getDB(ctx).Order("id ASC").Find(&sources).Error; err != nil {
return nil, err
}
return sources, nil
}
// GetAuthSourceByID 根据 ID 获取认证源
func GetAuthSourceByID(ctx context.Context, id uint64) (*AuthSource, error) {
var src AuthSource
if err := getDB(ctx).First(&src, id).Error; err != nil {
return nil, err
}
return &src, nil
}
// GetAuthSourceByName 根据名称获取认证源
func GetAuthSourceByName(ctx context.Context, name string) (*AuthSource, error) {
var src AuthSource
if err := getDB(ctx).Where("name = ?", name).First(&src).Error; err != nil {
return nil, err
}
return &src, nil
}
// ListActiveAuthSources 获取所有启用的认证源
func ListActiveAuthSources(ctx context.Context) ([]AuthSource, error) {
var sources []AuthSource
if err := getDB(ctx).Where("is_active = ?", true).Order("id ASC").Find(&sources).Error; err != nil {
return nil, err
}
return sources, nil
}
// CreateAuthSourceRecord 新建认证源记录
func CreateAuthSourceRecord(ctx context.Context, source *AuthSource) error {
return getDB(ctx).Create(source).Error
}
// SaveAuthSourceRecord 全量保存认证源记录
func SaveAuthSourceRecord(ctx context.Context, source *AuthSource) error {
return getDB(ctx).Save(source).Error
}
// DeleteAuthSourceRecord 删除认证源记录
func DeleteAuthSourceRecord(ctx context.Context, source *AuthSource) error {
return getDB(ctx).Delete(source).Error
}
// GetActiveAuthSourcesCached 获取所有启用的认证源(带缓存或直接查询)
func GetActiveAuthSourcesCached(ctx context.Context) ([]AuthSource, error) {
return ListActiveAuthSources(ctx)
}
// GetAuthSourceByNameCached 根据名称获取认证源(带缓存或直接查询)
func GetAuthSourceByNameCached(ctx context.Context, name string) (*AuthSource, error) {
return GetAuthSourceByName(ctx, name)
}
// FindExternalAccount 查询指定认证源的外部账号绑定
func FindExternalAccount(ctx context.Context, authSourceID uint64, externalID string) (*ExternalAccount, error) {
var account ExternalAccount
if err := getDB(ctx).Where("auth_source_id = ? AND external_id = ?", authSourceID, externalID).First(&account).Error; err != nil {
return nil, err
}
return &account, nil
}
// BindExternalAccount 绑定外部账号
func BindExternalAccount(ctx context.Context, account *ExternalAccount) error {
return getDB(ctx).Create(account).Error
}
// ListExternalAccountsByUserID 获取用户绑定的所有外部账号
func ListExternalAccountsByUserID(ctx context.Context, userID uint64) ([]ExternalAccount, error) {
var accounts []ExternalAccount
if err := getDB(ctx).Where("user_id = ?", userID).Find(&accounts).Error; err != nil {
return nil, err
}
return accounts, nil
}
// UnbindExternalAccount 解绑外部账号
func UnbindExternalAccount(ctx context.Context, id, userID uint64) error {
return getDB(ctx).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error
}
-262
View File
@@ -1,262 +0,0 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"Wavelet/core/contracts"
"context"
"errors"
"sync"
)
type authServiceImpl struct{}
func newAuthService() contracts.AuthService {
return &authServiceImpl{}
}
func (s *authServiceImpl) RequireAuthMiddleware() any {
return LoginRequired()
}
func (s *authServiceImpl) RequireAdminMiddleware() any {
return AdminRequired()
}
// GetCurrentUser 从 context 中读取登录用户。
//
// 中间件通过 gin 的 c.Set(contracts.AuthUserObjKey, user) 写入登录态;
// *gin.Context 自身实现了 context.Context,且其 Value(key) 对 string 类型 key
// 等价于 c.Get(key)(未命中时再回落到 Request.Context().Value),
// 因此这里无需感知 gin 即可读取同一份登录态。
func (s *authServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) {
if v := ctx.Value(contracts.AuthUserObjKey); v != nil {
if u, ok := v.(*contracts.UserDTO); ok && u != nil {
return u, nil
}
}
return nil, errors.New(errUserNotInContext)
}
func (s *authServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) {
if token == "" {
return nil, errors.New(errEmptyToken)
}
tokenHash := hashToken(token)
tokenRecord, err := GetCachedToken(ctx, tokenHash)
if err != nil {
tokenRecord, err = GetAccessTokenByHash(ctx, tokenHash)
if err != nil {
return nil, err
}
SetCachedToken(ctx, tokenHash, tokenRecord)
}
user, err := GetCachedUser(ctx, tokenRecord.UserID)
if err != nil || user == nil || !user.IsActive {
user, err = GetActiveUserByID(ctx, tokenRecord.UserID)
if err != nil {
return nil, err
}
SetCachedUser(ctx, tokenRecord.UserID, user)
}
if user.Username == SystemUsername {
return nil, errors.New(errSystemUserTokenNotAllowed)
}
return user, nil
}
func (s *authServiceImpl) CreateSession(_ context.Context, _ uint64, _ map[string]any) (string, error) {
return "", nil
}
func (s *authServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error {
InvalidateCachedUser(ctx, userID)
return nil
}
// GetCurrentUserID 从请求登录态中读取用户 ID。
//
// Session 读取依赖 gin,属于接入层职责,因此这里通过接入层桥接函数
// currentUserIDFromRequestContext(见 middleware.go)取值,Service 层本身不感知 gin。
func (s *authServiceImpl) GetCurrentUserID(ctx context.Context) (uint64, error) {
userID, ok := currentUserIDFromRequestContext(ctx)
if !ok {
return 0, errors.New(errUserNotInContext)
}
return userID, nil
}
func (s *authServiceImpl) RevokeToken(ctx context.Context, tokenHash string) error {
InvalidateCachedToken(ctx, tokenHash)
return nil
}
func (s *authServiceImpl) DisallowTokenAuthMiddleware() any {
return DisallowTokenAuth()
}
func (s *authServiceImpl) InvalidateCachedUser(ctx context.Context, userID uint64) {
InvalidateCachedUser(ctx, userID)
}
func (s *authServiceImpl) InvalidateCachedToken(ctx context.Context, tokenHash string) {
InvalidateCachedToken(ctx, tokenHash)
}
func (s *authServiceImpl) ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) {
sources, err := ListAllAuthSources(ctx)
if err != nil {
return nil, err
}
views := make([]contracts.AuthSourceViewDTO, len(sources))
for i := range sources {
views[i] = contracts.AuthSourceViewDTO{
ID: sources[i].ID,
Name: sources[i].Name,
Type: sources[i].Type,
DisplayName: sources[i].DisplayName,
IsActive: sources[i].IsActive,
IconURL: sources[i].IconURL,
ClientSecretConfigured: sources[i].ClientSecret != "",
}
}
return views, nil
}
func (s *authServiceImpl) CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
model := AuthSource{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
OpenIDDiscoveryURL: source.OpenIDDiscoveryURL,
Scopes: source.Scopes,
IconURL: source.IconURL,
IsActive: source.IsActive,
}
if err := model.Validate(); err != nil {
return nil, err
}
if err := CreateAuthSourceRecord(ctx, &model); err != nil {
return nil, err
}
model.Sanitize()
return toAuthSourceDTO(&model), nil
}
func (s *authServiceImpl) UpdateAuthSource(ctx context.Context, id uint64, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
existing, err := GetAuthSourceByID(ctx, id)
if err != nil {
return nil, err
}
existing.DisplayName = source.DisplayName
existing.ClientID = source.ClientID
if source.ClientSecret != "" {
existing.ClientSecret = source.ClientSecret
}
existing.OpenIDDiscoveryURL = source.OpenIDDiscoveryURL
existing.Scopes = source.Scopes
existing.IconURL = source.IconURL
if err := existing.Validate(); err != nil {
return nil, err
}
if err := SaveAuthSourceRecord(ctx, existing); err != nil {
return nil, err
}
existing.Sanitize()
return toAuthSourceDTO(existing), nil
}
func (s *authServiceImpl) DeleteAuthSource(ctx context.Context, id uint64) error {
existing, err := GetAuthSourceByID(ctx, id)
if err != nil {
return err
}
return DeleteAuthSourceRecord(ctx, existing)
}
func (s *authServiceImpl) ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) {
existing, err := GetAuthSourceByID(ctx, id)
if err != nil {
return nil, err
}
existing.IsActive = !existing.IsActive
if err := SaveAuthSourceRecord(ctx, existing); err != nil {
return nil, err
}
existing.Sanitize()
return toAuthSourceDTO(existing), nil
}
func toAuthSourceDTO(s *AuthSource) *contracts.AuthSourceDTO {
if s == nil {
return nil
}
return &contracts.AuthSourceDTO{
ID: s.ID,
Name: s.Name,
Type: s.Type,
DisplayName: s.DisplayName,
ClientID: s.ClientID,
ClientSecret: s.ClientSecret,
OpenIDDiscoveryURL: s.OpenIDDiscoveryURL,
Scopes: s.Scopes,
IconURL: s.IconURL,
IsActive: s.IsActive,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}
type authRegistryImpl struct {
mu sync.RWMutex
providers map[string]contracts.OAuthProvider
}
func newAuthRegistry() contracts.AuthRegistry {
return &authRegistryImpl{
providers: make(map[string]contracts.OAuthProvider),
}
}
func (r *authRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers[name] = provider
}
func (r *authRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.providers[name]
return p, ok
}
func (r *authRegistryImpl) ListOAuthProviders() []string {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]string, 0, len(r.providers))
for name := range r.providers {
res = append(res, name)
}
return res
}
@@ -0,0 +1,49 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package service implements domain business services and orchestration for the auth plugin.
package service
import (
"Wavelet/core/contracts"
"sync"
)
// AuthRegistryImpl implements contracts.AuthRegistry.
type AuthRegistryImpl struct {
mu sync.RWMutex
providers map[string]contracts.OAuthProvider
}
// NewAuthRegistry creates a new AuthRegistryImpl.
func NewAuthRegistry() *AuthRegistryImpl {
return &AuthRegistryImpl{
providers: make(map[string]contracts.OAuthProvider),
}
}
// RegisterOAuthProvider registers an OAuthProvider by name.
func (r *AuthRegistryImpl) RegisterOAuthProvider(name string, provider contracts.OAuthProvider) {
r.mu.Lock()
defer r.mu.Unlock()
r.providers[name] = provider
}
// GetOAuthProvider retrieves an OAuthProvider by name.
func (r *AuthRegistryImpl) GetOAuthProvider(name string) (contracts.OAuthProvider, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
p, ok := r.providers[name]
return p, ok
}
// ListOAuthProviders lists all registered provider names.
func (r *AuthRegistryImpl) ListOAuthProviders() []string {
r.mu.RLock()
defer r.mu.RUnlock()
res := make([]string, 0, len(r.providers))
for name := range r.providers {
res = append(res, name)
}
return res
}
@@ -0,0 +1,278 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package service implements domain business services and orchestration for the auth plugin.
package service
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/dao"
"Wavelet/plugins/domain/auth/model/entity"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
)
// HashToken computes SHA-256 hex digest of access token.
func HashToken(token string) string {
h := sha256.New()
h.Write([]byte(token))
return hex.EncodeToString(h.Sum(nil))
}
// UserIDExtractor extracts user ID from a request context.
type UserIDExtractor func(ctx context.Context) (uint64, bool)
// AuthServiceImpl implements contracts.AuthService.
type AuthServiceImpl struct {
dao *dao.DAO
requireAuthMiddleware any
requireAdminMiddleware any
disallowTokenMiddleware any
userIDExtractor UserIDExtractor
}
// NewAuthService creates a new AuthServiceImpl.
func NewAuthService(
d *dao.DAO,
requireAuth any,
requireAdmin any,
disallowToken any,
extractor UserIDExtractor,
) *AuthServiceImpl {
return &AuthServiceImpl{
dao: d,
requireAuthMiddleware: requireAuth,
requireAdminMiddleware: requireAdmin,
disallowTokenMiddleware: disallowToken,
userIDExtractor: extractor,
}
}
// SetMiddlewareHandlers wires middleware handlers into AuthService after controller initialization.
func (s *AuthServiceImpl) SetMiddlewareHandlers(requireAuth, requireAdmin, disallowToken any, extractor UserIDExtractor) {
s.requireAuthMiddleware = requireAuth
s.requireAdminMiddleware = requireAdmin
s.disallowTokenMiddleware = disallowToken
s.userIDExtractor = extractor
}
// RequireAuthMiddleware returns the authentication check middleware.
func (s *AuthServiceImpl) RequireAuthMiddleware() any {
return s.requireAuthMiddleware
}
// RequireAdminMiddleware returns the admin authorization middleware.
func (s *AuthServiceImpl) RequireAdminMiddleware() any {
return s.requireAdminMiddleware
}
// DisallowTokenAuthMiddleware returns the token rejection middleware.
func (s *AuthServiceImpl) DisallowTokenAuthMiddleware() any {
return s.disallowTokenMiddleware
}
// GetCurrentUser 从 context 中读取登录用户。
func (s *AuthServiceImpl) GetCurrentUser(ctx context.Context) (*contracts.UserDTO, error) {
if v := ctx.Value(contracts.AuthUserObjKey); v != nil {
if u, ok := v.(*contracts.UserDTO); ok && u != nil {
return u, nil
}
}
return nil, errors.New(consts.ErrUserNotInContext)
}
// GetCurrentUserID 从请求登录态中读取用户 ID。
func (s *AuthServiceImpl) GetCurrentUserID(ctx context.Context) (uint64, error) {
if s.userIDExtractor != nil {
if userID, ok := s.userIDExtractor(ctx); ok {
return userID, nil
}
}
return 0, errors.New(consts.ErrUserNotInContext)
}
// VerifyToken 验证访问令牌并返回对应的用户。
func (s *AuthServiceImpl) VerifyToken(ctx context.Context, token string) (*contracts.UserDTO, error) {
if token == "" {
return nil, errors.New(consts.ErrEmptyToken)
}
tokenHash := HashToken(token)
tokenRecord, err := s.dao.GetCachedToken(ctx, tokenHash)
if err != nil {
tokenRecord, err = s.dao.GetAccessTokenByHash(ctx, tokenHash)
if err != nil {
return nil, err
}
s.dao.SetCachedToken(ctx, tokenHash, tokenRecord)
}
user, err := s.dao.GetCachedUser(ctx, tokenRecord.UserID)
if err != nil || user == nil || !user.IsActive {
user, err = s.dao.GetActiveUserByID(ctx, tokenRecord.UserID)
if err != nil {
return nil, err
}
s.dao.SetCachedUser(ctx, tokenRecord.UserID, user)
}
if user.Username == consts.SystemUsername {
return nil, errors.New(consts.ErrSystemUserTokenNotAllowed)
}
return user, nil
}
// CreateSession establishes an authenticated session.
func (s *AuthServiceImpl) CreateSession(_ context.Context, _ uint64, _ map[string]any) (string, error) {
return "", nil
}
// RevokeUserSessions revokes active sessions and cached tokens for a user.
func (s *AuthServiceImpl) RevokeUserSessions(ctx context.Context, userID uint64) error {
s.dao.InvalidateCachedUser(ctx, userID)
return nil
}
// RevokeToken invalidates a cached token by its hash.
func (s *AuthServiceImpl) RevokeToken(ctx context.Context, tokenHash string) error {
s.dao.InvalidateCachedToken(ctx, tokenHash)
return nil
}
// InvalidateCachedUser invalidates cached user profile.
func (s *AuthServiceImpl) InvalidateCachedUser(ctx context.Context, userID uint64) {
s.dao.InvalidateCachedUser(ctx, userID)
}
// InvalidateCachedToken invalidates cached access token.
func (s *AuthServiceImpl) InvalidateCachedToken(ctx context.Context, tokenHash string) {
s.dao.InvalidateCachedToken(ctx, tokenHash)
}
// ListAuthSources lists all configured authentication sources.
func (s *AuthServiceImpl) ListAuthSources(ctx context.Context) ([]contracts.AuthSourceViewDTO, error) {
sources, err := s.dao.ListAllAuthSources(ctx)
if err != nil {
return nil, err
}
views := make([]contracts.AuthSourceViewDTO, len(sources))
for i := range sources {
views[i] = contracts.AuthSourceViewDTO{
ID: sources[i].ID,
Name: sources[i].Name,
Type: sources[i].Type,
DisplayName: sources[i].DisplayName,
IsActive: sources[i].IsActive,
IconURL: sources[i].IconURL,
ClientSecretConfigured: sources[i].ClientSecret != "",
}
}
return views, nil
}
// CreateAuthSource creates a new authentication source.
func (s *AuthServiceImpl) CreateAuthSource(ctx context.Context, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
model := entity.AuthSource{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
OpenIDDiscoveryURL: source.OpenIDDiscoveryURL,
Scopes: source.Scopes,
IconURL: source.IconURL,
IsActive: source.IsActive,
}
if err := model.Validate(); err != nil {
return nil, err
}
if err := s.dao.CreateAuthSource(ctx, &model); err != nil {
return nil, err
}
model.Sanitize()
return toAuthSourceDTO(&model), nil
}
// UpdateAuthSource updates an existing authentication source.
func (s *AuthServiceImpl) UpdateAuthSource(ctx context.Context, id uint64, source contracts.AuthSourceDTO) (*contracts.AuthSourceDTO, error) {
existing, err := s.dao.GetAuthSourceByID(ctx, id)
if err != nil {
return nil, err
}
existing.DisplayName = source.DisplayName
existing.ClientID = source.ClientID
if source.ClientSecret != "" {
existing.ClientSecret = source.ClientSecret
}
existing.OpenIDDiscoveryURL = source.OpenIDDiscoveryURL
existing.Scopes = source.Scopes
existing.IconURL = source.IconURL
if err := existing.Validate(); err != nil {
return nil, err
}
if err := s.dao.SaveAuthSource(ctx, existing); err != nil {
return nil, err
}
existing.Sanitize()
return toAuthSourceDTO(existing), nil
}
// DeleteAuthSource deletes an authentication source.
func (s *AuthServiceImpl) DeleteAuthSource(ctx context.Context, id uint64) error {
existing, err := s.dao.GetAuthSourceByID(ctx, id)
if err != nil {
return err
}
return s.dao.DeleteAuthSource(ctx, existing)
}
// ToggleAuthSource toggles active status of an authentication source.
func (s *AuthServiceImpl) ToggleAuthSource(ctx context.Context, id uint64) (*contracts.AuthSourceDTO, error) {
existing, err := s.dao.GetAuthSourceByID(ctx, id)
if err != nil {
return nil, err
}
existing.IsActive = !existing.IsActive
if err := s.dao.SaveAuthSource(ctx, existing); err != nil {
return nil, err
}
existing.Sanitize()
return toAuthSourceDTO(existing), nil
}
func toAuthSourceDTO(s *entity.AuthSource) *contracts.AuthSourceDTO {
if s == nil {
return nil
}
return &contracts.AuthSourceDTO{
ID: s.ID,
Name: s.Name,
Type: s.Type,
DisplayName: s.DisplayName,
ClientID: s.ClientID,
ClientSecret: s.ClientSecret,
OpenIDDiscoveryURL: s.OpenIDDiscoveryURL,
Scopes: s.Scopes,
IconURL: s.IconURL,
IsActive: s.IsActive,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
}
}
@@ -0,0 +1,194 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package service implements domain business services and orchestration for the auth plugin.
package service
import (
"Wavelet/core/contracts"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/model/dto"
"Wavelet/plugins/domain/auth/pow"
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// CaptchaManager orchestrates challenge generation and solution validation.
type CaptchaManager struct {
secret []byte
store pow.Store
settingsMgr *CapSettingsManager
}
// NewCaptchaManager creates a new CAPTCHA Manager.
func NewCaptchaManager(secret []byte, store pow.Store, settingsMgr *CapSettingsManager) *CaptchaManager {
return &CaptchaManager{
secret: secret,
store: store,
settingsMgr: settingsMgr,
}
}
// SetSecret updates the shared secret used for PoW generation and validation.
func (m *CaptchaManager) SetSecret(secret []byte) {
m.secret = secret
}
// Generate creates a challenge response.
func (m *CaptchaManager) Generate(ctx context.Context, scope string) (*pow.ChallengeResponse, error) {
settings, err := m.settingsMgr.Current(ctx)
if err != nil {
return nil, err
}
challengeConfig := pow.ChallengeConfig{
Count: settings.ChallengeCount,
Size: settings.ChallengeSize,
Difficulty: settings.ChallengeDifficulty,
Expires: settings.ChallengeTTL,
}
return pow.GenerateChallenge(m.secret, challengeConfig, scope)
}
// Redeem verifies PoW solutions and returns a one-time redeem token.
func (m *CaptchaManager) Redeem(ctx context.Context, token string, solutions []int, scope string) (*dto.RedeemResponse, error) {
sigHex := pow.JwtSigHex(token)
if sigHex == "" {
return &dto.RedeemResponse{Success: false, Error: consts.RedeemErrInvalidToken}, nil
}
nonceKey := "cap:nonce:" + sigHex
payload, err := pow.VerifyChallengeSolutions(token, solutions, m.secret, scope)
if err != nil {
return &dto.RedeemResponse{Success: false, Error: err.Error()}, nil //nolint:nilerr // validation errors returned as response
}
now := time.Now().UnixNano() / int64(time.Millisecond)
nonceTTL := time.Duration(payload.Expires-now) * time.Millisecond
if nonceTTL < time.Second {
nonceTTL = time.Second
}
set, err := m.store.SetNX(ctx, nonceKey, "1", nonceTTL)
if err != nil {
return &dto.RedeemResponse{Success: false, Error: consts.RedeemErrNonceStoreFailed}, err
}
if !set {
return &dto.RedeemResponse{Success: false, Error: consts.RedeemErrAlreadyRedeemed}, nil
}
settings, err := m.settingsMgr.Current(ctx)
if err != nil {
return &dto.RedeemResponse{Success: false, Error: consts.RedeemErrSettingsLoad}, err
}
id := pow.RandomHex(consts.RedeemTokenIDLength)
verToken := pow.RandomHex(consts.RedeemVerTokenLength)
verHashBytes := sha256.Sum256([]byte(verToken))
verHashHex := hex.EncodeToString(verHashBytes[:])
tokenKey := "cap:token:" + id + ":" + verHashHex
tokenExpires := time.Now().Add(settings.TokenTTL)
storeVal := strconv.FormatInt(tokenExpires.UnixNano(), 10) + "|" + scope
if err := m.store.Set(ctx, tokenKey, storeVal, settings.TokenTTL); err != nil {
return &dto.RedeemResponse{Success: false, Error: consts.RedeemErrTokenStoreFailed}, err
}
return &dto.RedeemResponse{
Success: true,
Token: id + ":" + verToken,
Expires: tokenExpires.UnixNano() / int64(time.Millisecond),
}, nil
}
// VerifyToken validates and consumes the redeem token (single-use).
func (m *CaptchaManager) VerifyToken(ctx context.Context, token, expectedScope string) (bool, error) {
if token == "" {
return false, nil
}
parts := strings.Split(token, ":")
if len(parts) != consts.TokenPartsCount {
return false, nil
}
id := parts[0]
verToken := parts[1]
verHashBytes := sha256.Sum256([]byte(verToken))
verHashHex := hex.EncodeToString(verHashBytes[:])
tokenKey := "cap:token:" + id + ":" + verHashHex
if m.store == nil {
return false, nil
}
val, exists, err := m.store.GetAndDelete(ctx, tokenKey)
if err != nil {
return false, err
}
if !exists {
return false, nil
}
valParts := strings.Split(val, "|")
if len(valParts) != consts.ValuePartsCount {
return false, nil
}
expNano, err := strconv.ParseInt(valParts[0], 10, 64)
if err != nil {
return false, nil //nolint:nilerr // invalid format is failure
}
tokenScope := valParts[1]
if expectedScope != "" && tokenScope != expectedScope {
return false, nil
}
if time.Now().UnixNano() > expNano {
return false, nil
}
return true, nil
}
// CaptchaServiceImpl implements contracts.CaptchaService.
type CaptchaServiceImpl struct {
manager *CaptchaManager
verifyMiddleware func(scope string) any
challengeHandler any
redeemHandler any
}
// NewCaptchaService creates a new CaptchaServiceImpl.
func NewCaptchaService(mgr *CaptchaManager, verifyMiddleware func(scope string) any, challengeHandler any, redeemHandler any) contracts.CaptchaService {
return &CaptchaServiceImpl{
manager: mgr,
verifyMiddleware: verifyMiddleware,
challengeHandler: challengeHandler,
redeemHandler: redeemHandler,
}
}
// VerifyMiddleware returns the captcha verification middleware.
func (s *CaptchaServiceImpl) VerifyMiddleware(scope string) any {
if s.verifyMiddleware != nil {
return s.verifyMiddleware(scope)
}
return nil
}
// ChallengeHandler returns the challenge HTTP handler.
func (s *CaptchaServiceImpl) ChallengeHandler() any {
return s.challengeHandler
}
// RedeemHandler returns the redeem HTTP handler.
func (s *CaptchaServiceImpl) RedeemHandler() any {
return s.redeemHandler
}

Some files were not shown because too many files have changed in this diff Show More